diff --git a/Directory.Build.props b/Directory.Build.props index f7cc80c8..1edfd13c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,8 +9,8 @@ disable latest - 5.7.4 - 46 + 5.7.5 + 47 FitEdit EnduraByte LLC 2024 diff --git a/Infrastructure/FitEdit.Adapters.Fit/Extensions/FieldTools.cs b/Infrastructure/FitEdit.Adapters.Fit/Extensions/FieldTools.cs index 526663ca..d3bca0e7 100644 --- a/Infrastructure/FitEdit.Adapters.Fit/Extensions/FieldTools.cs +++ b/Infrastructure/FitEdit.Adapters.Fit/Extensions/FieldTools.cs @@ -9,7 +9,7 @@ public static class FieldTools public static void ReadFieldValue( FieldBase field, byte size, - EndianBinaryReader mesgReader) + BinaryReader mesgReader) { byte baseType = (byte)(field.Type & Fit.BaseTypeNumMask); // strings may be an array and are of variable length @@ -76,7 +76,7 @@ public static void ReadFieldValue( private static bool TryReadValue( out object value, byte type, - EndianBinaryReader mesgReader, + BinaryReader mesgReader, byte size) { bool invalid = true; diff --git a/Infrastructure/FitEdit.Adapters.Fit/Mesg.cs b/Infrastructure/FitEdit.Adapters.Fit/Mesg.cs index 9e303772..b4b9009d 100644 --- a/Infrastructure/FitEdit.Adapters.Fit/Mesg.cs +++ b/Infrastructure/FitEdit.Adapters.Fit/Mesg.cs @@ -120,7 +120,7 @@ public Mesg(Stream fitStream, MesgDefinition defnMesg) public void Read(Stream inStream, MesgDefinition defnMesg) { inStream.Position = 1; - EndianBinaryReader mesgReader = new EndianBinaryReader(inStream, defnMesg.IsBigEndian); + BinaryReader mesgReader = new EndianBinaryReader(inStream, defnMesg.IsBigEndian); LocalNum = defnMesg.LocalMesgNum; diff --git a/Infrastructure/FitEdit.Adapters.Fit/MesgDefinition.cs b/Infrastructure/FitEdit.Adapters.Fit/MesgDefinition.cs index 5bf03b27..2ad00bd7 100644 --- a/Infrastructure/FitEdit.Adapters.Fit/MesgDefinition.cs +++ b/Infrastructure/FitEdit.Adapters.Fit/MesgDefinition.cs @@ -90,12 +90,12 @@ internal IEnumerable DeveloperFieldDefinitions #endregion #region Constructors - internal MesgDefinition() : base((MesgDefinition)null) - { - LocalMesgNum = 0; - GlobalMesgNum = (ushort)MesgNum.Invalid; - architecture = Fit.LittleEndian; - } + // internal MesgDefinition() : base((MesgDefinition)null) + // { + // LocalMesgNum = 0; + // GlobalMesgNum = (ushort)MesgNum.Invalid; + // architecture = Fit.LittleEndian; + // } internal MesgDefinition( Stream source, @@ -137,7 +137,7 @@ public MesgDefinition(MesgDefinition mesgDef) : base(mesgDef) { LocalMesgNum = mesgDef.LocalMesgNum; GlobalMesgNum = mesgDef.GlobalMesgNum; - architecture = mesgDef.IsBigEndian ? Fit.BigEndian : Fit.LittleEndian; + architecture = mesgDef.architecture; NumFields = mesgDef.NumFields; foreach (FieldDefinition fieldDef in mesgDef.fieldDefs) diff --git a/Infrastructure/FitEdit.Data/Fit/Edits/EmptyEdit.cs b/Infrastructure/FitEdit.Data/Fit/Edits/EmptyEdit.cs new file mode 100644 index 00000000..f57a6114 --- /dev/null +++ b/Infrastructure/FitEdit.Data/Fit/Edits/EmptyEdit.cs @@ -0,0 +1,6 @@ +namespace FitEdit.Data.Fit.Edits; + +public class EmptyEdit : IEdit +{ + public FitFile Apply() => new(); +} \ No newline at end of file diff --git a/Infrastructure/FitEdit.Data/Fit/Edits/IEdit.cs b/Infrastructure/FitEdit.Data/Fit/Edits/IEdit.cs new file mode 100644 index 00000000..c75bad1a --- /dev/null +++ b/Infrastructure/FitEdit.Data/Fit/Edits/IEdit.cs @@ -0,0 +1,6 @@ +namespace FitEdit.Data.Fit.Edits; + +public interface IEdit +{ + public FitFile Apply(); +} \ No newline at end of file diff --git a/Infrastructure/FitEdit.Data/Fit/Edits/SplitLapEdit.cs b/Infrastructure/FitEdit.Data/Fit/Edits/SplitLapEdit.cs new file mode 100644 index 00000000..04a55e6d --- /dev/null +++ b/Infrastructure/FitEdit.Data/Fit/Edits/SplitLapEdit.cs @@ -0,0 +1,33 @@ +using Dynastream.Fit; + +namespace FitEdit.Data.Fit.Edits; + +public class SplitLapEdit(FitFile fit, RecordMesg rec) : IEdit +{ + public FitFile Apply() + { + var lap = FindLapContaining(fit, rec); + + var records1 = fit.GetRecords(lap.Start(), rec.InstantOfTime()); + var records2 = fit.GetRecords(rec.InstantOfTime(), lap.End()); + + var start1 = records1.First().InstantOfTime(); + var end1 = records1.Last().InstantOfTime(); + + var start2 = records2.First().InstantOfTime(); + var end2 = records2.Last().InstantOfTime(); + + var lap1 = FitFileExtensions.ReconstructLap(records1, start1, end1); + var lap2 = FitFileExtensions.ReconstructLap(records2, start2, end2); + + fit.Remove(lap); + fit.Add(lap1); + fit.Add(lap2); + + fit.ForwardfillEvents(); + return fit; + } + + // Find the lap the RecordMesg belongs to + private static LapMesg FindLapContaining(FitFile fit, RecordMesg record) => fit.Get().FirstOrDefault(lap => record.IsBetween(lap.Start(), lap.End())); +} \ No newline at end of file diff --git a/Infrastructure/FitEdit.Data/Fit/FitFileExtensions.cs b/Infrastructure/FitEdit.Data/Fit/FitFileExtensions.cs index f423f9ad..c7c5a844 100644 --- a/Infrastructure/FitEdit.Data/Fit/FitFileExtensions.cs +++ b/Infrastructure/FitEdit.Data/Fit/FitFileExtensions.cs @@ -178,7 +178,7 @@ public static FitFile BackfillEvents(this FitFile f, int resolution = 100, Actio public static string OneLine(this FitFile f) => f.Sessions.Count switch { 1 => $"From {f.Sessions[0].Start()} to {f.Sessions[0].End()}: {f.Sessions[0].GetTotalDistance()} m in {f.Sessions[0].GetTotalElapsedTime()}s ({f.Sessions[0].GetEnhancedAvgSpeed():0.##} m/s)", - _ when f.Sessions.Count > 1 => $"From {f.Sessions.First().Start()} to {f.Sessions.Last().End()}: {f.Sessions.TotalDistance()} m in {f.Sessions.TotalElapsedTime()}s", + > 1 => $"From {f.Sessions.First().Start()} to {f.Sessions.Last().End()}: {f.Sessions.TotalDistance()} m in {f.Sessions.TotalElapsedTime()}s", _ => "No sessions", }; @@ -223,8 +223,8 @@ private static FitFile Print(this FitFile f, Action print, bool showReco foreach (var rec in lapRecords) { - var speed = new Speed { Unit = Unit.MetersPerSecond, Value = (double)rec.GetEnhancedSpeed() }; - var distance = new Distance { Unit = Unit.Meter, Value = (double)rec.GetDistance() }; + var speed = new Speed { Unit = Unit.MetersPerSecond, Value = rec.GetEnhancedSpeed() ?? 0 }; + var distance = new Distance { Unit = Unit.Meter, Value = rec.GetDistance() ?? 0 }; print($" At {rec.InstantOfTime():HH:mm:ss}: {distance.Miles():0.##} mi, {speed.Convert(Unit.MinutesPerMile)}, {rec.GetHeartRate()} bpm, {(rec.GetCadence() + rec.GetFractionalCadence()) * 2} cad"); //print($" At {rec.Start():HH:mm:ss}: {rec.GetDistance():0.##} m, {rec.GetEnhancedSpeed():0.##} m/s, {rec.GetHeartRate()} bpm, {(rec.GetCadence() + rec.GetFractionalCadence()) * 2} cad"); @@ -371,6 +371,7 @@ public static FitFile ApplySpeeds(this FitFile fitFile, Dictionary l return merged; } + public static (FitFile first, FitFile second) SplitAt(this FitFile source, DateTime at) { DateTime start = source.GetStartTime(); @@ -391,17 +392,21 @@ public static List SplitByLap(this FitFile? source) .ToList(); } - private static FitFile? ExtractRecords(this FitFile? source, DateTime start, DateTime end) - { - List records = source + private static FitFile? ExtractRecords(this FitFile? source, DateTime start, DateTime end) => + RepairAdditively(source, GetRecords(source, start, end)); + + public static List GetLaps(this FitFile? source, DateTime start, DateTime end) => source + .Get() + .InstantBetween(start, end) + .ToList() + .Sorted(MessageExtensions.SortByTimestamp); + + public static List GetRecords(this FitFile? source, DateTime start, DateTime end) => source .Get() .InstantBetween(start, end) .ToList() .Sorted(MessageExtensions.SortByTimestamp); - return RepairAdditively(source, records); - } - private static List FindAll(this FitFile f) where T : Mesg => f.FindAll(typeof(T)).Cast().ToList(); private static List FindAll(this FitFile f, Type t) => f.Events.OfType() @@ -420,7 +425,7 @@ private static List FindAll(this FitFile f, Type t) => f.Events.OfType - /// This method preserves more information than , + /// This method preserves more information than , /// such as individual sports in multisport events like triathlons, sessions, and laps. /// Use that one when this one does not work. /// @@ -510,8 +515,8 @@ private static List FindAll(this FitFile f, Type t) => f.Events.OfType().FirstOrDefault(); - fileId!.SetTimeCreated(start); + var fileId = source.Get().FirstOrDefault() ?? new FileIdMesg(); + fileId.SetTimeCreated(start); var fileCreator = source.Get().FirstOrDefault(); var deviceInfo = source.Get().InstantBetween(start, end); @@ -531,14 +536,14 @@ private static List FindAll(this FitFile f, Type t) => f.Events.OfType records2 = EnsureCumulativeDistance(records); SessionMesg session = ReconstructSession(source, records2); - LapMesg lap = ReconstructLap(source, records2); + LapMesg lap = source.ReconstructLap(records2); ActivityMesg activity = ReconstructActivity(source); List mesgs = new(); - if (fileId != null) { mesgs.Add(fileId); } + mesgs.Add(fileId); if (fileCreator != null) { mesgs.Add(fileCreator); } - if (startEvent != null) { mesgs.Add(startEvent); } + mesgs.Add(startEvent); if (deviceInfo != null) { mesgs.AddRange(deviceInfo); } if (deviceSettings != null) { mesgs.Add(deviceSettings); } if (userProfile != null) { mesgs.Add(userProfile); } @@ -673,7 +678,6 @@ private static void AddDistance(this RecordMesg record, double distance) var sport = source.Get().FirstOrDefault(); var firstLap = source.Get().FirstOrDefault(); - var lastLap = source.Get().LastOrDefault(); var session = source.Get().FirstOrDefault(); var activity = source.Get().FirstOrDefault(); @@ -693,7 +697,7 @@ private static void AddDistance(this RecordMesg record, double distance) if (firstLap is null) { - firstLap = ReconstructLap(source, records); + firstLap = source.ReconstructLap(records); if (sport != null) { @@ -735,7 +739,7 @@ private static void AddDistance(this RecordMesg record, double distance) Dynastream.Fit.DateTime? lapEnd = lap.GetStartTime(); float? elapsed = lap.GetTotalElapsedTime(); - if (lap != null && elapsed != null) + if (elapsed != null) { lapEnd.Add(new Dynastream.Fit.DateTime((uint)elapsed)); } @@ -743,18 +747,22 @@ private static void AddDistance(this RecordMesg record, double distance) return lapEnd?.GetDateTime(); } - private static LapMesg ReconstructLap(FitFile source, List records) + public static LapMesg ReconstructLap(List records, DateTime start, DateTime end) => + ReconstructLap(records, new Dynastream.Fit.DateTime(start), new Dynastream.Fit.DateTime(end)); + + private static LapMesg ReconstructLap(List records, Dynastream.Fit.DateTime start, Dynastream.Fit.DateTime end) { - if (!records.Any()) - { - return GetFakeLap(source); - } - - var start = new Dynastream.Fit.DateTime(source.GetStartTime()); - var end = new Dynastream.Fit.DateTime(source.GetEndTime()); - - float totalDistance = SumDistance(records); - + double avgSpeed = records.Average(r => r.GetEnhancedSpeed() ?? 0); + double maxSpeed = records.Max(r => r.GetEnhancedSpeed() ?? 0); + double avgHr = records.Average(r => r.GetHeartRate() ?? 0); + double maxHr = records.Max(r => r.GetHeartRate() ?? 0); + double avgCadence = records.Average(r => r.GetCadence() ?? 0); + double maxCadence = records.Max(r => r.GetCadence() ?? 0); + double avgPower = records.Average(r => r.GetPower() ?? 0); + double maxPower = records.Max(r => r.GetPower() ?? 0); + uint totalCalories = (records.Last().GetCalories() ?? 0U) - (records.First().GetCalories() ?? 0U); + double totalDistance = (records.Last().GetDistance() ?? 0) - (records.First().GetDistance() ?? 0); + var lap = new LapMesg(); lap.SetStartTime(start); lap.SetTimestamp(end); @@ -766,11 +774,26 @@ private static LapMesg ReconstructLap(FitFile source, List records) lap.SetEndPositionLong(records.Last().GetPositionLong()); lap.SetTotalElapsedTime(end.GetTimeStamp() - start.GetTimeStamp()); lap.SetTotalTimerTime(end.GetTimeStamp() - start.GetTimeStamp()); - lap.SetTotalDistance(totalDistance); + lap.SetTotalDistance((float)totalDistance); + lap.SetTotalCalories((ushort)totalCalories); + lap.SetEnhancedAvgSpeed((float)avgSpeed); + lap.SetEnhancedMaxSpeed((float)maxSpeed); + lap.SetAvgHeartRate((byte)avgHr); + lap.SetMaxHeartRate((byte)maxHr); + lap.SetAvgCadence((byte)avgCadence); + lap.SetMaxCadence((byte)maxCadence); + lap.SetAvgPower((ushort)avgPower); + lap.SetMaxPower((ushort)maxPower); + lap.SetLapTrigger(LapTrigger.SessionEnd); return lap; } + + private static LapMesg ReconstructLap(this FitFile source, List records) => + records.Any() + ? ReconstructLap(records, source.GetStartTime(), source.GetEndTime()) + : GetFakeLap(source); private static SessionMesg ReconstructSession(FitFile source, List records) { @@ -782,8 +805,6 @@ private static SessionMesg ReconstructSession(FitFile source, List r var start = new Dynastream.Fit.DateTime(source.GetStartTime()); var end = new Dynastream.Fit.DateTime(source.GetEndTime()); - float totalDistance = SumDistance(records); - var session = new SessionMesg(); session.SetStartTime(start); session.SetTimestamp(end); @@ -795,7 +816,7 @@ private static SessionMesg ReconstructSession(FitFile source, List r session.SetEndPositionLong(records.Last().GetPositionLong()); session.SetTotalElapsedTime(end.GetTimeStamp() - start.GetTimeStamp()); session.SetTotalTimerTime(end.GetTimeStamp() - start.GetTimeStamp()); - session.SetTotalDistance(totalDistance); + session.SetTotalDistance(records.Last().GetDistance() - records.First().GetDistance()); session.SetTrigger(SessionTrigger.ActivityEnd); return session; } @@ -844,20 +865,15 @@ private static SessionMesg GetFakeSession(FitFile source) /// /// Sum the distance between all records /// - private static float SumDistance(List records) + private static double SumDistance(List records) { - double? sum = 0; - var penultimate = Math.Max(0, records.Count - 1); - foreach (int i in Enumerable.Range(1, penultimate)) - { - sum += records[i].GetDistance() - (double?)records[i - 1].GetDistance(); - } - - return (float)(sum ?? 0); + return Enumerable.Range(1, penultimate) + .Aggregate(0, (current, i) => + current + ((records[i].GetDistance() ?? 0) - (records[i - 1].GetDistance() ?? 0))); } - + /// /// Reconstruct missing Session messages from Lap messages. /// @@ -988,7 +1004,7 @@ public static void RemoveAll(this FitFile fit) where T : Mesg /// /// Remove all messages and message definitions for the given message type. /// - public static void RemoveAll(this FitFile fit, Type t) + public static void RemoveAllOfType(this FitFile fit, Type t) { var matches = fit.FindAll(t); foreach (var match in matches) @@ -1026,21 +1042,4 @@ public static void RemoveAll(this FitFile fit, Type t, DateTime after = default, private static bool HasMessageOfType(this EventArgs e, Type t) => e is MesgEventArgs mea && mea.mesg.Num == MessageFactory.MesgNums[t] || e is MesgDefinitionEventArgs mdea && mdea.mesgDef.GlobalMesgNum == MessageFactory.MesgNums[t]; - - /// - /// Return true if the given message occurs between the given DateTimes. - /// If either DateTime is not specified, it is not considered. - /// - private static bool IsBetween(this Mesg mesg, DateTime after = default, DateTime before = default) => mesg switch - { - _ when mesg is IDurationOfTime dur - && (after == default || dur.GetStartTime().GetDateTime() > after) - && (before == default || dur.GetTimestamp().GetDateTime() <= before) => true, - - _ when mesg is IInstantOfTime inst - && (after == default || inst.GetTimestamp().GetDateTime() > after) - && (before == default || inst.GetTimestamp().GetDateTime() <= before) => true, - - _ => false, - }; -} +} \ No newline at end of file diff --git a/Infrastructure/FitEdit.Data/Fit/MesgExtensions.cs b/Infrastructure/FitEdit.Data/Fit/MesgExtensions.cs index 96ae943a..6d6ddfe5 100644 --- a/Infrastructure/FitEdit.Data/Fit/MesgExtensions.cs +++ b/Infrastructure/FitEdit.Data/Fit/MesgExtensions.cs @@ -6,6 +6,7 @@ using FitEdit.Model.Extensions; using Dynastream.Fit; using AssemblyExtensions = FitEdit.Model.Extensions.AssemblyExtensions; +using DateTime = System.DateTime; namespace FitEdit.Data.Fit; @@ -22,6 +23,21 @@ static MesgExtensions() fit_ = assembly; } + /// + /// Return true if the given message occurs between the given DateTimes. + /// If either DateTime is not specified, it is not considered. + /// + public static bool IsBetween(this Mesg mesg, DateTime after = default, DateTime before = default) => mesg switch + { + IDurationOfTime dur when (after == default || dur.GetStartTime().GetDateTime() > after) + && (before == default || dur.GetTimestamp().GetDateTime() <= before) => true, + + IInstantOfTime inst when (after == default || inst.GetTimestamp().GetDateTime() > after) + && (before == default || inst.GetTimestamp().GetDateTime() <= before) => true, + + _ => false, + }; + public static void SetFieldValue(this Mesg mesg, string name, object? value, bool pretty) { try diff --git a/Ui/FitEdit.Ui.iOS/Info.plist b/Ui/FitEdit.Ui.iOS/Info.plist index 3906503c..c4480b6e 100644 --- a/Ui/FitEdit.Ui.iOS/Info.plist +++ b/Ui/FitEdit.Ui.iOS/Info.plist @@ -7,7 +7,7 @@ CFBundleIdentifier com.endurabyte.fitedit CFBundleShortVersionString - 5.7.4 + 5.7.5 LSRequiresIPhoneOS MinimumOSVersion @@ -58,4 +58,4 @@ CFBundleVersion - + \ No newline at end of file diff --git a/Ui/FitEdit.Ui/ViewModels/RecordViewModel.cs b/Ui/FitEdit.Ui/ViewModels/RecordViewModel.cs index ce8b5699..44b73456 100644 --- a/Ui/FitEdit.Ui/ViewModels/RecordViewModel.cs +++ b/Ui/FitEdit.Ui/ViewModels/RecordViewModel.cs @@ -10,6 +10,7 @@ using Dynastream.Fit; using FitEdit.Data; using FitEdit.Data.Fit; +using FitEdit.Data.Fit.Edits; using FitEdit.Model.Extensions; using FitEdit.Ui.Converters; using FitEdit.Ui.Model; @@ -576,6 +577,31 @@ private void AddContextMenus(string mesgName, DataGrid dg) }; menu.Items.Add(delete); + if (mesgName == "Record") + { + var menuItem = new MenuItem + { + Header = "Split Activity Here", + Command = ReactiveCommand.Create(SplitActivity), + }; + ToolTip.SetTip(menuItem, + "Split the activity at the selected record." + + "\nThe first activity will contain all messages up to and including the selected row." + + "\nThe second activity will contain all messages after the selected row."); + menu.Items.Add(menuItem); + } + + if (mesgName == "Record") + { + var menuItem = new MenuItem + { + Header = "Split Lap Here", + Command = ReactiveCommand.Create(SplitLap), + }; + ToolTip.SetTip(menuItem, "Split the lap at the selected record into two laps."); + menu.Items.Add(menuItem); + } + if (mesgName == "Lap") { var menuItem = new MenuItem @@ -679,6 +705,15 @@ public async Task SplitActivity() await fileService_.CreateAsync(first, "(Split 1)"); await fileService_.CreateAsync(second, "(Split 2)"); } + + public void SplitLap() + { + if (fitFile_ is null) { return; } + IEdit edit = new SplitLapEdit(fitFile_, fitFile_.Records[SelectedIndex]); + edit.Apply(); + + HaveUnsavedChanges = true; + } private void HandleCellEditEnding(object? sender, DataGridCellEditEndingEventArgs e) { @@ -733,6 +768,6 @@ private void SetFieldValues(List messages, string fieldName, obj { message.SetFieldValue(fieldName, newValue, PrettifyFields); } + HaveUnsavedChanges = true; } - } diff --git a/Ui/FitEdit.Ui/Views/RecordView.axaml b/Ui/FitEdit.Ui/Views/RecordView.axaml index c8aeb46e..88d3417a 100644 --- a/Ui/FitEdit.Ui/Views/RecordView.axaml +++ b/Ui/FitEdit.Ui/Views/RecordView.axaml @@ -57,18 +57,6 @@ - -