Compare commits
5 Commits
0.7.2-dev.
...
0.7.3-dev.
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b7ec31e5d | |||
| e9b328e7e6 | |||
| 45042b5001 | |||
| 5c0043146f | |||
| 91f5391684 |
@@ -133,6 +133,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
|
|||||||
bool _loadingStations = false;
|
bool _loadingStations = false;
|
||||||
|
|
||||||
RouteResult? _routeResult;
|
RouteResult? _routeResult;
|
||||||
|
List<String>? _calculatedStations;
|
||||||
RouteResult? get result => _routeResult;
|
RouteResult? get result => _routeResult;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
@@ -178,6 +179,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
|
|||||||
if (cleaned.length < 2) {
|
if (cleaned.length < 2) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_routeResult = null;
|
_routeResult = null;
|
||||||
|
_calculatedStations = null;
|
||||||
_errorMessage = 'Add at least two stations before calculating.';
|
_errorMessage = 'Add at least two stations before calculating.';
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -185,6 +187,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
_routeResult = null;
|
_routeResult = null;
|
||||||
|
_calculatedStations = null;
|
||||||
});
|
});
|
||||||
final api = context.read<ApiService>(); // context is valid here
|
final api = context.read<ApiService>(); // context is valid here
|
||||||
try {
|
try {
|
||||||
@@ -195,6 +198,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
|
|||||||
if (res is Map && res['error'] == false) {
|
if (res is Map && res['error'] == false) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_routeResult = RouteResult.fromJson(Map<String, dynamic>.from(res));
|
_routeResult = RouteResult.fromJson(Map<String, dynamic>.from(res));
|
||||||
|
_calculatedStations = List.from(cleaned);
|
||||||
});
|
});
|
||||||
final distance = (_routeResult?.distance ?? 0);
|
final distance = (_routeResult?.distance ?? 0);
|
||||||
widget.onDistanceComputed?.call(distance);
|
widget.onDistanceComputed?.call(distance);
|
||||||
@@ -205,17 +209,30 @@ class _RouteCalculatorState extends State<RouteCalculator> {
|
|||||||
).msg;
|
).msg;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setState(() => _errorMessage = 'Failed to calculate route.');
|
setState(() {
|
||||||
|
_errorMessage = 'Failed to calculate route.';
|
||||||
|
_calculatedStations = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _errorMessage = 'Failed to calculate route: $e');
|
setState(() {
|
||||||
|
_errorMessage = 'Failed to calculate route: $e';
|
||||||
|
_calculatedStations = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _markRouteDirty() {
|
||||||
|
_routeResult = null;
|
||||||
|
_calculatedStations = null;
|
||||||
|
_errorMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
void _addStation() {
|
void _addStation() {
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
setState(() {
|
setState(() {
|
||||||
data.stations.add('');
|
data.stations.add('');
|
||||||
|
_markRouteDirty();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +240,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
|
|||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
setState(() {
|
setState(() {
|
||||||
data.stations.removeAt(index);
|
data.stations.removeAt(index);
|
||||||
|
_markRouteDirty();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +248,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
|
|||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
setState(() {
|
setState(() {
|
||||||
data.stations[index] = value;
|
data.stations[index] = value;
|
||||||
|
_markRouteDirty();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,14 +256,91 @@ class _RouteCalculatorState extends State<RouteCalculator> {
|
|||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
setState(() {
|
setState(() {
|
||||||
data.stations = [''];
|
data.stations = [''];
|
||||||
_routeResult = null;
|
_markRouteDirty();
|
||||||
_errorMessage = null;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isResultCurrent(List<String> stations) {
|
||||||
|
if (_routeResult == null || _calculatedStations == null) return false;
|
||||||
|
final cleaned = stations.where((s) => s.trim().isNotEmpty).toList();
|
||||||
|
if (cleaned.length != _calculatedStations!.length) return false;
|
||||||
|
for (var i = 0; i < cleaned.length; i++) {
|
||||||
|
if (cleaned[i] != _calculatedStations![i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final data = context.watch<DataService>();
|
final data = context.watch<DataService>();
|
||||||
|
final isCompact = MediaQuery.of(context).size.width < 600;
|
||||||
|
final showApply =
|
||||||
|
widget.onApplyRoute != null && _isResultCurrent(data.stations);
|
||||||
|
final primaryPadding = EdgeInsets.symmetric(
|
||||||
|
horizontal: isCompact ? 14 : 20,
|
||||||
|
vertical: isCompact ? 10 : 14,
|
||||||
|
);
|
||||||
|
final secondaryPadding = EdgeInsets.symmetric(
|
||||||
|
horizontal: isCompact ? 10 : 16,
|
||||||
|
vertical: isCompact ? 8 : 12,
|
||||||
|
);
|
||||||
|
final primaryStyle = FilledButton.styleFrom(
|
||||||
|
padding: primaryPadding,
|
||||||
|
minimumSize: Size(0, isCompact ? 38 : 46),
|
||||||
|
);
|
||||||
|
final secondaryStyle = OutlinedButton.styleFrom(
|
||||||
|
padding: secondaryPadding,
|
||||||
|
minimumSize: Size(0, isCompact ? 34 : 42),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget buildSecondaryButton({
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required VoidCallback onPressed,
|
||||||
|
}) {
|
||||||
|
if (isCompact) {
|
||||||
|
return Tooltip(
|
||||||
|
message: label,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: onPressed,
|
||||||
|
style: secondaryStyle,
|
||||||
|
child: Icon(icon, size: 20),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return OutlinedButton.icon(
|
||||||
|
onPressed: onPressed,
|
||||||
|
icon: Icon(icon, size: 20),
|
||||||
|
label: Text(label),
|
||||||
|
style: secondaryStyle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildPrimaryAction({required bool fullWidth}) {
|
||||||
|
final button = showApply
|
||||||
|
? FilledButton.icon(
|
||||||
|
onPressed: () => widget.onApplyRoute!(_routeResult!),
|
||||||
|
icon: const Icon(Icons.check),
|
||||||
|
label: const Text('Apply to entry'),
|
||||||
|
style: primaryStyle,
|
||||||
|
)
|
||||||
|
: FilledButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
await _calculateRoute(data.stations);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.route),
|
||||||
|
label: const Text('Calculate Route'),
|
||||||
|
style: primaryStyle,
|
||||||
|
);
|
||||||
|
final key =
|
||||||
|
ValueKey<String>(showApply ? 'apply-primary-action' : 'calc-primary-action');
|
||||||
|
if (!fullWidth) return KeyedSubtree(key: key, child: button);
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: key,
|
||||||
|
child: SizedBox(width: double.infinity, child: button),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Align(
|
Align(
|
||||||
@@ -301,6 +397,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
final moved = data.stations.removeAt(oldIndex);
|
final moved = data.stations.removeAt(oldIndex);
|
||||||
data.stations.insert(newIndex, moved);
|
data.stations.insert(newIndex, moved);
|
||||||
|
_markRouteDirty();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
children: List.generate(data.stations.length, (index) {
|
children: List.generate(data.stations.length, (index) {
|
||||||
@@ -364,56 +461,94 @@ class _RouteCalculatorState extends State<RouteCalculator> {
|
|||||||
context.push('/calculator/details', extra: result);
|
context.push('/calculator/details', extra: result);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (widget.onApplyRoute != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: () => widget.onApplyRoute!(_routeResult!),
|
|
||||||
icon: const Icon(Icons.check),
|
|
||||||
label: const Text('Apply to entry'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
else
|
else
|
||||||
SizedBox.shrink(),
|
SizedBox.shrink(),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
child: Wrap(
|
child: isCompact
|
||||||
alignment: WrapAlignment.center,
|
? Column(
|
||||||
spacing: 12,
|
children: [
|
||||||
runSpacing: 8,
|
Row(
|
||||||
children: [
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
...(() {
|
children: [
|
||||||
final reverseButton = ElevatedButton.icon(
|
buildSecondaryButton(
|
||||||
icon: const Icon(Icons.swap_horiz),
|
icon: Icons.swap_horiz,
|
||||||
label: const Text('Reverse route'),
|
label: 'Reverse route',
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
setState(() {
|
setState(() {
|
||||||
data.stations = data.stations.reversed.toList();
|
data.stations = data.stations.reversed.toList();
|
||||||
});
|
});
|
||||||
await _calculateRoute(data.stations);
|
await _calculateRoute(data.stations);
|
||||||
},
|
},
|
||||||
);
|
),
|
||||||
final addButton = ElevatedButton.icon(
|
const SizedBox(width: 12),
|
||||||
icon: const Icon(Icons.add),
|
buildSecondaryButton(
|
||||||
label: const Text('Add Station'),
|
icon: Icons.add,
|
||||||
onPressed: _addStation,
|
label: 'Add station',
|
||||||
);
|
onPressed: _addStation,
|
||||||
final calculateButton = ElevatedButton.icon(
|
),
|
||||||
icon: const Icon(Icons.route),
|
],
|
||||||
label: const Text('Calculate Route'),
|
),
|
||||||
onPressed: () async {
|
const SizedBox(height: 10),
|
||||||
await _calculateRoute(data.stations);
|
SizedBox(
|
||||||
},
|
width: double.infinity,
|
||||||
);
|
child: AnimatedSwitcher(
|
||||||
final isMobile = MediaQuery.of(context).size.width < 600;
|
duration: const Duration(milliseconds: 220),
|
||||||
return isMobile
|
transitionBuilder: (child, animation) {
|
||||||
? [addButton, reverseButton, calculateButton]
|
final curved = CurvedAnimation(
|
||||||
: [reverseButton, addButton, calculateButton];
|
parent: animation,
|
||||||
})(),
|
curve: Curves.easeOutBack,
|
||||||
],
|
);
|
||||||
),
|
return ScaleTransition(
|
||||||
|
scale:
|
||||||
|
Tween<double>(begin: 0.94, end: 1.0).animate(curved),
|
||||||
|
child: FadeTransition(opacity: animation, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: buildPrimaryAction(fullWidth: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Wrap(
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
buildSecondaryButton(
|
||||||
|
icon: Icons.swap_horiz,
|
||||||
|
label: 'Reverse route',
|
||||||
|
onPressed: () async {
|
||||||
|
setState(() {
|
||||||
|
data.stations = data.stations.reversed.toList();
|
||||||
|
});
|
||||||
|
await _calculateRoute(data.stations);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
transitionBuilder: (child, animation) {
|
||||||
|
final curved = CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOutBack,
|
||||||
|
);
|
||||||
|
return ScaleTransition(
|
||||||
|
scale:
|
||||||
|
Tween<double>(begin: 0.94, end: 1.0).animate(curved),
|
||||||
|
child: FadeTransition(opacity: animation, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: buildPrimaryAction(fullWidth: false),
|
||||||
|
),
|
||||||
|
buildSecondaryButton(
|
||||||
|
icon: Icons.add,
|
||||||
|
label: 'Add station',
|
||||||
|
onPressed: _addStation,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:mileograph_flutter/objects/objects.dart';
|
|||||||
import 'package:mileograph_flutter/services/authservice.dart';
|
import 'package:mileograph_flutter/services/authservice.dart';
|
||||||
import 'package:mileograph_flutter/services/data_service.dart';
|
import 'package:mileograph_flutter/services/data_service.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
part 'loco_timeline/timeline_grid.dart';
|
part 'loco_timeline/timeline_grid.dart';
|
||||||
part 'loco_timeline/event_editor.dart';
|
part 'loco_timeline/event_editor.dart';
|
||||||
@@ -17,25 +18,41 @@ class LocoTimelinePage extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.locoId,
|
required this.locoId,
|
||||||
required this.locoLabel,
|
required this.locoLabel,
|
||||||
|
this.forceShowPending = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int locoId;
|
final int locoId;
|
||||||
final String locoLabel;
|
final String locoLabel;
|
||||||
|
final bool forceShowPending;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<LocoTimelinePage> createState() => _LocoTimelinePageState();
|
State<LocoTimelinePage> createState() => _LocoTimelinePageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
||||||
|
static const String _prefsKeyShowPending = 'timeline_show_pending';
|
||||||
|
|
||||||
final List<_EventDraft> _draftEvents = [];
|
final List<_EventDraft> _draftEvents = [];
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
bool _isDeleting = false;
|
bool _isDeleting = false;
|
||||||
bool _isModerating = false;
|
final Set<int> _moderatingEventIds = {};
|
||||||
|
final Set<String> _expandedPendingAttrs = {};
|
||||||
|
bool _showPending = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
if (widget.forceShowPending) {
|
||||||
|
setState(() {
|
||||||
|
_showPending = true;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await _restorePendingVisibility();
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
await _load();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
dynamic _normalizeFieldValue(_FieldEntry field) {
|
dynamic _normalizeFieldValue(_FieldEntry field) {
|
||||||
@@ -65,10 +82,35 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
|||||||
data.fetchEventFields();
|
data.fetchEventFields();
|
||||||
return data.fetchLocoTimeline(
|
return data.fetchLocoTimeline(
|
||||||
widget.locoId,
|
widget.locoId,
|
||||||
includeAllPending: auth.isElevated,
|
includeAllPending: auth.isElevated && _showPending,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _restorePendingVisibility() async {
|
||||||
|
final auth = context.read<AuthService>();
|
||||||
|
if (!auth.isElevated) return;
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final saved = prefs.getBool(_prefsKeyShowPending);
|
||||||
|
if (saved == null) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_showPending = saved;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore preference restore failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistPendingVisibility(bool value) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_prefsKeyShowPending, value);
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore persistence failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _addDraftEvent() {
|
void _addDraftEvent() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_draftEvents.add(_EventDraft());
|
_draftEvents.add(_EventDraft());
|
||||||
@@ -247,7 +289,6 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
|||||||
LocoAttrVersion entry,
|
LocoAttrVersion entry,
|
||||||
_PendingModerationAction action,
|
_PendingModerationAction action,
|
||||||
) async {
|
) async {
|
||||||
if (_isModerating) return;
|
|
||||||
final eventId = entry.sourceEventId;
|
final eventId = entry.sourceEventId;
|
||||||
if (eventId == null) {
|
if (eventId == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -257,6 +298,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (_moderatingEventIds.contains(eventId)) return;
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
final approve = action == _PendingModerationAction.approve;
|
final approve = action == _PendingModerationAction.approve;
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
@@ -283,7 +325,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
|||||||
if (ok != true || !mounted) return;
|
if (ok != true || !mounted) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isModerating = true;
|
_moderatingEventIds.add(eventId);
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
if (approve) {
|
if (approve) {
|
||||||
@@ -310,7 +352,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
|||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isModerating = false;
|
_moderatingEventIds.remove(eventId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -499,7 +541,11 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final data = context.watch<DataService>();
|
final data = context.watch<DataService>();
|
||||||
final timeline = data.timelineForLoco(widget.locoId);
|
final timeline = data.timelineForLoco(widget.locoId);
|
||||||
|
final isElevated = context.select<AuthService, bool>((auth) => auth.isElevated);
|
||||||
final isLoading = data.isLocoTimelineLoading(widget.locoId);
|
final isLoading = data.isLocoTimelineLoading(widget.locoId);
|
||||||
|
final visibleTimeline = (!isElevated || _showPending)
|
||||||
|
? timeline
|
||||||
|
: timeline.where((entry) => !entry.isPending).toList();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -516,7 +562,32 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
|||||||
if (isLoading && timeline.isEmpty) {
|
if (isLoading && timeline.isEmpty) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
if (timeline.isEmpty) {
|
if (visibleTimeline.isEmpty) {
|
||||||
|
if (timeline.isNotEmpty && isElevated && !_showPending) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Pending entries hidden',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Enable "Show pending entries" to view pending timeline blocks.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Card(
|
child: Card(
|
||||||
@@ -550,15 +621,49 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
|
|||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
|
if (isElevated)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SwitchListTile.adaptive(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Show pending entries'),
|
||||||
|
value: _showPending,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() {
|
||||||
|
_showPending = value;
|
||||||
|
});
|
||||||
|
await _persistPendingVisibility(value);
|
||||||
|
if (mounted) {
|
||||||
|
await _load();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Refresh timeline',
|
||||||
|
onPressed: _load,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
_TimelineGrid(
|
_TimelineGrid(
|
||||||
entries: timeline,
|
entries: visibleTimeline,
|
||||||
onEditEntry: (entry) => _prefillDraftFromEntry(
|
onEditEntry: (entry) => _prefillDraftFromEntry(
|
||||||
entry,
|
entry,
|
||||||
data.eventFields,
|
data.eventFields,
|
||||||
),
|
),
|
||||||
onDeleteEntry: _deleteEntry,
|
onDeleteEntry: _deleteEntry,
|
||||||
onModeratePending: _moderatePendingEntry,
|
onModeratePending: _moderatePendingEntry,
|
||||||
pendingActionsBusy: _isModerating,
|
pendingActionEventIds: _moderatingEventIds,
|
||||||
|
expandedPendingAttrs: _expandedPendingAttrs,
|
||||||
|
onTogglePendingAttr: (attrCode) {
|
||||||
|
setState(() {
|
||||||
|
if (!_expandedPendingAttrs.add(attrCode)) {
|
||||||
|
_expandedPendingAttrs.remove(attrCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_EventEditor(
|
_EventEditor(
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ class _TimelineGrid extends StatefulWidget {
|
|||||||
this.onEditEntry,
|
this.onEditEntry,
|
||||||
this.onDeleteEntry,
|
this.onDeleteEntry,
|
||||||
this.onModeratePending,
|
this.onModeratePending,
|
||||||
this.pendingActionsBusy = false,
|
this.pendingActionEventIds = const {},
|
||||||
|
this.expandedPendingAttrs = const {},
|
||||||
|
this.onTogglePendingAttr,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<LocoAttrVersion> entries;
|
final List<LocoAttrVersion> entries;
|
||||||
@@ -20,7 +22,9 @@ class _TimelineGrid extends StatefulWidget {
|
|||||||
LocoAttrVersion entry,
|
LocoAttrVersion entry,
|
||||||
_PendingModerationAction action,
|
_PendingModerationAction action,
|
||||||
)? onModeratePending;
|
)? onModeratePending;
|
||||||
final bool pendingActionsBusy;
|
final Set<int> pendingActionEventIds;
|
||||||
|
final Set<String> expandedPendingAttrs;
|
||||||
|
final void Function(String attrCode)? onTogglePendingAttr;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_TimelineGrid> createState() => _TimelineGridState();
|
State<_TimelineGrid> createState() => _TimelineGridState();
|
||||||
@@ -86,12 +90,15 @@ class _TimelineGridState extends State<_TimelineGrid> {
|
|||||||
'build_day',
|
'build_day',
|
||||||
}.contains(code);
|
}.contains(code);
|
||||||
}).toList();
|
}).toList();
|
||||||
final model = _TimelineModel.fromEntries(filteredEntries);
|
final model = _TimelineModel.fromEntries(
|
||||||
|
filteredEntries,
|
||||||
|
expandedAttrCodes: widget.expandedPendingAttrs,
|
||||||
|
);
|
||||||
final axisSegments = model.axisSegments;
|
final axisSegments = model.axisSegments;
|
||||||
const labelWidth = 110.0;
|
const labelWidth = 110.0;
|
||||||
const rowHeight = 52.0;
|
const rowHeight = 52.0;
|
||||||
const double axisHeight = 48;
|
const double axisHeight = 48;
|
||||||
final rows = model.attrRows.entries.toList();
|
final rows = model.rows;
|
||||||
final totalRowsHeight = rows.length * rowHeight;
|
final totalRowsHeight = rows.length * rowHeight;
|
||||||
final axisWidth = math.max(model.axisTotalWidth, 120.0);
|
final axisWidth = math.max(model.axisTotalWidth, 120.0);
|
||||||
final double viewHeight = totalRowsHeight + axisHeight + 8;
|
final double viewHeight = totalRowsHeight + axisHeight + 8;
|
||||||
@@ -131,7 +138,12 @@ class _TimelineGridState extends State<_TimelineGrid> {
|
|||||||
itemExtent: rowHeight,
|
itemExtent: rowHeight,
|
||||||
itemCount: rows.length,
|
itemCount: rows.length,
|
||||||
itemBuilder: (_, index) {
|
itemBuilder: (_, index) {
|
||||||
final label = _formatAttrLabel(rows[index].key);
|
final row = rows[index];
|
||||||
|
final label = row.isPrimary
|
||||||
|
? _formatAttrLabel(row.attrCode)
|
||||||
|
: (row.pendingUser?.trim().isNotEmpty == true
|
||||||
|
? row.pendingUser!.trim()
|
||||||
|
: 'Unknown');
|
||||||
return Container(
|
return Container(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -148,12 +160,49 @@ class _TimelineGridState extends State<_TimelineGrid> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Row(
|
||||||
label,
|
children: [
|
||||||
style: Theme.of(context)
|
if (!row.isPrimary) ...[
|
||||||
.textTheme
|
Icon(
|
||||||
.labelLarge
|
Icons.subdirectory_arrow_right,
|
||||||
?.copyWith(fontWeight: FontWeight.w700),
|
size: 16,
|
||||||
|
color: Theme.of(context).hintColor,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelLarge
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: row.isPrimary
|
||||||
|
? FontWeight.w700
|
||||||
|
: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (row.showExpandToggle)
|
||||||
|
IconButton(
|
||||||
|
onPressed: widget.onTogglePendingAttr == null
|
||||||
|
? null
|
||||||
|
: () => widget.onTogglePendingAttr?.call(
|
||||||
|
row.attrCode,
|
||||||
|
),
|
||||||
|
icon: Icon(
|
||||||
|
row.isExpanded
|
||||||
|
? Icons.expand_less
|
||||||
|
: Icons.expand_more,
|
||||||
|
),
|
||||||
|
tooltip: row.isExpanded
|
||||||
|
? 'Collapse pending rows'
|
||||||
|
: 'Expand pending rows',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -188,7 +237,7 @@ class _TimelineGridState extends State<_TimelineGrid> {
|
|||||||
itemExtent: rowHeight,
|
itemExtent: rowHeight,
|
||||||
itemCount: rows.length,
|
itemCount: rows.length,
|
||||||
itemBuilder: (_, index) {
|
itemBuilder: (_, index) {
|
||||||
final blocks = rows[index].value;
|
final blocks = rows[index].blocks;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(vertical: 2.0),
|
const EdgeInsets.symmetric(vertical: 2.0),
|
||||||
@@ -201,7 +250,7 @@ class _TimelineGridState extends State<_TimelineGrid> {
|
|||||||
onEditEntry: widget.onEditEntry,
|
onEditEntry: widget.onEditEntry,
|
||||||
onDeleteEntry: widget.onDeleteEntry,
|
onDeleteEntry: widget.onDeleteEntry,
|
||||||
onModeratePending: widget.onModeratePending,
|
onModeratePending: widget.onModeratePending,
|
||||||
pendingActionsBusy: widget.pendingActionsBusy,
|
pendingActionEventIds: widget.pendingActionEventIds,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -288,7 +337,7 @@ class _AttrRow extends StatelessWidget {
|
|||||||
this.onEditEntry,
|
this.onEditEntry,
|
||||||
this.onDeleteEntry,
|
this.onDeleteEntry,
|
||||||
this.onModeratePending,
|
this.onModeratePending,
|
||||||
this.pendingActionsBusy = false,
|
this.pendingActionEventIds = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
final double rowHeight;
|
final double rowHeight;
|
||||||
@@ -302,7 +351,7 @@ class _AttrRow extends StatelessWidget {
|
|||||||
LocoAttrVersion entry,
|
LocoAttrVersion entry,
|
||||||
_PendingModerationAction action,
|
_PendingModerationAction action,
|
||||||
)? onModeratePending;
|
)? onModeratePending;
|
||||||
final bool pendingActionsBusy;
|
final Set<int> pendingActionEventIds;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -329,7 +378,7 @@ class _AttrRow extends StatelessWidget {
|
|||||||
onEditEntry: onEditEntry,
|
onEditEntry: onEditEntry,
|
||||||
onDeleteEntry: onDeleteEntry,
|
onDeleteEntry: onDeleteEntry,
|
||||||
onModeratePending: onModeratePending,
|
onModeratePending: onModeratePending,
|
||||||
pendingActionsBusy: pendingActionsBusy,
|
pendingActionEventIds: pendingActionEventIds,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (activeBlock != null)
|
if (activeBlock != null)
|
||||||
@@ -346,7 +395,7 @@ class _AttrRow extends StatelessWidget {
|
|||||||
width: stickyWidth,
|
width: stickyWidth,
|
||||||
),
|
),
|
||||||
clipLeftEdge: scrollOffset > activeBlock.left + 0.1,
|
clipLeftEdge: scrollOffset > activeBlock.left + 0.1,
|
||||||
pendingActionsBusy: pendingActionsBusy,
|
pendingActionEventIds: pendingActionEventIds,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -368,12 +417,12 @@ class _ValueBlockView extends StatelessWidget {
|
|||||||
const _ValueBlockView({
|
const _ValueBlockView({
|
||||||
required this.block,
|
required this.block,
|
||||||
this.clipLeftEdge = false,
|
this.clipLeftEdge = false,
|
||||||
this.pendingActionsBusy = false,
|
this.pendingActionEventIds = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
final _ValueBlock block;
|
final _ValueBlock block;
|
||||||
final bool clipLeftEdge;
|
final bool clipLeftEdge;
|
||||||
final bool pendingActionsBusy;
|
final Set<int> pendingActionEventIds;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -384,6 +433,11 @@ class _ValueBlockView extends StatelessWidget {
|
|||||||
? Colors.white
|
? Colors.white
|
||||||
: Colors.black87;
|
: Colors.black87;
|
||||||
|
|
||||||
|
final entry = block.entry;
|
||||||
|
final eventId = entry?.sourceEventId;
|
||||||
|
final isPendingAction =
|
||||||
|
entry?.isPending == true && eventId != null && pendingActionEventIds.contains(eventId);
|
||||||
|
|
||||||
final radius = BorderRadius.only(
|
final radius = BorderRadius.only(
|
||||||
topLeft: Radius.circular(clipLeftEdge ? 0 : 12),
|
topLeft: Radius.circular(clipLeftEdge ? 0 : 12),
|
||||||
bottomLeft: Radius.circular(clipLeftEdge ? 0 : 12),
|
bottomLeft: Radius.circular(clipLeftEdge ? 0 : 12),
|
||||||
@@ -425,7 +479,7 @@ class _ValueBlockView extends StatelessWidget {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
child: pendingActionsBusy
|
child: isPendingAction
|
||||||
? CircularProgressIndicator(
|
? CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
valueColor:
|
valueColor:
|
||||||
@@ -484,7 +538,7 @@ class _ValueBlockMenu extends StatelessWidget {
|
|||||||
this.onEditEntry,
|
this.onEditEntry,
|
||||||
this.onDeleteEntry,
|
this.onDeleteEntry,
|
||||||
this.onModeratePending,
|
this.onModeratePending,
|
||||||
this.pendingActionsBusy = false,
|
this.pendingActionEventIds = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
final _ValueBlock block;
|
final _ValueBlock block;
|
||||||
@@ -494,7 +548,7 @@ class _ValueBlockMenu extends StatelessWidget {
|
|||||||
LocoAttrVersion entry,
|
LocoAttrVersion entry,
|
||||||
_PendingModerationAction action,
|
_PendingModerationAction action,
|
||||||
)? onModeratePending;
|
)? onModeratePending;
|
||||||
final bool pendingActionsBusy;
|
final Set<int> pendingActionEventIds;
|
||||||
|
|
||||||
bool get _hasActions {
|
bool get _hasActions {
|
||||||
final canModerate = block.entry?.isPending == true &&
|
final canModerate = block.entry?.isPending == true &&
|
||||||
@@ -515,6 +569,9 @@ class _ValueBlockMenu extends StatelessWidget {
|
|||||||
block.entry?.canModeratePending == true &&
|
block.entry?.canModeratePending == true &&
|
||||||
onModeratePending != null;
|
onModeratePending != null;
|
||||||
final canEdit = onEditEntry != null && block.entry?.isPending != true;
|
final canEdit = onEditEntry != null && block.entry?.isPending != true;
|
||||||
|
final eventId = block.entry?.sourceEventId;
|
||||||
|
final isPendingAction =
|
||||||
|
eventId != null && pendingActionEventIds.contains(eventId);
|
||||||
|
|
||||||
Future<void> showContextMenuAt(Offset globalPosition) async {
|
Future<void> showContextMenuAt(Offset globalPosition) async {
|
||||||
final overlay = Overlay.of(context);
|
final overlay = Overlay.of(context);
|
||||||
@@ -540,13 +597,13 @@ class _ValueBlockMenu extends StatelessWidget {
|
|||||||
if (canModerate)
|
if (canModerate)
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: _TimelineBlockAction.approve,
|
value: _TimelineBlockAction.approve,
|
||||||
enabled: !pendingActionsBusy,
|
enabled: !isPendingAction,
|
||||||
child: const Text('Approve pending'),
|
child: const Text('Approve pending'),
|
||||||
),
|
),
|
||||||
if (canModerate)
|
if (canModerate)
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: _TimelineBlockAction.reject,
|
value: _TimelineBlockAction.reject,
|
||||||
enabled: !pendingActionsBusy,
|
enabled: !isPendingAction,
|
||||||
child: const Text('Reject pending'),
|
child: const Text('Reject pending'),
|
||||||
),
|
),
|
||||||
if (onDeleteEntry != null)
|
if (onDeleteEntry != null)
|
||||||
@@ -588,7 +645,7 @@ class _ValueBlockMenu extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
child: _ValueBlockView(
|
child: _ValueBlockView(
|
||||||
block: block,
|
block: block,
|
||||||
pendingActionsBusy: pendingActionsBusy,
|
pendingActionEventIds: pendingActionEventIds,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -649,79 +706,310 @@ DateTime _safeEnd(DateTime start, DateTime? end) {
|
|||||||
return end;
|
return end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _startKey(DateTime date) => date.year * 10000 + date.month * 100 + date.day;
|
||||||
|
|
||||||
|
bool _isOverlappingStart(LocoAttrVersion entry, Set<int> approvedStartKeys) {
|
||||||
|
final start = _effectiveStart(entry);
|
||||||
|
if (start == null) return false;
|
||||||
|
return approvedStartKeys.contains(_startKey(start));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_ValueSegment> _segmentsForEntries(
|
||||||
|
List<LocoAttrVersion> items,
|
||||||
|
DateTime now, {
|
||||||
|
bool? clampToNextStart,
|
||||||
|
}) {
|
||||||
|
if (items.isEmpty) return const [];
|
||||||
|
final hasPending = items.any((e) => e.isPending);
|
||||||
|
final hasApproved = items.any((e) => !e.isPending);
|
||||||
|
final shouldClamp =
|
||||||
|
clampToNextStart ?? (hasPending && hasApproved);
|
||||||
|
final sorted = [...items];
|
||||||
|
sorted.sort(
|
||||||
|
(a, b) => (_effectiveStart(a) ?? now)
|
||||||
|
.compareTo(_effectiveStart(b) ?? now),
|
||||||
|
);
|
||||||
|
final segments = <_ValueSegment>[];
|
||||||
|
for (int i = 0; i < sorted.length; i++) {
|
||||||
|
final entry = sorted[i];
|
||||||
|
final start = _effectiveStart(entry) ?? now;
|
||||||
|
final nextStart = i < sorted.length - 1
|
||||||
|
? _effectiveStart(sorted[i + 1])
|
||||||
|
: null;
|
||||||
|
DateTime? rawEnd = entry.validTo;
|
||||||
|
if (nextStart != null) {
|
||||||
|
if (rawEnd == null || (shouldClamp && nextStart.isBefore(rawEnd))) {
|
||||||
|
rawEnd = nextStart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rawEnd ??= now;
|
||||||
|
final end = _safeEnd(start, rawEnd);
|
||||||
|
segments.add(
|
||||||
|
_ValueSegment(
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
value: _formatValueWithUnits(entry),
|
||||||
|
entry: entry,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LocoAttrVersion> _applyPendingOverrides(
|
||||||
|
List<LocoAttrVersion> approved,
|
||||||
|
List<LocoAttrVersion> pending,
|
||||||
|
) {
|
||||||
|
if (pending.isEmpty) return approved;
|
||||||
|
final pendingByStart = <int, LocoAttrVersion>{};
|
||||||
|
final extraPending = <LocoAttrVersion>[];
|
||||||
|
for (final entry in pending) {
|
||||||
|
final start = _effectiveStart(entry);
|
||||||
|
if (start == null) continue;
|
||||||
|
final key = _startKey(start);
|
||||||
|
pendingByStart[key] = entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
final applied = <LocoAttrVersion>[];
|
||||||
|
final seenKeys = <int>{};
|
||||||
|
for (final entry in approved) {
|
||||||
|
final start = _effectiveStart(entry);
|
||||||
|
if (start == null) continue;
|
||||||
|
final key = _startKey(start);
|
||||||
|
if (pendingByStart.containsKey(key)) {
|
||||||
|
if (!seenKeys.contains(key)) {
|
||||||
|
applied.add(pendingByStart[key]!);
|
||||||
|
seenKeys.add(key);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
applied.add(entry);
|
||||||
|
seenKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final entry in pendingByStart.values) {
|
||||||
|
final start = _effectiveStart(entry);
|
||||||
|
if (start == null) continue;
|
||||||
|
final key = _startKey(start);
|
||||||
|
if (!seenKeys.contains(key)) {
|
||||||
|
extraPending.add(entry);
|
||||||
|
seenKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extraPending.isNotEmpty) {
|
||||||
|
applied.addAll(extraPending);
|
||||||
|
}
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<DateTime> _buildBoundaries(
|
||||||
|
List<_ValueSegment> segments,
|
||||||
|
DateTime now,
|
||||||
|
) {
|
||||||
|
DateTime? minStart;
|
||||||
|
DateTime? maxEnd;
|
||||||
|
final boundaryDates = <DateTime>{};
|
||||||
|
for (final seg in segments) {
|
||||||
|
boundaryDates.add(seg.start);
|
||||||
|
boundaryDates.add(seg.end);
|
||||||
|
minStart = minStart == null || seg.start.isBefore(minStart!)
|
||||||
|
? seg.start
|
||||||
|
: minStart;
|
||||||
|
maxEnd = maxEnd == null || seg.end.isAfter(maxEnd!) ? seg.end : maxEnd;
|
||||||
|
}
|
||||||
|
minStart ??= now.subtract(const Duration(days: 1));
|
||||||
|
final effectiveMaxEnd = maxEnd ?? now;
|
||||||
|
boundaryDates.add(effectiveMaxEnd);
|
||||||
|
var boundaries = boundaryDates.toList()..sort();
|
||||||
|
if (boundaries.length < 2) {
|
||||||
|
boundaries = [minStart!, effectiveMaxEnd];
|
||||||
|
}
|
||||||
|
return boundaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimelineRowSpec {
|
||||||
|
final String id;
|
||||||
|
final String attrCode;
|
||||||
|
final List<_ValueSegment> segments;
|
||||||
|
final bool isPrimary;
|
||||||
|
final bool showExpandToggle;
|
||||||
|
final bool isExpanded;
|
||||||
|
final String? userLabel;
|
||||||
|
|
||||||
|
const _TimelineRowSpec._({
|
||||||
|
required this.id,
|
||||||
|
required this.attrCode,
|
||||||
|
required this.segments,
|
||||||
|
required this.isPrimary,
|
||||||
|
required this.showExpandToggle,
|
||||||
|
required this.isExpanded,
|
||||||
|
this.userLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory _TimelineRowSpec.primary({
|
||||||
|
required String attrCode,
|
||||||
|
required List<_ValueSegment> segments,
|
||||||
|
required bool showExpandToggle,
|
||||||
|
required bool isExpanded,
|
||||||
|
}) {
|
||||||
|
return _TimelineRowSpec._(
|
||||||
|
id: attrCode,
|
||||||
|
attrCode: attrCode,
|
||||||
|
segments: segments,
|
||||||
|
isPrimary: true,
|
||||||
|
showExpandToggle: showExpandToggle,
|
||||||
|
isExpanded: isExpanded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory _TimelineRowSpec.pending({
|
||||||
|
required String attrCode,
|
||||||
|
required String userLabel,
|
||||||
|
required List<_ValueSegment> segments,
|
||||||
|
}) {
|
||||||
|
return _TimelineRowSpec._(
|
||||||
|
id: '$attrCode::$userLabel',
|
||||||
|
attrCode: attrCode,
|
||||||
|
segments: segments,
|
||||||
|
isPrimary: false,
|
||||||
|
showExpandToggle: false,
|
||||||
|
isExpanded: false,
|
||||||
|
userLabel: userLabel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimelineRowData {
|
||||||
|
final String id;
|
||||||
|
final String attrCode;
|
||||||
|
final List<_ValueBlock> blocks;
|
||||||
|
final bool isPrimary;
|
||||||
|
final bool showExpandToggle;
|
||||||
|
final bool isExpanded;
|
||||||
|
final String? pendingUser;
|
||||||
|
|
||||||
|
const _TimelineRowData({
|
||||||
|
required this.id,
|
||||||
|
required this.attrCode,
|
||||||
|
required this.blocks,
|
||||||
|
required this.isPrimary,
|
||||||
|
required this.showExpandToggle,
|
||||||
|
required this.isExpanded,
|
||||||
|
this.pendingUser,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class _TimelineModel {
|
class _TimelineModel {
|
||||||
final List<_AxisSegment> axisSegments;
|
final List<_AxisSegment> axisSegments;
|
||||||
final Map<String, List<_ValueBlock>> attrRows;
|
final List<_TimelineRowData> rows;
|
||||||
final String endLabel;
|
final String endLabel;
|
||||||
final List<DateTime> boundaries;
|
final List<DateTime> boundaries;
|
||||||
final double axisTotalWidth;
|
final double axisTotalWidth;
|
||||||
|
|
||||||
_TimelineModel({
|
_TimelineModel({
|
||||||
required this.axisSegments,
|
required this.axisSegments,
|
||||||
required this.attrRows,
|
required this.rows,
|
||||||
required this.endLabel,
|
required this.endLabel,
|
||||||
required this.boundaries,
|
required this.boundaries,
|
||||||
required this.axisTotalWidth,
|
required this.axisTotalWidth,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory _TimelineModel.fromEntries(List<LocoAttrVersion> entries) {
|
factory _TimelineModel.fromEntries(
|
||||||
|
List<LocoAttrVersion> entries, {
|
||||||
|
Set<String> expandedAttrCodes = const {},
|
||||||
|
}) {
|
||||||
final effectiveEntries = entries
|
final effectiveEntries = entries
|
||||||
.where((e) => _effectiveStart(e) != null)
|
.where((e) => _effectiveStart(e) != null)
|
||||||
.toList();
|
.toList();
|
||||||
final grouped = <String, List<LocoAttrVersion>>{};
|
final grouped = <String, List<LocoAttrVersion>>{};
|
||||||
|
final attrOrder = <String>[];
|
||||||
for (final entry in effectiveEntries) {
|
for (final entry in effectiveEntries) {
|
||||||
grouped.putIfAbsent(entry.attrCode, () => []).add(entry);
|
final key = entry.attrCode;
|
||||||
|
if (!grouped.containsKey(key)) {
|
||||||
|
attrOrder.add(key);
|
||||||
|
}
|
||||||
|
grouped.putIfAbsent(key, () => []).add(entry);
|
||||||
}
|
}
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
DateTime? minStart;
|
final allSegments = <_ValueSegment>[];
|
||||||
DateTime? maxEnd;
|
final rowSpecs = <_TimelineRowSpec>[];
|
||||||
final attrSegments = <String, List<_ValueSegment>>{};
|
for (final attr in attrOrder) {
|
||||||
|
final items = grouped[attr] ?? const [];
|
||||||
|
final approved = items.where((e) => !e.isPending).toList();
|
||||||
|
final pending = items.where((e) => e.isPending).toList();
|
||||||
|
final approvedSegments = _segmentsForEntries(approved, now);
|
||||||
|
|
||||||
grouped.forEach((attr, items) {
|
final approvedStartKeys = <int>{};
|
||||||
items.sort(
|
for (final entry in approved) {
|
||||||
(a, b) => (_effectiveStart(a) ?? now)
|
final start = _effectiveStart(entry);
|
||||||
.compareTo(_effectiveStart(b) ?? now),
|
if (start == null) continue;
|
||||||
|
approvedStartKeys.add(_startKey(start));
|
||||||
|
}
|
||||||
|
|
||||||
|
final pendingByUser = <String, List<LocoAttrVersion>>{};
|
||||||
|
final overlapByUser = <String, List<LocoAttrVersion>>{};
|
||||||
|
for (final entry in pending) {
|
||||||
|
final user = (entry.suggestedBy ?? '').trim().isEmpty
|
||||||
|
? 'Unknown'
|
||||||
|
: entry.suggestedBy!.trim();
|
||||||
|
pendingByUser.putIfAbsent(user, () => []).add(entry);
|
||||||
|
final start = _effectiveStart(entry);
|
||||||
|
if (start == null) continue;
|
||||||
|
if (approvedStartKeys.contains(_startKey(start))) {
|
||||||
|
overlapByUser.putIfAbsent(user, () => []).add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final hasOverlap = overlapByUser.isNotEmpty;
|
||||||
|
final canToggle = pending.isNotEmpty && !hasOverlap;
|
||||||
|
final isExpanded = expandedAttrCodes.contains(attr);
|
||||||
|
final shouldShowPendingRows = isExpanded || hasOverlap;
|
||||||
|
|
||||||
|
final nonOverlapPending =
|
||||||
|
pending.where((e) => !_isOverlappingStart(e, approvedStartKeys)).toList();
|
||||||
|
final baseEntries =
|
||||||
|
shouldShowPendingRows ? approved : [...approved, ...nonOverlapPending];
|
||||||
|
final baseSegments = shouldShowPendingRows
|
||||||
|
? approvedSegments
|
||||||
|
: _segmentsForEntries(baseEntries, now);
|
||||||
|
|
||||||
|
rowSpecs.add(
|
||||||
|
_TimelineRowSpec.primary(
|
||||||
|
attrCode: attr,
|
||||||
|
segments: baseSegments,
|
||||||
|
showExpandToggle: canToggle,
|
||||||
|
isExpanded: isExpanded,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
final segments = <_ValueSegment>[];
|
allSegments.addAll(baseSegments);
|
||||||
for (int i = 0; i < items.length; i++) {
|
|
||||||
final entry = items[i];
|
|
||||||
final start = _effectiveStart(entry) ?? now;
|
|
||||||
final nextStart = i < items.length - 1
|
|
||||||
? _effectiveStart(items[i + 1])
|
|
||||||
: null;
|
|
||||||
final rawEnd = entry.validTo ?? nextStart ?? now;
|
|
||||||
final end = _safeEnd(start, rawEnd);
|
|
||||||
segments.add(
|
|
||||||
_ValueSegment(
|
|
||||||
start: start,
|
|
||||||
end: end,
|
|
||||||
value: _formatValueWithUnits(entry),
|
|
||||||
entry: entry,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
minStart = minStart == null || start.isBefore(minStart!)
|
|
||||||
? start
|
|
||||||
: minStart;
|
|
||||||
maxEnd = maxEnd == null || end.isAfter(maxEnd!) ? end : maxEnd;
|
|
||||||
}
|
|
||||||
attrSegments[attr] = segments;
|
|
||||||
});
|
|
||||||
|
|
||||||
minStart ??= now.subtract(const Duration(days: 1));
|
if (shouldShowPendingRows) {
|
||||||
final effectiveMaxEnd = maxEnd ?? now;
|
final users = isExpanded
|
||||||
|
? pendingByUser.keys.toList()
|
||||||
final boundaryDates = <DateTime>{};
|
: overlapByUser.keys.toList();
|
||||||
for (final segments in attrSegments.values) {
|
users.sort();
|
||||||
for (final seg in segments) {
|
for (final user in users) {
|
||||||
boundaryDates.add(seg.start);
|
final pendingEntries = isExpanded
|
||||||
boundaryDates.add(seg.end);
|
? (pendingByUser[user] ?? const [])
|
||||||
|
: (overlapByUser[user] ?? const []);
|
||||||
|
if (pendingEntries.isEmpty) continue;
|
||||||
|
final appliedEntries =
|
||||||
|
_applyPendingOverrides(approved, pendingEntries);
|
||||||
|
final combinedSegments = _segmentsForEntries(appliedEntries, now);
|
||||||
|
rowSpecs.add(
|
||||||
|
_TimelineRowSpec.pending(
|
||||||
|
attrCode: attr,
|
||||||
|
userLabel: user,
|
||||||
|
segments: combinedSegments,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
allSegments.addAll(combinedSegments);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
boundaryDates.add(effectiveMaxEnd);
|
|
||||||
var boundaries = boundaryDates.toList()..sort();
|
final boundaries = _buildBoundaries(allSegments, now);
|
||||||
if (boundaries.length < 2) {
|
|
||||||
boundaries = [minStart!, effectiveMaxEnd];
|
|
||||||
}
|
|
||||||
|
|
||||||
final axisSegments = <_AxisSegment>[];
|
final axisSegments = <_AxisSegment>[];
|
||||||
const double yearWidth = 240.0;
|
const double yearWidth = 240.0;
|
||||||
@@ -745,10 +1033,10 @@ class _TimelineModel {
|
|||||||
final axisTotalWidth =
|
final axisTotalWidth =
|
||||||
axisSegments.fold<double>(0, (sum, seg) => sum + seg.width);
|
axisSegments.fold<double>(0, (sum, seg) => sum + seg.width);
|
||||||
|
|
||||||
final attrRows = <String, List<_ValueBlock>>{};
|
final rows = <_TimelineRowData>[];
|
||||||
for (final entry in attrSegments.entries) {
|
for (final spec in rowSpecs) {
|
||||||
final blocks = <_ValueBlock>[];
|
final blocks = <_ValueBlock>[];
|
||||||
for (final seg in entry.value) {
|
for (final seg in spec.segments) {
|
||||||
final left = _positionForDate(seg.start, boundaries, axisSegments);
|
final left = _positionForDate(seg.start, boundaries, axisSegments);
|
||||||
final right = _positionForDate(seg.end, boundaries, axisSegments);
|
final right = _positionForDate(seg.end, boundaries, axisSegments);
|
||||||
final span = right - left;
|
final span = right - left;
|
||||||
@@ -762,13 +1050,24 @@ class _TimelineModel {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
attrRows[entry.key] = blocks;
|
rows.add(
|
||||||
|
_TimelineRowData(
|
||||||
|
id: spec.id,
|
||||||
|
attrCode: spec.attrCode,
|
||||||
|
blocks: blocks,
|
||||||
|
isPrimary: spec.isPrimary,
|
||||||
|
showExpandToggle: spec.showExpandToggle,
|
||||||
|
isExpanded: spec.isExpanded,
|
||||||
|
pendingUser: spec.userLabel,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final endLabel = _formatDate(effectiveMaxEnd) ?? 'Now';
|
final endLabel =
|
||||||
|
boundaries.isNotEmpty ? _formatDate(boundaries.last) ?? 'Now' : 'Now';
|
||||||
return _TimelineModel(
|
return _TimelineModel(
|
||||||
axisSegments: axisSegments,
|
axisSegments: axisSegments,
|
||||||
attrRows: attrRows,
|
rows: rows,
|
||||||
endLabel: endLabel,
|
endLabel: endLabel,
|
||||||
boundaries: boundaries,
|
boundaries: boundaries,
|
||||||
axisTotalWidth: axisTotalWidth,
|
axisTotalWidth: axisTotalWidth,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ enum _TractionMoreAction {
|
|||||||
classStats,
|
classStats,
|
||||||
classLeaderboard,
|
classLeaderboard,
|
||||||
adminPending,
|
adminPending,
|
||||||
|
adminPendingChanges,
|
||||||
}
|
}
|
||||||
|
|
||||||
class TractionPage extends StatefulWidget {
|
class TractionPage extends StatefulWidget {
|
||||||
@@ -708,23 +709,56 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case _TractionMoreAction.adminPendingChanges:
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
try {
|
||||||
|
await context.push('/traction/changes');
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Unable to open pending changes'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
final items = <PopupMenuEntry<_TractionMoreAction>>[];
|
final items = <PopupMenuEntry<_TractionMoreAction>>[];
|
||||||
if (hasClassActions) {
|
if (hasClassActions) {
|
||||||
items.add(
|
items.add(
|
||||||
const PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: _TractionMoreAction.classStats,
|
value: _TractionMoreAction.classStats,
|
||||||
child: Text('Class stats'),
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_showClassStatsPanel ? Icons.check : Icons.check_box_outline_blank,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text('Class stats'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (hasClassActions) {
|
if (hasClassActions) {
|
||||||
items.add(
|
items.add(
|
||||||
const PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: _TractionMoreAction.classLeaderboard,
|
value: _TractionMoreAction.classLeaderboard,
|
||||||
child: Text('Class leaderboard'),
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_showClassLeaderboardPanel
|
||||||
|
? Icons.check
|
||||||
|
: Icons.check_box_outline_blank,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text('Class leaderboard'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -745,6 +779,12 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
items.add(
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _TractionMoreAction.adminPendingChanges,
|
||||||
|
child: Text('Pending changes'),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
|
|||||||
152
lib/components/pages/traction/traction_pending_changes_page.dart
Normal file
152
lib/components/pages/traction/traction_pending_changes_page.dart
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:mileograph_flutter/components/traction/traction_card.dart';
|
||||||
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
|
import 'package:mileograph_flutter/services/api_service.dart';
|
||||||
|
import 'package:mileograph_flutter/services/authservice.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class TractionPendingChangesPage extends StatefulWidget {
|
||||||
|
const TractionPendingChangesPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TractionPendingChangesPage> createState() =>
|
||||||
|
_TractionPendingChangesPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TractionPendingChangesPageState extends State<TractionPendingChangesPage> {
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
List<LocoSummary> _locos = const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final api = context.read<ApiService>();
|
||||||
|
final json = await api.get('/event/pending/locos');
|
||||||
|
if (json is List) {
|
||||||
|
setState(() {
|
||||||
|
_locos = json
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => LocoSummary.fromJson(
|
||||||
|
e.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Unexpected response';
|
||||||
|
_locos = const [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_locos = const [];
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isElevated = context.select<AuthService, bool>((auth) => auth.isElevated);
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.of(context).maybePop(),
|
||||||
|
),
|
||||||
|
title: const Text('Pending changes'),
|
||||||
|
),
|
||||||
|
body: isElevated
|
||||||
|
? RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
child: _buildBody(context),
|
||||||
|
)
|
||||||
|
: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: const [
|
||||||
|
Text('Admin access required.'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(BuildContext context) {
|
||||||
|
if (_isLoading && _locos.isEmpty) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (_error != null) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Failed to load pending changes: $_error',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyMedium
|
||||||
|
?.copyWith(color: Theme.of(context).colorScheme.error),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _load,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_locos.isEmpty) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: const [
|
||||||
|
Text('No pending changes found.'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: _locos.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final loco = _locos[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6.0),
|
||||||
|
child: TractionCard(
|
||||||
|
loco: loco,
|
||||||
|
selectionMode: false,
|
||||||
|
isSelected: false,
|
||||||
|
onShowInfo: () => showTractionDetails(
|
||||||
|
context,
|
||||||
|
loco,
|
||||||
|
onActionComplete: _load,
|
||||||
|
),
|
||||||
|
onOpenTimeline: () => context.push(
|
||||||
|
'/traction/${loco.id}/timeline',
|
||||||
|
extra: {
|
||||||
|
'label': '${loco.locoClass} ${loco.number}'.trim(),
|
||||||
|
'showPending': true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onOpenLegs: () => context.push('/traction/${loco.id}/legs'),
|
||||||
|
onActionComplete: _load,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
|
|||||||
int? _legId;
|
int? _legId;
|
||||||
int? _shareId;
|
int? _shareId;
|
||||||
Leg? _currentLeg;
|
Leg? _currentLeg;
|
||||||
|
LegShareData? _share;
|
||||||
|
Future<LegShareData?>? _shareFuture;
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
|
|
||||||
static const int _summaryLimit = 3;
|
static const int _summaryLimit = 3;
|
||||||
@@ -33,11 +35,11 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
|
|||||||
void didUpdateWidget(covariant LegShareEditNotificationCard oldWidget) {
|
void didUpdateWidget(covariant LegShareEditNotificationCard oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.notification != widget.notification) {
|
if (oldWidget.notification != widget.notification) {
|
||||||
_parseNotification();
|
_parseNotification(notify: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _parseNotification() {
|
void _parseNotification({bool notify = false}) {
|
||||||
final rawBody = widget.notification.body.trim();
|
final rawBody = widget.notification.body.trim();
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
@@ -45,11 +47,14 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
|
|||||||
_legId = null;
|
_legId = null;
|
||||||
_currentLeg = null;
|
_currentLeg = null;
|
||||||
_changes = null;
|
_changes = null;
|
||||||
|
_share = null;
|
||||||
|
_shareFuture = null;
|
||||||
|
|
||||||
final parsed = _decodeBody(rawBody);
|
final parsed = _decodeBody(rawBody);
|
||||||
if (parsed != null) {
|
if (parsed != null) {
|
||||||
_shareId = _parseInt(parsed['share_id']);
|
_shareId = _parseInt(parsed['share_id']);
|
||||||
_legId = _parseInt(parsed['leg_id']);
|
_legId = _parseInt(parsed['leg_id']);
|
||||||
|
_currentLeg = _findCurrentLeg(_legId);
|
||||||
final accepted = _asStringKeyedMap(parsed['accepted_changes']);
|
final accepted = _asStringKeyedMap(parsed['accepted_changes']);
|
||||||
if (accepted != null) {
|
if (accepted != null) {
|
||||||
_changes = accepted;
|
_changes = accepted;
|
||||||
@@ -58,6 +63,10 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
|
|||||||
|
|
||||||
// Fallback: extract share_id from raw string if still missing.
|
// Fallback: extract share_id from raw string if still missing.
|
||||||
_shareId ??= _extractShareId(rawBody);
|
_shareId ??= _extractShareId(rawBody);
|
||||||
|
_prepareShareFuture();
|
||||||
|
if (notify) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int? _parseInt(dynamic value) {
|
int? _parseInt(dynamic value) {
|
||||||
@@ -164,6 +173,25 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _prepareShareFuture() {
|
||||||
|
if (_shareId == null) return;
|
||||||
|
_shareFuture = context.read<DataService>().fetchLegShare(_shareId!.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadShareIfNeeded() async {
|
||||||
|
if (_share != null) return;
|
||||||
|
if (_shareId == null) return;
|
||||||
|
try {
|
||||||
|
final future = _shareFuture ??
|
||||||
|
context.read<DataService>().fetchLegShare(_shareId!.toString());
|
||||||
|
final share = await future;
|
||||||
|
if (!mounted) return;
|
||||||
|
_share = share;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore: avoid_empty_catches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Leg? _findCurrentLeg(int? legId) {
|
Leg? _findCurrentLeg(int? legId) {
|
||||||
if (legId == null) return null;
|
if (legId == null) return null;
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
@@ -174,6 +202,79 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _formatDateTime(DateTime dateTime) {
|
||||||
|
return '${dateTime.year.toString().padLeft(4, '0')}-'
|
||||||
|
'${dateTime.month.toString().padLeft(2, '0')}-'
|
||||||
|
'${dateTime.day.toString().padLeft(2, '0')} '
|
||||||
|
'${dateTime.hour.toString().padLeft(2, '0')}:'
|
||||||
|
'${dateTime.minute.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLegSummary(BuildContext context) {
|
||||||
|
final future = _shareFuture;
|
||||||
|
if (future == null) {
|
||||||
|
final leg = _currentLeg;
|
||||||
|
return leg == null ? const SizedBox.shrink() : _legSummaryRow(context, leg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FutureBuilder<LegShareData?>(
|
||||||
|
future: future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final share = snapshot.data;
|
||||||
|
if (share != null) {
|
||||||
|
_share = share;
|
||||||
|
}
|
||||||
|
final leg = share?.entry ?? _currentLeg;
|
||||||
|
if (leg == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return _legSummaryRow(context, leg);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _legSummaryRow(BuildContext context, Leg leg) {
|
||||||
|
final start = leg.route.isNotEmpty ? leg.route.first : leg.start;
|
||||||
|
final end = leg.route.isNotEmpty ? leg.route.last : leg.end;
|
||||||
|
return Text('${_formatDateTime(leg.beginTime)} • $start → $end');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _asString(dynamic value) {
|
||||||
|
if (value == null) return '';
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Loco? _resolveLocoById(int locoId, {Leg? shareLeg}) {
|
||||||
|
for (final loco in shareLeg?.locos ?? const <Loco>[]) {
|
||||||
|
if (loco.id == locoId) return loco;
|
||||||
|
}
|
||||||
|
for (final loco in _currentLeg?.locos ?? const <Loco>[]) {
|
||||||
|
if (loco.id == locoId) return loco;
|
||||||
|
}
|
||||||
|
for (final loco in context.read<DataService>().traction) {
|
||||||
|
if (loco.id == locoId) return loco;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _locoDisplayName(Map<String, dynamic> loco, {Leg? shareLeg}) {
|
||||||
|
final locoId = _parseInt(loco['loco_id']);
|
||||||
|
var locoClass = _asString(loco['class'] ?? loco['loco_class']);
|
||||||
|
var number = _asString(loco['number'] ?? loco['loco_number']);
|
||||||
|
if ((locoClass.isEmpty || number.isEmpty) && locoId != null) {
|
||||||
|
final resolved = _resolveLocoById(locoId, shareLeg: shareLeg);
|
||||||
|
if (resolved != null) {
|
||||||
|
if (locoClass.isEmpty) locoClass = resolved.locoClass;
|
||||||
|
if (number.isEmpty) number = resolved.number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final parts = <String>[];
|
||||||
|
if (locoClass.isNotEmpty) parts.add(locoClass);
|
||||||
|
if (number.isNotEmpty) parts.add(number);
|
||||||
|
if (parts.isNotEmpty) return parts.join(' ');
|
||||||
|
return 'Loco ${locoId ?? '?'}';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -184,10 +285,14 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
|
|||||||
final entries = changes.entries.toList();
|
final entries = changes.entries.toList();
|
||||||
final shown = entries.take(_summaryLimit).toList();
|
final shown = entries.take(_summaryLimit).toList();
|
||||||
final remaining = entries.length - shown.length;
|
final remaining = entries.length - shown.length;
|
||||||
|
final legSummary = _buildLegSummary(context);
|
||||||
|
final hasSummary = _shareFuture != null || _currentLeg != null;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
if (hasSummary) legSummary,
|
||||||
|
if (hasSummary) const SizedBox(height: 8),
|
||||||
...shown.map((e) => _changePreview(context, e)),
|
...shown.map((e) => _changePreview(context, e)),
|
||||||
if (remaining > 0)
|
if (remaining > 0)
|
||||||
Padding(
|
Padding(
|
||||||
@@ -325,6 +430,7 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
|
|||||||
Future<void> _openDrawer(Map<String, dynamic> changes) async {
|
Future<void> _openDrawer(Map<String, dynamic> changes) async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
await _loadLegIdIfNeeded();
|
await _loadLegIdIfNeeded();
|
||||||
|
await _loadShareIfNeeded();
|
||||||
_currentLeg ??= _findCurrentLeg(_legId);
|
_currentLeg ??= _findCurrentLeg(_legId);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _loading = false);
|
setState(() => _loading = false);
|
||||||
@@ -398,7 +504,9 @@ class _LegShareEditNotificationCardState extends State<LegShareEditNotificationC
|
|||||||
(loco['alloc_powering'] == 1 || loco['alloc_powering'] == true)
|
(loco['alloc_powering'] == 1 || loco['alloc_powering'] == true)
|
||||||
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12)
|
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12)
|
||||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
label: Text('Loco ${loco['loco_id'] ?? '?'} (pos ${loco['alloc_pos'] ?? '?'}'),
|
label: Text(
|
||||||
|
'${_locoDisplayName(loco, shareLeg: _share?.entry)} (pos ${loco['alloc_pos'] ?? '?'})',
|
||||||
|
),
|
||||||
avatar: Icon(
|
avatar: Icon(
|
||||||
Icons.train,
|
Icons.train,
|
||||||
size: 16,
|
size: 16,
|
||||||
|
|||||||
@@ -7,15 +7,23 @@ import 'package:mileograph_flutter/objects/objects.dart';
|
|||||||
import 'package:mileograph_flutter/services/data_service.dart';
|
import 'package:mileograph_flutter/services/data_service.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class LegShareNotificationCard extends StatelessWidget {
|
class LegShareNotificationCard extends StatefulWidget {
|
||||||
const LegShareNotificationCard({super.key, required this.notification});
|
const LegShareNotificationCard({super.key, required this.notification});
|
||||||
|
|
||||||
final UserNotification notification;
|
final UserNotification notification;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LegShareNotificationCard> createState() => _LegShareNotificationCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LegShareNotificationCardState extends State<LegShareNotificationCard> {
|
||||||
|
bool _accepting = false;
|
||||||
|
bool _rejecting = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
final legShareId = _extractLegShareId(notification.body);
|
final legShareId = _extractLegShareId(widget.notification.body);
|
||||||
if (legShareId == null) {
|
if (legShareId == null) {
|
||||||
return const Text('Invalid leg share notification.');
|
return const Text('Invalid leg share notification.');
|
||||||
}
|
}
|
||||||
@@ -78,16 +86,28 @@ class LegShareNotificationCard extends StatelessWidget {
|
|||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => _accept(context, share),
|
onPressed: _accepting ? null : () => _accept(context, share),
|
||||||
child: const Text('Accept'),
|
child: _accepting
|
||||||
|
? const SizedBox(
|
||||||
|
height: 18,
|
||||||
|
width: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Accept'),
|
||||||
),
|
),
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onPressed: () => _inspect(context, share),
|
onPressed: () => _inspect(context, share),
|
||||||
child: const Text('Inspect'),
|
child: const Text('Inspect'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => _reject(context, share),
|
onPressed: _rejecting ? null : () => _reject(context, share),
|
||||||
child: const Text('Reject'),
|
child: _rejecting
|
||||||
|
? const SizedBox(
|
||||||
|
height: 18,
|
||||||
|
width: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Reject'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -98,12 +118,13 @@ class LegShareNotificationCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _accept(BuildContext context, LegShareData share) async {
|
Future<void> _accept(BuildContext context, LegShareData share) async {
|
||||||
|
setState(() => _accepting = true);
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
try {
|
try {
|
||||||
await data.acceptLegShare(share);
|
await data.acceptLegShare(share);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
await data.dismissNotifications([notification.id]);
|
await data.dismissNotifications([widget.notification.id]);
|
||||||
// Refresh legs in the background.
|
// Refresh legs in the background.
|
||||||
unawaited(data.refreshLegs());
|
unawaited(data.refreshLegs());
|
||||||
messenger?.showSnackBar(
|
messenger?.showSnackBar(
|
||||||
@@ -113,16 +134,21 @@ class LegShareNotificationCard extends StatelessWidget {
|
|||||||
messenger?.showSnackBar(
|
messenger?.showSnackBar(
|
||||||
SnackBar(content: Text('Failed to add shared entry: $e')),
|
SnackBar(content: Text('Failed to add shared entry: $e')),
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _accepting = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _reject(BuildContext context, LegShareData share) async {
|
Future<void> _reject(BuildContext context, LegShareData share) async {
|
||||||
|
setState(() => _rejecting = true);
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
try {
|
try {
|
||||||
await data.rejectLegShare(share.id);
|
await data.rejectLegShare(share.id);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
await data.dismissNotifications([notification.id]);
|
await data.dismissNotifications([widget.notification.id]);
|
||||||
messenger?.showSnackBar(
|
messenger?.showSnackBar(
|
||||||
const SnackBar(content: Text('Share rejected')),
|
const SnackBar(content: Text('Share rejected')),
|
||||||
);
|
);
|
||||||
@@ -130,6 +156,10 @@ class LegShareNotificationCard extends StatelessWidget {
|
|||||||
messenger?.showSnackBar(
|
messenger?.showSnackBar(
|
||||||
SnackBar(content: Text('Failed to reject share: $e')),
|
SnackBar(content: Text('Failed to reject share: $e')),
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _rejecting = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +170,7 @@ class LegShareNotificationCard extends StatelessWidget {
|
|||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
final target = share.copyWith(notificationId: notification.id);
|
final target = share.copyWith(notificationId: widget.notification.id);
|
||||||
final ts = DateTime.now().millisecondsSinceEpoch;
|
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||||
final path = '/add?share=${Uri.encodeComponent(share.id)}&ts=$ts';
|
final path = '/add?share=${Uri.encodeComponent(share.id)}&ts=$ts';
|
||||||
router.go(path, extra: target);
|
router.go(path, extra: target);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import 'package:mileograph_flutter/components/pages/settings.dart';
|
|||||||
import 'package:mileograph_flutter/components/pages/stats.dart';
|
import 'package:mileograph_flutter/components/pages/stats.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/traction.dart';
|
import 'package:mileograph_flutter/components/pages/traction.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/traction/traction_pending_page.dart';
|
import 'package:mileograph_flutter/components/pages/traction/traction_pending_page.dart';
|
||||||
|
import 'package:mileograph_flutter/components/pages/traction/traction_pending_changes_page.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/more/user_profile_page.dart';
|
import 'package:mileograph_flutter/components/pages/more/user_profile_page.dart';
|
||||||
import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart';
|
import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart';
|
||||||
import 'package:mileograph_flutter/components/widgets/leg_share_edit_notification_card.dart';
|
import 'package:mileograph_flutter/components/widgets/leg_share_edit_notification_card.dart';
|
||||||
@@ -221,6 +222,11 @@ class _MyAppState extends State<MyApp> {
|
|||||||
path: '/traction/pending',
|
path: '/traction/pending',
|
||||||
builder: (context, state) => const TractionPendingPage(),
|
builder: (context, state) => const TractionPendingPage(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/traction/changes',
|
||||||
|
builder: (context, state) =>
|
||||||
|
const TractionPendingChangesPage(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/profile',
|
path: '/profile',
|
||||||
builder: (context, state) => const ProfilePage(),
|
builder: (context, state) => const ProfilePage(),
|
||||||
@@ -238,7 +244,15 @@ class _MyAppState extends State<MyApp> {
|
|||||||
label = extra;
|
label = extra;
|
||||||
}
|
}
|
||||||
if (label.trim().isEmpty) label = 'Loco $locoId';
|
if (label.trim().isEmpty) label = 'Loco $locoId';
|
||||||
return LocoTimelinePage(locoId: locoId, locoLabel: label);
|
bool showPending = false;
|
||||||
|
if (extra is Map && extra['showPending'] is bool) {
|
||||||
|
showPending = extra['showPending'] as bool;
|
||||||
|
}
|
||||||
|
return LocoTimelinePage(
|
||||||
|
locoId: locoId,
|
||||||
|
locoLabel: label,
|
||||||
|
forceShowPending: showPending,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
@@ -523,26 +537,34 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
final logo = Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: const [
|
||||||
|
TextSpan(text: "Mile"),
|
||||||
|
TextSpan(
|
||||||
|
text: "O",
|
||||||
|
style: TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
TextSpan(text: "graph"),
|
||||||
|
],
|
||||||
|
style: const TextStyle(
|
||||||
|
decoration: TextDecoration.none,
|
||||||
|
color: Colors.white,
|
||||||
|
fontFamily: "Tomatoes",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||||
title: Text.rich(
|
title: isWide
|
||||||
TextSpan(
|
? logo
|
||||||
children: const [
|
: FittedBox(
|
||||||
TextSpan(text: "Mile"),
|
fit: BoxFit.scaleDown,
|
||||||
TextSpan(
|
alignment: Alignment.centerLeft,
|
||||||
text: "O",
|
child: logo,
|
||||||
style: TextStyle(color: Colors.red),
|
|
||||||
),
|
),
|
||||||
TextSpan(text: "graph"),
|
|
||||||
],
|
|
||||||
style: const TextStyle(
|
|
||||||
decoration: TextDecoration.none,
|
|
||||||
color: Colors.white,
|
|
||||||
fontFamily: "Tomatoes",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
_buildNotificationAction(context, data),
|
_buildNotificationAction(context, data),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@@ -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
|
# 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
|
# 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.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.7.2+10
|
version: 0.7.3+11
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.8.1
|
||||||
|
|||||||
Reference in New Issue
Block a user