diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 4045252..537a452 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -19,6 +19,7 @@ jobs: - mileograph outputs: base_version: ${{ steps.meta.outputs.base }} + release_tag: ${{ steps.meta.outputs.release_tag }} steps: - name: Checkout uses: actions/checkout@v4 @@ -29,6 +30,7 @@ jobs: RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml) BASE_VERSION=${RAW_VERSION%%+*} echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT" + echo "release_tag=v${BASE_VERSION}" >> "$GITHUB_OUTPUT" android-build: runs-on: @@ -308,7 +310,7 @@ jobs: id: bundle run: | BASE="${{ needs.meta.outputs.base_version }}" - TAG="v${BASE}" + TAG="${{ needs.meta.outputs.release_tag }}" echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "apk=artifacts/mileograph-${BASE}.apk" >> "$GITHUB_OUTPUT" diff --git a/lib/components/pages/loco_timeline.dart b/lib/components/pages/loco_timeline.dart index 84c46ee..8131145 100644 --- a/lib/components/pages/loco_timeline.dart +++ b/lib/components/pages/loco_timeline.dart @@ -21,6 +21,9 @@ class LocoTimelinePage extends StatefulWidget { } class _LocoTimelinePageState extends State { + final List<_EventDraft> _draftEvents = []; + bool _isSaving = false; + @override void initState() { super.initState(); @@ -28,7 +31,129 @@ class _LocoTimelinePageState extends State { } Future _load() { - return context.read().fetchLocoTimeline(widget.locoId); + final data = context.read(); + data.fetchEventFields(); + return data.fetchLocoTimeline(widget.locoId); + } + + void _addDraftEvent() { + setState(() { + _draftEvents.add(_EventDraft()); + }); + } + + Future _saveEvents() async { + if (_isSaving) return; + if (!_canSaveDrafts()) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please fix validation issues before saving.')), + ); + return; + } + final data = context.read(); + setState(() { + _isSaving = true; + }); + try { + final invalid = []; + for (final draft in _draftEvents) { + final dateStr = draft.dateController.text.trim(); + if (!_isValidDateString(dateStr)) { + invalid.add('Date is invalid (${dateStr.isEmpty ? 'empty' : dateStr})'); + continue; + } + if (draft.fields.isEmpty) { + invalid.add('Add at least one field for each event'); + continue; + } + final values = {}; + for (final field in draft.fields) { + final val = field.value; + final isBlankString = val is String && val.trim().isEmpty; + if (val == null || isBlankString) { + invalid.add('Field ${field.field.display} is empty'); + break; + } + values[field.field.name] = val; + } + if (invalid.isNotEmpty) continue; + if (values.isEmpty) { + invalid.add('Add at least one value'); + continue; + } + await data.createLocoEvent( + locoId: widget.locoId, + eventDate: dateStr, + values: values, + details: draft.details, + ); + } + if (invalid.isNotEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(invalid.first)), + ); + } + return; + } + _draftEvents.clear(); + await _load(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Events saved')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to save events: $e')), + ); + } + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + bool _isValidDateString(String input) { + final trimmed = input.trim(); + final regex = RegExp(r'^\d{4}-(\d{2}|xx|XX)-(\d{2}|xx|XX)$'); + if (!regex.hasMatch(trimmed)) return false; + final parts = trimmed.split('-'); + final monthPart = parts[1]; + final dayPart = parts[2]; + final monthUnknown = monthPart.toLowerCase() == 'xx'; + final dayUnknown = dayPart.toLowerCase() == 'xx'; + if (monthUnknown && !dayUnknown) return false; + if (!monthUnknown) { + final month = int.tryParse(monthPart); + if (month == null || month < 1 || month > 12) return false; + } + if (!dayUnknown) { + final day = int.tryParse(dayPart); + if (day == null || day < 1 || day > 31) return false; + } + return true; + } + + bool _draftIsValid(_EventDraft draft) { + final dateStr = draft.dateController.text.trim(); + if (!_isValidDateString(dateStr)) return false; + if (draft.fields.isEmpty) return false; + for (final field in draft.fields) { + final val = field.value; + if (val == null) return false; + if (val is String && val.trim().isEmpty) return false; + } + return true; + } + + bool _canSaveDrafts() { + if (_draftEvents.isEmpty) return false; + return _draftEvents.every(_draftIsValid); } @override @@ -83,9 +208,23 @@ class _LocoTimelinePageState extends State { ), ); } - return _TimelineGrid( - entries: timeline, - maxHeight: constraints.maxHeight, + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _TimelineGrid( + entries: timeline, + ), + const SizedBox(height: 16), + _EventEditor( + eventFields: data.eventFields, + drafts: _draftEvents, + onAddEvent: _addDraftEvent, + onChange: () => setState(() {}), + onSave: _saveEvents, + isSaving: _isSaving, + canSave: _canSaveDrafts(), + ), + ], ); }, ), @@ -97,11 +236,9 @@ class _LocoTimelinePageState extends State { class _TimelineGrid extends StatefulWidget { const _TimelineGrid({ required this.entries, - required this.maxHeight, }); final List entries; - final double maxHeight; @override State<_TimelineGrid> createState() => _TimelineGridState(); @@ -175,13 +312,7 @@ class _TimelineGridState extends State<_TimelineGrid> { final rows = model.attrRows.entries.toList(); final totalRowsHeight = rows.length * rowHeight; final axisWidth = math.max(model.axisTotalWidth, 120.0); - final paddingTop = MediaQuery.of(context).padding.top; - final double constraintHeight = widget.maxHeight.isFinite - ? widget.maxHeight - : MediaQuery.of(context).size.height; - final double availableHeight = - (constraintHeight - paddingTop - 24).clamp(axisHeight + 40, double.infinity); - final double viewHeight = availableHeight; + final double viewHeight = totalRowsHeight + axisHeight + 8; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -794,6 +925,337 @@ class _ValueBlock { } } +class _EventEditor extends StatelessWidget { + const _EventEditor({ + required this.eventFields, + required this.drafts, + required this.onAddEvent, + required this.onChange, + required this.onSave, + required this.isSaving, + required this.canSave, + }); + + final List eventFields; + final List<_EventDraft> drafts; + final VoidCallback onAddEvent; + final VoidCallback onChange; + final Future Function() onSave; + final bool isSaving; + final bool canSave; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Add events', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Row( + children: [ + OutlinedButton.icon( + onPressed: onAddEvent, + icon: const Icon(Icons.add), + label: const Text('New event'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: (!canSave || isSaving) ? null : onSave, + icon: isSaving + ? const SizedBox( + height: 14, + width: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save), + label: Text(isSaving ? 'Saving...' : 'Save all'), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + if (drafts.isEmpty) + const Text('No events yet. Add one to propose new values.') + else + ...drafts.asMap().entries.map( + (entry) { + final idx = entry.key; + final draft = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Event ${idx + 1}', + style: Theme.of(context).textTheme.titleMedium, + ), + const Spacer(), + IconButton( + tooltip: 'Remove', + onPressed: () { + drafts.removeAt(idx); + onChange(); + }, + icon: const Icon(Icons.delete_outline), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: draft.dateController, + onChanged: (_) => onChange(), + decoration: const InputDecoration( + labelText: 'Date (YYYY-MM-DD, MM/DD can be XX)', + border: OutlineInputBorder(), + ), + ), + ), + IconButton( + tooltip: 'Pick date', + onPressed: () async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: draft.date ?? now, + firstDate: DateTime(1900), + lastDate: DateTime(now.year + 10), + ); + if (picked != null) { + draft.date = picked; + draft.dateController.text = + DateFormat('yyyy-MM-dd').format(picked); + onChange(); + } + }, + icon: const Icon(Icons.calendar_month), + ), + ], + ), + const SizedBox(height: 8), + _FieldList( + draft: draft, + eventFields: eventFields, + onChange: onChange, + ), + const SizedBox(height: 12), + TextField( + controller: draft.detailsController, + onChanged: (val) { + draft.details = val; + onChange(); + }, + decoration: const InputDecoration( + labelText: 'Commit message / details', + border: OutlineInputBorder(), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ], + ); + } +} + +class _FieldList extends StatelessWidget { + const _FieldList({ + required this.draft, + required this.eventFields, + required this.onChange, + }); + + final _EventDraft draft; + final List eventFields; + final VoidCallback onChange; + + @override + Widget build(BuildContext context) { + final usedNames = draft.fields.map((f) => f.field.name).toSet(); + final availableFields = + eventFields.where((f) => !usedNames.contains(f.name)).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Fields', + style: Theme.of(context).textTheme.titleSmall, + ), + const Spacer(), + DropdownButton( + hint: const Text('Add field'), + value: null, + onChanged: (field) { + if (field == null) return; + draft.fields.add(_FieldEntry(field: field)); + onChange(); + }, + items: availableFields + .map( + (f) => DropdownMenuItem( + value: f, + child: Text(f.display), + ), + ) + .toList(), + ), + ], + ), + const SizedBox(height: 8), + if (draft.fields.isEmpty) + const Text('No fields added yet.') + else + ...draft.fields.asMap().entries.map( + (entry) { + final idx = entry.key; + final field = entry.value; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + field.field.display, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + _FieldInput( + field: field.field, + value: field.value, + onChanged: (val) { + field.value = val; + onChange(); + }, + ), + ], + ), + ), + IconButton( + onPressed: () { + draft.fields.removeAt(idx); + onChange(); + }, + icon: const Icon(Icons.close), + ), + ], + ), + ); + }, + ), + ], + ); + } +} + +class _FieldInput extends StatelessWidget { + const _FieldInput({ + required this.field, + required this.value, + required this.onChanged, + }); + + final EventField field; + final dynamic value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (field.enumValues != null && field.enumValues!.isNotEmpty) { + final options = field.enumValues!; + return DropdownButtonFormField( + value: value is String && options.contains(value) ? value : null, + decoration: const InputDecoration(border: OutlineInputBorder()), + items: options + .map((v) => DropdownMenuItem(value: v, child: Text(v))) + .toList(), + onChanged: (val) => onChanged(val), + hint: const Text('Select value'), + ); + } + + final type = field.type?.toLowerCase(); + if (type == 'bool' || type == 'boolean') { + final bool? current = + value is bool ? value : (value is String ? value == 'true' : null); + return DropdownButtonFormField( + value: current, + decoration: const InputDecoration(border: OutlineInputBorder()), + items: const [ + DropdownMenuItem(value: true, child: Text('Yes')), + DropdownMenuItem(value: false, child: Text('No')), + ], + onChanged: (val) => onChanged(val), + hint: const Text('Select'), + ); + } + + final isNumber = type == 'int' || type == 'integer'; + return TextFormField( + initialValue: value?.toString(), + onChanged: (val) { + if (isNumber) { + final parsed = int.tryParse(val); + onChanged(parsed); + } else { + onChanged(val); + } + }, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'Enter value', + ), + keyboardType: isNumber ? TextInputType.number : TextInputType.text, + ); + } +} + +class _EventDraft { + DateTime? date; + String details = ''; + final TextEditingController detailsController = TextEditingController(); + final TextEditingController dateController = TextEditingController(); + final List<_FieldEntry> fields = []; + + _EventDraft(); +} + +class _FieldEntry { + final EventField field; + dynamic value; + + _FieldEntry({required this.field, this.value}); +} + Color _colorForValue(String value) { final hue = (value.hashCode % 360).toDouble(); final hsl = HSLColor.fromAHSL(1, hue, 0.55, 0.55); diff --git a/lib/services/apiService.dart b/lib/services/apiService.dart index bfb9893..09035d5 100644 --- a/lib/services/apiService.dart +++ b/lib/services/apiService.dart @@ -97,10 +97,7 @@ class ApiService { return body; } - if (res.statusCode == 401 && - body is Map && - body['detail'] == 'Not authenticated' && - _onUnauthorized != null) { + if (res.statusCode == 401 && _onUnauthorized != null) { await _onUnauthorized!(); } diff --git a/lib/services/dataService.dart b/lib/services/dataService.dart index 6422a76..c366705 100644 --- a/lib/services/dataService.dart +++ b/lib/services/dataService.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; @@ -441,6 +442,30 @@ class DataService extends ChangeNotifier { return _locoClasses; } + Future createLocoEvent({ + required int locoId, + required String eventDate, + required Map values, + required String details, + String eventType = 'other', + }) async { + try { + await api.put( + '/event/new', + { + 'loco_id': locoId, + 'loco_event_type': eventType, + 'loco_event_date': eventDate, + 'loco_event_value': jsonEncode(values), + 'loco_event_details': details, + }, + ); + } catch (e) { + debugPrint('Failed to create loco event: $e'); + rethrow; + } + } + void clear() { _homepageStats = null; _legs = []; diff --git a/pubspec.yaml b/pubspec.yaml index 69d2f32..462dda2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.1.6+1 +version: 0.2.0+1 environment: sdk: ^3.8.1