diff --git a/iphone/CoreApi/CoreApi/Framework/MWMFrameworkHelper.h b/iphone/CoreApi/CoreApi/Framework/MWMFrameworkHelper.h index a81f261b43..0272047ba5 100644 --- a/iphone/CoreApi/CoreApi/Framework/MWMFrameworkHelper.h +++ b/iphone/CoreApi/CoreApi/Framework/MWMFrameworkHelper.h @@ -5,6 +5,7 @@ @class MWMMapSearchResult; @class TrackInfo; +@class ElevationProfileData; typedef NS_ENUM(NSUInteger, MWMZoomMode) { MWMZoomModeIn = 0, MWMZoomModeOut }; @@ -20,14 +21,17 @@ NS_ASSUME_NONNULL_BEGIN typedef void (^SearchInDownloaderCompletions)(NSArray *results, BOOL finished); typedef void (^TrackRecordingUpdatedHandler)(TrackInfo * _Nonnull trackInfo); -@protocol TrackRecorder +@protocol TrackRecorder + (void)startTrackRecording; + (void)setTrackRecordingUpdateHandler:(TrackRecordingUpdatedHandler _Nullable)trackRecordingDidUpdate; + (void)stopTrackRecording; -+ (void)saveTrackRecordingWithName:(nullable NSString *)name; ++ (void)saveTrackRecordingWithName:(nonnull NSString *)name; + (BOOL)isTrackRecordingEnabled; + (BOOL)isTrackRecordingEmpty; +/// Returns current track recording elevation info. +/// If the track recording is not in progress, returns empty ElevationProfileData. ++ (ElevationProfileData * _Nonnull)trackRecordingElevationInfo; @end diff --git a/iphone/CoreApi/CoreApi/Framework/MWMFrameworkHelper.mm b/iphone/CoreApi/CoreApi/Framework/MWMFrameworkHelper.mm index d00c60df64..47f6fe1461 100644 --- a/iphone/CoreApi/CoreApi/Framework/MWMFrameworkHelper.mm +++ b/iphone/CoreApi/CoreApi/Framework/MWMFrameworkHelper.mm @@ -3,6 +3,7 @@ #import "ProductsConfiguration+Core.h" #import "Product+Core.h" #import "TrackInfo+Core.h" +#import "ElevationProfileData+Core.h" #include "Framework.h" @@ -234,8 +235,8 @@ static Framework::ProductsPopupCloseReason ConvertProductPopupCloseReasonToCore( GetFramework().StopTrackRecording(); } -+ (void)saveTrackRecordingWithName:(nullable NSString *)name { - GetFramework().SaveTrackRecordingWithName(name == nil ? "" : name.UTF8String); ++ (void)saveTrackRecordingWithName:(nonnull NSString *)name { + GetFramework().SaveTrackRecordingWithName(name.UTF8String); } + (BOOL)isTrackRecordingEnabled { @@ -246,6 +247,10 @@ static Framework::ProductsPopupCloseReason ConvertProductPopupCloseReasonToCore( return GetFramework().IsTrackRecordingEmpty(); } ++ (ElevationProfileData * _Nonnull)trackRecordingElevationInfo { + return [[ElevationProfileData alloc] initWithElevationInfo:GetFramework().GetTrackRecordingElevationInfo()]; +} + // MARK: - ProductsManager + (nullable ProductsConfiguration *)getProductsConfiguration { diff --git a/iphone/CoreApi/CoreApi/PlacePageData/Common/PlacePagePreviewData.h b/iphone/CoreApi/CoreApi/PlacePageData/Common/PlacePagePreviewData.h index ab25289cb4..8b5a10fa6d 100644 --- a/iphone/CoreApi/CoreApi/PlacePageData/Common/PlacePagePreviewData.h +++ b/iphone/CoreApi/CoreApi/PlacePageData/Common/PlacePagePreviewData.h @@ -1,6 +1,7 @@ #import @class PlacePageScheduleData; +@class TrackInfo; typedef NS_ENUM(NSInteger, PlacePageDataHotelType) { PlacePageDataHotelTypeHotel, @@ -39,6 +40,8 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, readonly) PlacePageDataSchedule schedule; @property(nonatomic, readonly) BOOL isMyPosition; +- (instancetype)initWithTrackInfo:(TrackInfo * _Nonnull)trackInfo; + @end NS_ASSUME_NONNULL_END diff --git a/iphone/CoreApi/CoreApi/PlacePageData/Common/PlacePagePreviewData.mm b/iphone/CoreApi/CoreApi/PlacePageData/Common/PlacePagePreviewData.mm index 9aed885cbb..785d906302 100644 --- a/iphone/CoreApi/CoreApi/PlacePageData/Common/PlacePagePreviewData.mm +++ b/iphone/CoreApi/CoreApi/PlacePageData/Common/PlacePagePreviewData.mm @@ -1,4 +1,8 @@ #import "PlacePagePreviewData+Core.h" +#import "DistanceFormatter.h" +#import "AltitudeFormatter.h" +#import "DurationFormatter.h" +#import "TrackInfo.h" #include "3party/opening_hours/opening_hours.hpp" @@ -46,6 +50,18 @@ static PlacePageDataSchedule convertOpeningHours(std::string_view rawOH) @implementation PlacePagePreviewData +- (instancetype)initWithTrackInfo:(TrackInfo * _Nonnull)trackInfo { + self = [super init]; + if (self) { + // TODO: (KK) Replace separator with a shared static constant. + NSString * kSeparator = @" • "; + NSString * duration = [DurationFormatter durationStringFromTimeInterval:trackInfo.duration]; + NSString * distance = [DistanceFormatter distanceStringFromMeters:trackInfo.distance]; + _title = [@[duration, distance] componentsJoinedByString:kSeparator]; + } + return self; +} + @end @implementation PlacePagePreviewData (Core) diff --git a/iphone/CoreApi/CoreApi/PlacePageData/Common/PlacePageTrackData.h b/iphone/CoreApi/CoreApi/PlacePageData/Common/PlacePageTrackData.h index 0a6ad57bf4..fa83eb897a 100644 --- a/iphone/CoreApi/CoreApi/PlacePageData/Common/PlacePageTrackData.h +++ b/iphone/CoreApi/CoreApi/PlacePageData/Common/PlacePageTrackData.h @@ -10,8 +10,10 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, readonly) MWMTrackID trackId; @property(nonatomic, readonly) MWMMarkGroupID groupId; -@property(nonatomic, readonly, nonnull) TrackInfo * trackInfo; -@property(nonatomic, readonly, nullable) ElevationProfileData * elevationProfileData; +@property(nonatomic, readwrite, nonnull) TrackInfo * trackInfo; +@property(nonatomic, readwrite, nullable) ElevationProfileData * elevationProfileData; + +- (instancetype)initWithTrackInfo:(TrackInfo * _Nonnull)trackInfo elevationInfo:(ElevationProfileData * _Nullable)elevationInfo; @end diff --git a/iphone/CoreApi/CoreApi/PlacePageData/Common/PlacePageTrackData.mm b/iphone/CoreApi/CoreApi/PlacePageData/Common/PlacePageTrackData.mm index ad421d8f16..d8b4ff89fe 100644 --- a/iphone/CoreApi/CoreApi/PlacePageData/Common/PlacePageTrackData.mm +++ b/iphone/CoreApi/CoreApi/PlacePageData/Common/PlacePageTrackData.mm @@ -4,6 +4,15 @@ @implementation PlacePageTrackData +- (nonnull instancetype)initWithTrackInfo:(TrackInfo *)trackInfo elevationInfo:(ElevationProfileData * _Nullable)elevationInfo { + self = [super init]; + if (self) { + _trackInfo = trackInfo; + _elevationProfileData = elevationInfo; + } + return self; +} + @end @implementation PlacePageTrackData (Core) diff --git a/iphone/CoreApi/CoreApi/PlacePageData/ElevationProfile/ElevationProfileData+Core.h b/iphone/CoreApi/CoreApi/PlacePageData/ElevationProfile/ElevationProfileData+Core.h index 48c63cde20..0a00cabca2 100644 --- a/iphone/CoreApi/CoreApi/PlacePageData/ElevationProfile/ElevationProfileData+Core.h +++ b/iphone/CoreApi/CoreApi/PlacePageData/ElevationProfile/ElevationProfileData+Core.h @@ -11,6 +11,7 @@ NS_ASSUME_NONNULL_BEGIN elevationInfo:(ElevationInfo const &)elevationInfo activePoint:(double)activePoint myPosition:(double)myPosition; +- (instancetype)initWithElevationInfo:(ElevationInfo const &)elevationInfo; @end diff --git a/iphone/CoreApi/CoreApi/PlacePageData/ElevationProfile/ElevationProfileData.h b/iphone/CoreApi/CoreApi/PlacePageData/ElevationProfile/ElevationProfileData.h index f8b5a070e3..2087a30a2f 100644 --- a/iphone/CoreApi/CoreApi/PlacePageData/ElevationProfile/ElevationProfileData.h +++ b/iphone/CoreApi/CoreApi/PlacePageData/ElevationProfile/ElevationProfileData.h @@ -13,6 +13,7 @@ typedef NS_ENUM(NSInteger, ElevationDifficulty) { @interface ElevationProfileData : NSObject @property(nonatomic, readonly) uint64_t trackId; +@property(nonatomic, readonly) BOOL isTrackRecording; @property(nonatomic, readonly) ElevationDifficulty difficulty; @property(nonatomic, readonly) NSArray * points; @property(nonatomic, readonly) double activePoint; diff --git a/iphone/CoreApi/CoreApi/PlacePageData/ElevationProfile/ElevationProfileData.mm b/iphone/CoreApi/CoreApi/PlacePageData/ElevationProfile/ElevationProfileData.mm index 69e89c9388..ffdd63c2d4 100644 --- a/iphone/CoreApi/CoreApi/PlacePageData/ElevationProfile/ElevationProfileData.mm +++ b/iphone/CoreApi/CoreApi/PlacePageData/ElevationProfile/ElevationProfileData.mm @@ -30,23 +30,36 @@ static ElevationDifficulty convertDifficulty(uint8_t difficulty) { if (self) { _trackId = trackId; _difficulty = convertDifficulty(elevationInfo.GetDifficulty()); - - auto const & points = elevationInfo.GetPoints(); - NSMutableArray * pointsArray = [[NSMutableArray alloc] initWithCapacity:points.size()]; - for (auto const & point : points) { - auto pointLatLon = mercator::ToLatLon(point.m_point.GetPoint()); - CLLocationCoordinate2D coordinates = CLLocationCoordinate2DMake(pointLatLon.m_lat, pointLatLon.m_lon); - ElevationHeightPoint * elevationPoint = [[ElevationHeightPoint alloc] initWithCoordinates:coordinates - distance:point.m_distance - andAltitude:point.m_point.GetAltitude()]; - [pointsArray addObject:elevationPoint]; - } - _points = [pointsArray copy]; + _points = [ElevationProfileData pointsFromElevationInfo:elevationInfo]; _activePoint = activePoint; _myPosition = myPosition; + _isTrackRecording = false; } return self; } +- (instancetype)initWithElevationInfo:(ElevationInfo const &)elevationInfo { + self = [super init]; + if (self) { + _difficulty = convertDifficulty(elevationInfo.GetDifficulty()); + _points = [ElevationProfileData pointsFromElevationInfo:elevationInfo]; + _isTrackRecording = true; + } + return self; +} + ++ (NSArray *)pointsFromElevationInfo:(ElevationInfo const &)elevationInfo { + auto const & points = elevationInfo.GetPoints(); + NSMutableArray * pointsArray = [[NSMutableArray alloc] initWithCapacity:points.size()]; + for (auto const & point : points) { + auto pointLatLon = mercator::ToLatLon(point.m_point.GetPoint()); + CLLocationCoordinate2D coordinates = CLLocationCoordinate2DMake(pointLatLon.m_lat, pointLatLon.m_lon); + ElevationHeightPoint * elevationPoint = [[ElevationHeightPoint alloc] initWithCoordinates:coordinates + distance:point.m_distance + andAltitude:point.m_point.GetAltitude()]; + [pointsArray addObject:elevationPoint]; + } + return [pointsArray copy]; +} @end diff --git a/iphone/CoreApi/CoreApi/PlacePageData/PlacePageData.h b/iphone/CoreApi/CoreApi/PlacePageData/PlacePageData.h index c3c198816d..99736a9707 100644 --- a/iphone/CoreApi/CoreApi/PlacePageData/PlacePageData.h +++ b/iphone/CoreApi/CoreApi/PlacePageData/PlacePageData.h @@ -10,6 +10,7 @@ @class PlacePageBookmarkData; @class MWMMapNodeAttributes; @class TrackInfo; +@class ElevationProfileData; typedef NS_ENUM(NSInteger, PlacePageRoadType) { PlacePageRoadTypeToll, @@ -49,12 +50,15 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, readonly) CLLocationCoordinate2D locationCoordinate; @property(nonatomic, copy, nullable) MWMVoidBlock onBookmarkStatusUpdate; @property(nonatomic, copy, nullable) MWMVoidBlock onMapNodeStatusUpdate; +@property(nonatomic, copy, nullable) MWMVoidBlock onTrackRecordingProgressUpdate; @property(nonatomic, copy, nullable) void (^onMapNodeProgressUpdate)(uint64_t downloadedBytes, uint64_t totalBytes); - (instancetype)initWithLocalizationProvider:(id)localization; +- (instancetype)initWithTrackInfo:(TrackInfo * _Nonnull)trackInfo elevationInfo:(ElevationProfileData * _Nullable)elevationInfo; - (instancetype)init NS_UNAVAILABLE; - (void)updateBookmarkStatus; +- (void)updateWithTrackInfo:(TrackInfo * _Nonnull)trackInfo elevationInfo:(ElevationProfileData * _Nullable)elevationInfo; @end diff --git a/iphone/CoreApi/CoreApi/PlacePageData/PlacePageData.mm b/iphone/CoreApi/CoreApi/PlacePageData/PlacePageData.mm index cda7f27918..491452b0a2 100644 --- a/iphone/CoreApi/CoreApi/PlacePageData/PlacePageData.mm +++ b/iphone/CoreApi/CoreApi/PlacePageData/PlacePageData.mm @@ -5,6 +5,7 @@ #import "PlacePageInfoData+Core.h" #import "PlacePageBookmarkData+Core.h" #import "PlacePageTrackData+Core.h" +#import "ElevationProfileData+Core.h" #import "MWMMapNodeAttributes.h" #include @@ -84,6 +85,25 @@ static PlacePageRoadType convertRoadType(RoadWarningMarkType roadType) { return self; } +- (instancetype)initWithTrackInfo:(TrackInfo * _Nonnull)trackInfo elevationInfo:(ElevationProfileData * _Nullable)elevationInfo { + self = [super init]; + if (self) { + _objectType = PlacePageObjectTypeTrackRecording; + _roadType = PlacePageRoadTypeNone; + _previewData = [[PlacePagePreviewData alloc] initWithTrackInfo:trackInfo]; + _trackData = [[PlacePageTrackData alloc] initWithTrackInfo:trackInfo elevationInfo:elevationInfo]; + } + return self; +} + +- (void)updateWithTrackInfo:(TrackInfo * _Nonnull)trackInfo elevationInfo:(ElevationProfileData * _Nullable)elevationInfo { + _previewData = [[PlacePagePreviewData alloc] initWithTrackInfo:trackInfo]; + _trackData.trackInfo = trackInfo; + _trackData.elevationProfileData = elevationInfo; + if (self.onTrackRecordingProgressUpdate != nil) + self.onTrackRecordingProgressUpdate(); +} + - (void)dealloc { if (self.mapNodeAttributes != nil) { [[MWMStorage sharedStorage] removeObserver:self]; diff --git a/iphone/Maps/Classes/CustomViews/MapViewControls/MWMMapViewControlsManager.h b/iphone/Maps/Classes/CustomViews/MapViewControls/MWMMapViewControlsManager.h index b57d9bef95..bca62c672d 100644 --- a/iphone/Maps/Classes/CustomViews/MapViewControls/MWMMapViewControlsManager.h +++ b/iphone/Maps/Classes/CustomViews/MapViewControls/MWMMapViewControlsManager.h @@ -4,7 +4,7 @@ @class MapViewController; @class BottomTabBarViewController; -@class TrackRecordingViewController; +@class TrackRecordingButtonViewController; @protocol MWMFeatureHolder; @@ -20,7 +20,7 @@ @property(nonatomic) MWMBottomMenuState menuRestoreState; @property(nonatomic) BOOL isDirectionViewHidden; @property(nonatomic) BottomTabBarViewController * tabBarController; -@property(nonatomic) TrackRecordingViewController * trackRecordingButton; +@property(nonatomic) TrackRecordingButtonViewController * trackRecordingButton; - (instancetype)init __attribute__((unavailable("init is not available"))); - (instancetype)initWithParentController:(MapViewController *)controller; diff --git a/iphone/Maps/Classes/CustomViews/MapViewControls/MWMMapViewControlsManager.mm b/iphone/Maps/Classes/CustomViews/MapViewControls/MWMMapViewControlsManager.mm index 8357f5e63e..5cce5b7fc5 100644 --- a/iphone/Maps/Classes/CustomViews/MapViewControls/MWMMapViewControlsManager.mm +++ b/iphone/Maps/Classes/CustomViews/MapViewControls/MWMMapViewControlsManager.mm @@ -288,7 +288,7 @@ NSString *const kMapToCategorySelectorSegue = @"MapToCategorySelectorSegue"; _trackRecordingButton = nil; } else if (!trackRecordingButtonHidden && !_trackRecordingButton) { - _trackRecordingButton = [[TrackRecordingViewController alloc] init]; + _trackRecordingButton = [[TrackRecordingButtonViewController alloc] init]; [MWMMapWidgetsHelper updateLayoutForAvailableArea]; } } diff --git a/iphone/Maps/Classes/CustomViews/MapViewControls/TrackRecordingViewController.swift b/iphone/Maps/Classes/CustomViews/MapViewControls/TrackRecordingButtonViewController.swift similarity index 91% rename from iphone/Maps/Classes/CustomViews/MapViewControls/TrackRecordingViewController.swift rename to iphone/Maps/Classes/CustomViews/MapViewControls/TrackRecordingButtonViewController.swift index a4385108aa..7cf1a61f9c 100644 --- a/iphone/Maps/Classes/CustomViews/MapViewControls/TrackRecordingViewController.swift +++ b/iphone/Maps/Classes/CustomViews/MapViewControls/TrackRecordingButtonViewController.swift @@ -1,4 +1,4 @@ -final class TrackRecordingViewController: MWMViewController { +final class TrackRecordingButtonViewController: MWMViewController { private enum Constants { static let buttonDiameter = CGFloat(48) @@ -50,6 +50,11 @@ final class TrackRecordingViewController: MWMViewController { // MARK: - Public methods + @objc + func setHidden(_ hidden: Bool) { + button.isHidden = hidden + } + @objc func close(completion: @escaping (() -> Void)) { stopTimer() @@ -75,7 +80,7 @@ final class TrackRecordingViewController: MWMViewController { button.tintColor = Constants.color.darker button.translatesAutoresizingMaskIntoConstraints = false button.setImage(UIImage(resource: .icMenuBookmarkTrackRecording), for: .normal) - button.addTarget(self, action: #selector(onTrackRecordingButtonPressed), for: .touchUpInside) + button.addTarget(self, action: #selector(didTap), for: .touchUpInside) button.isHidden = true } @@ -134,12 +139,7 @@ final class TrackRecordingViewController: MWMViewController { // MARK: - Actions @objc - private func onTrackRecordingButtonPressed(_ sender: Any) { - switch trackRecordingManager.recordingState { - case .inactive: - trackRecordingManager.processAction(.start) - case .active: - trackRecordingManager.processAction(.stop) - } + private func didTap(_ sender: Any) { + MapViewController.shared()?.showTrackRecordingPlacePage() } } diff --git a/iphone/Maps/Classes/MapViewController.h b/iphone/Maps/Classes/MapViewController.h index c6c1a3e3d5..e45d05b848 100644 --- a/iphone/Maps/Classes/MapViewController.h +++ b/iphone/Maps/Classes/MapViewController.h @@ -33,6 +33,7 @@ - (void)openFullPlaceDescriptionWithHtml:(NSString *_Nonnull)htmlString; - (void)searchText:(NSString *_Nonnull)text; - (void)openDrivingOptions; +- (void)showTrackRecordingPlacePage; - (void)setPlacePageTopBound:(CGFloat)bound duration:(double)duration; diff --git a/iphone/Maps/Classes/MapViewController.mm b/iphone/Maps/Classes/MapViewController.mm index 93fc70cb37..f07c0d3326 100644 --- a/iphone/Maps/Classes/MapViewController.mm +++ b/iphone/Maps/Classes/MapViewController.mm @@ -76,6 +76,7 @@ NSString *const kSettingsSegue = @"Map2Settings"; @property(nonatomic, readwrite) MWMMapViewControlsManager *controlsManager; @property(nonatomic, readwrite) SearchOnMapManager *searchManager; +@property(nonatomic, readwrite) TrackRecordingManager *trackRecordingManager; @property(nonatomic) BOOL disableStandbyOnLocationStateMode; @@ -118,15 +119,48 @@ NSString *const kSettingsSegue = @"Map2Settings"; #pragma mark - Map Navigation +- (void)showTrackRecordingPlacePage { + if ([self.trackRecordingManager contains:self]) { + [self dismissPlacePage]; + return; + } + __block PlacePageData * placePageData = [[PlacePageData alloc] initWithTrackInfo:TrackRecordingManager.shared.trackRecordingInfo + elevationInfo:[MWMFrameworkHelper trackRecordingElevationInfo]]; + __weak __typeof(self) weakSelf = self; + [self.trackRecordingManager addObserver:self recordingIsActiveDidChangeHandler:^(TrackRecordingState state, TrackInfo * _Nonnull trackInfo) { + __strong __typeof(weakSelf) self = weakSelf; + switch (state) { + case TrackRecordingStateInactive: + [self stopObservingTrackRecordingUpdates]; + break; + case TrackRecordingStateActive: + if (UIApplication.sharedApplication.applicationState != UIApplicationStateActive) + return; + [self.controlsManager.trackRecordingButton setHidden:YES]; + [placePageData updateWithTrackInfo:trackInfo elevationInfo:[MWMFrameworkHelper trackRecordingElevationInfo]]; + break; + } + }]; + [self.controlsManager.trackRecordingButton setHidden:YES]; + [self showOrUpdatePlacePage:placePageData]; +} + +- (void)stopObservingTrackRecordingUpdates { + [self.trackRecordingManager removeObserver:self]; + if (self.trackRecordingManager.isActive) + [self.controlsManager.trackRecordingButton setHidden:NO]; +} + - (void)showOrUpdatePlacePage:(PlacePageData *)data { if (self.searchManager.isSearching) [self.searchManager setPlaceOnMapSelected:YES]; self.controlsManager.trafficButtonHidden = YES; if (self.placePageVC != nil) { - [PlacePageBuilder update:(PlacePageViewController *)self.placePageVC with:data]; + [PlacePageBuilder update:self.placePageVC with:data]; return; } + [self showPlacePageFor:data]; } @@ -190,6 +224,7 @@ NSString *const kSettingsSegue = @"Map2Settings"; } - (void)hideRegularPlacePage { + [self stopObservingTrackRecordingUpdates]; [self.placePageVC closeAnimatedWithCompletion:^{ [self.placePageVC.view removeFromSuperview]; [self.placePageVC willMoveToParentViewController:nil]; @@ -233,6 +268,7 @@ NSString *const kSettingsSegue = @"Map2Settings"; return; } PlacePageData * data = [[PlacePageData alloc] initWithLocalizationProvider:[[OpeinigHoursLocalization alloc] init]]; + [self stopObservingTrackRecordingUpdates]; [self showOrUpdatePlacePage:data]; } @@ -730,6 +766,12 @@ NSString *const kSettingsSegue = @"Map2Settings"; return _searchManager; } +- (TrackRecordingManager *)trackRecordingManager { + if (!_trackRecordingManager) + _trackRecordingManager = TrackRecordingManager.shared; + return _trackRecordingManager; +} + - (UIView * _Nullable)searchViewContainer { return self.searchManager.viewController.view; } diff --git a/iphone/Maps/Core/Location/MWMLocationManager.h b/iphone/Maps/Core/Location/MWMLocationManager.h index e662c47921..2bb3b033f7 100644 --- a/iphone/Maps/Core/Location/MWMLocationManager.h +++ b/iphone/Maps/Core/Location/MWMLocationManager.h @@ -3,8 +3,15 @@ NS_ASSUME_NONNULL_BEGIN +@protocol LocationService + ++ (BOOL)isLocationProhibited; ++ (void)checkLocationStatus; + +@end + NS_SWIFT_NAME(LocationManager) -@interface MWMLocationManager : NSObject +@interface MWMLocationManager : NSObject + (void)start; + (void)stop; @@ -14,10 +21,8 @@ NS_SWIFT_NAME(LocationManager) + (void)removeObserver:(id)observer NS_SWIFT_NAME(remove(observer:)); + (void)setMyPositionMode:(MWMMyPositionMode)mode; -+ (void)checkLocationStatus; + (nullable CLLocation *)lastLocation; -+ (BOOL)isLocationProhibited; + (nullable CLHeading *)lastHeading; + (void)applicationDidBecomeActive; diff --git a/iphone/Maps/Core/Theme/GlobalStyleSheet.swift b/iphone/Maps/Core/Theme/GlobalStyleSheet.swift index 60590d985b..e5bf69bc67 100644 --- a/iphone/Maps/Core/Theme/GlobalStyleSheet.swift +++ b/iphone/Maps/Core/Theme/GlobalStyleSheet.swift @@ -185,6 +185,7 @@ extension GlobalStyleSheet: IStyleSheet { case .trackRecordingWidgetButton: return .addFrom(Self.bottomTabBarButton) { s in s.cornerRadius = 23 + s.coloring = .red } case .blackOpaqueBackground: return .add { s in diff --git a/iphone/Maps/Core/TrackRecorder/TrackRecordingActivityManager.swift b/iphone/Maps/Core/TrackRecorder/TrackRecordingActivityManager.swift index 5df98663b4..8c0bc77439 100644 --- a/iphone/Maps/Core/TrackRecorder/TrackRecordingActivityManager.swift +++ b/iphone/Maps/Core/TrackRecorder/TrackRecordingActivityManager.swift @@ -55,12 +55,20 @@ private extension TrackRecordingLiveActivityAttributes.ContentState { let ascent = AltitudeFormatter.altitudeString(fromMeters: Double(trackInfo.ascent)) let descent = AltitudeFormatter.altitudeString(fromMeters: Double(trackInfo.descent)) - self.distance = StatisticsViewModel(key: "", value: distance) - self.duration = StatisticsViewModel(key: "", value: duration) - self.maxElevation = StatisticsViewModel(key: L("elevation_profile_max_elevation"), value: maxElevation) - self.minElevation = StatisticsViewModel(key: L("elevation_profile_min_elevation"), value: minElevation) - self.ascent = StatisticsViewModel(key: L("elevation_profile_ascent"), value: ascent) - self.descent = StatisticsViewModel(key: L("elevation_profile_descent"), value: descent) + self.distance = ValueViewModel(value: distance) + self.duration = ValueViewModel(value: duration) + self.ascent = DetailViewModel(.ascent, value: ascent) + self.descent = DetailViewModel(.descent, value: descent) + self.minElevation = DetailViewModel(.minElevation, value: minElevation) + self.maxElevation = DetailViewModel(.maxElevation, value: maxElevation) + } +} + +private extension TrackRecordingLiveActivityAttributes.ContentState.DetailViewModel { + init(_ elevationDescription: ElevationDescription, value: String) { + self.value = value + self.key = elevationDescription.title + self.imageName = elevationDescription.imageName } } diff --git a/iphone/Maps/Core/TrackRecorder/TrackRecordingManager.swift b/iphone/Maps/Core/TrackRecorder/TrackRecordingManager.swift index 5012b2ed65..0cef948908 100644 --- a/iphone/Maps/Core/TrackRecorder/TrackRecordingManager.swift +++ b/iphone/Maps/Core/TrackRecorder/TrackRecordingManager.swift @@ -4,31 +4,38 @@ enum TrackRecordingState: Int, Equatable { case active } -enum TrackRecordingAction: String, CaseIterable { +enum TrackRecordingAction { case start - case stop + case stopAndSave(name: String) } enum TrackRecordingError: Error { case locationIsProhibited + case trackIsEmpty + case systemError(Error) } -protocol TrackRecordingObserver: AnyObject { +enum TrackRecordingActionResult { + case success + case error(TrackRecordingError) +} + +@objc +protocol TrackRecordingObservable: AnyObject { + var recordingState: TrackRecordingState { get } + var trackRecordingInfo: TrackInfo { get } + func addObserver(_ observer: AnyObject, recordingIsActiveDidChangeHandler: @escaping TrackRecordingStateHandler) func removeObserver(_ observer: AnyObject) + func contains(_ observer: AnyObject) -> Bool } -typealias TrackRecordingStateHandler = (TrackRecordingState, TrackInfo?) -> Void +typealias TrackRecordingStateHandler = (TrackRecordingState, TrackInfo) -> Void @objcMembers final class TrackRecordingManager: NSObject { - typealias CompletionHandler = () -> Void - - private enum SavingOption { - case withoutSaving - case saveWithName(String? = nil) - } + typealias CompletionHandler = (TrackRecordingActionResult) -> Void fileprivate struct Observation { weak var observer: AnyObject? @@ -37,26 +44,33 @@ final class TrackRecordingManager: NSObject { static let shared: TrackRecordingManager = { let trackRecorder = FrameworkHelper.self + let locationManager = LocationManager.self var activityManager: TrackRecordingActivityManager? = nil #if canImport(ActivityKit) if #available(iOS 16.2, *) { activityManager = TrackRecordingLiveActivityManager.shared } #endif - return TrackRecordingManager(trackRecorder: trackRecorder, activityManager: activityManager) + return TrackRecordingManager(trackRecorder: trackRecorder, + locationService: locationManager, + activityManager: activityManager) }() private let trackRecorder: TrackRecorder.Type + private var locationService: LocationService.Type private var activityManager: TrackRecordingActivityManager? private var observations: [Observation] = [] - private var trackRecordingInfo: TrackInfo? + private(set) var trackRecordingInfo: TrackInfo = .empty() var recordingState: TrackRecordingState { trackRecorder.isTrackRecordingEnabled() ? .active : .inactive } - private init(trackRecorder: TrackRecorder.Type, activityManager: TrackRecordingActivityManager?) { + init(trackRecorder: TrackRecorder.Type, + locationService: LocationService.Type, + activityManager: TrackRecordingActivityManager?) { self.trackRecorder = trackRecorder + self.locationService = locationService self.activityManager = activityManager super.init() } @@ -84,22 +98,35 @@ final class TrackRecordingManager: NSObject { } func processAction(_ action: TrackRecordingAction, completion: (CompletionHandler)? = nil) { - switch action { - case .start: - start(completion: completion) - case .stop: - stop(completion: completion) + do { + switch action { + case .start: + try startRecording() + case .stopAndSave(let name): + stopRecording() + try checkIsTrackNotEmpty() + saveTrackRecording(name: name) + } + completion?(.success) + } catch { + handleError(error, completion: completion) } } // MARK: - Private methods - private func checkIsLocationEnabled() throws { - if LocationManager.isLocationProhibited() { + private func checkIsLocationEnabled() throws(TrackRecordingError) { + if locationService.isLocationProhibited() { throw TrackRecordingError.locationIsProhibited } } + private func checkIsTrackNotEmpty() throws(TrackRecordingError) { + if trackRecorder.isTrackRecordingEmpty() { + throw TrackRecordingError.trackIsEmpty + } + } + // MARK: - Handle track recording process private func subscribeOnTrackRecordingProgressUpdates() { @@ -113,92 +140,63 @@ final class TrackRecordingManager: NSObject { private func unsubscribeFromTrackRecordingProgressUpdates() { trackRecorder.setTrackRecordingUpdateHandler(nil) - trackRecordingInfo = nil } // MARK: - Handle Start/Stop event and Errors - private func start(completion: (CompletionHandler)? = nil) { - do { + private func startRecording() throws(TrackRecordingError) { + switch recordingState { + case .inactive: try checkIsLocationEnabled() - switch recordingState { - case .inactive: - subscribeOnTrackRecordingProgressUpdates() - trackRecorder.startTrackRecording() - notifyObservers() - try? activityManager?.start(with: trackRecordingInfo ?? .empty()) - case .active: - break + subscribeOnTrackRecordingProgressUpdates() + trackRecorder.startTrackRecording() + notifyObservers() + do { + try activityManager?.start(with: trackRecordingInfo) + } catch { + LOG(.warning, "Failed to start activity manager") + handleError(.systemError(error)) } - completion?() - } catch { - handleError(error, completion: completion) + case .active: + break } } - private func stop(completion: (CompletionHandler)? = nil) { - guard !trackRecorder.isTrackRecordingEmpty() else { - Toast.toast(withText: L("track_recording_toast_nothing_to_save")).show() - stopRecording(.withoutSaving, completion: completion) - return - } - Self.showOnFinishRecordingAlert(onSave: { [weak self] in - guard let self else { return } - // TODO: (KK) pass the user provided name from the track saving screen (when it will be implemented) - self.stopRecording(.saveWithName(), completion: completion) - }, - onStop: { [weak self] in - guard let self else { return } - self.stopRecording(.withoutSaving, completion: completion) - }, - onContinue: { - completion?() - }) - } - - private func stopRecording(_ savingOption: SavingOption, completion: (CompletionHandler)? = nil) { + private func stopRecording() { unsubscribeFromTrackRecordingProgressUpdates() trackRecorder.stopTrackRecording() + trackRecordingInfo = .empty() activityManager?.stop() notifyObservers() - - switch savingOption { - case .withoutSaving: - break - case .saveWithName(let name): - trackRecorder.saveTrackRecording(withName: name) - } - completion?() } - private func handleError(_ error: Error, completion: (CompletionHandler)? = nil) { - LOG(.error, error.localizedDescription) + private func saveTrackRecording(name: String) { + trackRecorder.saveTrackRecording(withName: name) + } + + private func handleError(_ error: TrackRecordingError, completion: (CompletionHandler)? = nil) { switch error { case TrackRecordingError.locationIsProhibited: // Show alert to enable location - LocationManager.checkLocationStatus() - default: + locationService.checkLocationStatus() + case TrackRecordingError.trackIsEmpty: + Toast.toast(withText: L("track_recording_toast_nothing_to_save")).show() + case TrackRecordingError.systemError(let error): + LOG(.error, error.localizedDescription) break } - stopRecording(.withoutSaving, completion: completion) - } - - private static func showOnFinishRecordingAlert(onSave: @escaping CompletionHandler, - onStop: @escaping CompletionHandler, - onContinue: @escaping CompletionHandler) { - let alert = UIAlertController(title: L("track_recording_alert_title"), message: nil, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: L("continue_recording"), style: .default, handler: { _ in onContinue() })) - alert.addAction(UIAlertAction(title: L("stop_without_saving"), style: .default, handler: { _ in onStop() })) - alert.addAction(UIAlertAction(title: L("save"), style: .cancel, handler: { _ in onSave() })) - UIViewController.topViewController().present(alert, animated: true) + DispatchQueue.main.async { + completion?(.error(error)) + } } } // MARK: - TrackRecordingObserver -extension TrackRecordingManager: TrackRecordingObserver { +extension TrackRecordingManager: TrackRecordingObservable { @objc func addObserver(_ observer: AnyObject, recordingIsActiveDidChangeHandler: @escaping TrackRecordingStateHandler) { + guard !observations.contains(where: { $0.observer === observer }) else { return } let observation = Observation(observer: observer, recordingStateDidChangeHandler: recordingIsActiveDidChangeHandler) observations.append(observation) recordingIsActiveDidChangeHandler(recordingState, trackRecordingInfo) @@ -209,6 +207,11 @@ extension TrackRecordingManager: TrackRecordingObserver { observations.removeAll { $0.observer === observer } } + @objc + func contains(_ observer: AnyObject) -> Bool { + observations.contains { $0.observer === observer } + } + private func notifyObservers() { observations = observations.filter { $0.observer != nil } observations.forEach { $0.recordingStateDidChangeHandler?(recordingState, trackRecordingInfo) } diff --git a/iphone/Maps/Images.xcassets/Place Page/ic_placepage_save_track_recording.imageset/Contents.json b/iphone/Maps/Images.xcassets/Place Page/ic_placepage_save_track_recording.imageset/Contents.json new file mode 100644 index 0000000000..4c2879a332 --- /dev/null +++ b/iphone/Maps/Images.xcassets/Place Page/ic_placepage_save_track_recording.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ic_track_save.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/Images.xcassets/Place Page/ic_placepage_save_track_recording.imageset/ic_track_save.png b/iphone/Maps/Images.xcassets/Place Page/ic_placepage_save_track_recording.imageset/ic_track_save.png new file mode 100644 index 0000000000..d0bee6031e Binary files /dev/null and b/iphone/Maps/Images.xcassets/Place Page/ic_placepage_save_track_recording.imageset/ic_track_save.png differ diff --git a/iphone/Maps/Maps.xcodeproj/project.pbxproj b/iphone/Maps/Maps.xcodeproj/project.pbxproj index 1023b1e8e8..ca01ebe9a2 100644 --- a/iphone/Maps/Maps.xcodeproj/project.pbxproj +++ b/iphone/Maps/Maps.xcodeproj/project.pbxproj @@ -481,7 +481,7 @@ ED2D74652D14357F00660FBF /* TrackRecordingLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED2D74302D14337500660FBF /* TrackRecordingLiveActivityAttributes.swift */; }; ED2D74662D1435A600660FBF /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED2D742D2D14337500660FBF /* LiveActivityManager.swift */; }; ED2E328E2D10500900807A08 /* TrackRecordingButtonArea.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED46DD922D06F804007CACD6 /* TrackRecordingButtonArea.swift */; }; - ED2E32912D10501700807A08 /* TrackRecordingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED49D76F2CF0E3A8004AF27E /* TrackRecordingViewController.swift */; }; + ED2E32912D10501700807A08 /* TrackRecordingButtonViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED49D76F2CF0E3A8004AF27E /* TrackRecordingButtonViewController.swift */; }; ED3EAC202B03C88100220A4A /* BottomTabBarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED3EAC1F2B03C88100220A4A /* BottomTabBarButton.swift */; }; ED43B8BD2C12063500D07BAA /* DocumentPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED43B8BC2C12063500D07BAA /* DocumentPicker.swift */; }; ED46DDCE2D098A0B007CACD6 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = ED46DDCD2D098A0B007CACD6 /* WidgetKit.framework */; }; @@ -519,11 +519,14 @@ ED810EC52D566E9B00ECDE2C /* SearchOnMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED810EC42D566E9B00ECDE2C /* SearchOnMapTests.swift */; }; ED8270F02C2071A3005966DA /* SettingsTableViewDetailedSwitchCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED8270EF2C2071A3005966DA /* SettingsTableViewDetailedSwitchCell.swift */; }; ED83880F2D54DEB3002A0536 /* UIImage+FilledWithColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED83880E2D54DEA4002A0536 /* UIImage+FilledWithColor.swift */; }; + ED8A921C2D772904009E063B /* ElevationDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED8A921B2D772904009E063B /* ElevationDescription.swift */; }; ED914AB22D35063A00973C45 /* TextColorStyleSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED914AB12D35063A00973C45 /* TextColorStyleSheet.swift */; }; ED914AB82D351DF000973C45 /* StyleApplicable.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED914AB72D351DF000973C45 /* StyleApplicable.swift */; }; ED914ABE2D351FF800973C45 /* UILabel+SetFontStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED914ABD2D351FF800973C45 /* UILabel+SetFontStyle.swift */; }; ED9857082C4ED02D00694F6C /* MailComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED9857072C4ED02D00694F6C /* MailComposer.swift */; }; ED9966802B94FBC20083CE55 /* ColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED99667D2B94FBC20083CE55 /* ColorPicker.swift */; }; + ED9DDF882D6F151000645BC8 /* PlacePageTrackRecordingLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED9DDF872D6F151000645BC8 /* PlacePageTrackRecordingLayout.swift */; }; + ED9DDF9D2D6F6F7900645BC8 /* TrackRecordingManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED9DDF9B2D6F6DA300645BC8 /* TrackRecordingManagerTests.swift */; }; EDA1EAA42CC7ECAD00DBDCAA /* ElevationProfileFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA1EAA32CC7ECAD00DBDCAA /* ElevationProfileFormatter.swift */; }; EDBD68072B625724005DD151 /* LocationServicesDisabledAlert.xib in Resources */ = {isa = PBXBuildFile; fileRef = EDBD68062B625724005DD151 /* LocationServicesDisabledAlert.xib */; }; EDBD680B2B62572E005DD151 /* LocationServicesDisabledAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDBD680A2B62572E005DD151 /* LocationServicesDisabledAlert.swift */; }; @@ -1449,7 +1452,7 @@ ED46DDCF2D098A0B007CACD6 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; ED48BBB817C2B1E2003E7E92 /* CircleView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CircleView.h; sourceTree = ""; }; ED48BBB917C2B1E2003E7E92 /* CircleView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CircleView.m; sourceTree = ""; }; - ED49D76F2CF0E3A8004AF27E /* TrackRecordingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackRecordingViewController.swift; sourceTree = ""; }; + ED49D76F2CF0E3A8004AF27E /* TrackRecordingButtonViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackRecordingButtonViewController.swift; sourceTree = ""; }; ED4DC7732CAEDECC0029B338 /* ProductButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductButton.swift; sourceTree = ""; }; ED4DC7742CAEDECC0029B338 /* ProductsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsViewController.swift; sourceTree = ""; }; ED4DC7752CAEDECC0029B338 /* ProductsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsViewModel.swift; sourceTree = ""; }; @@ -1485,11 +1488,14 @@ ED810EC42D566E9B00ECDE2C /* SearchOnMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOnMapTests.swift; sourceTree = ""; }; ED8270EF2C2071A3005966DA /* SettingsTableViewDetailedSwitchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTableViewDetailedSwitchCell.swift; sourceTree = ""; }; ED83880E2D54DEA4002A0536 /* UIImage+FilledWithColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+FilledWithColor.swift"; sourceTree = ""; }; + ED8A921B2D772904009E063B /* ElevationDescription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElevationDescription.swift; sourceTree = ""; }; ED914AB12D35063A00973C45 /* TextColorStyleSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextColorStyleSheet.swift; sourceTree = ""; }; ED914AB72D351DF000973C45 /* StyleApplicable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StyleApplicable.swift; sourceTree = ""; }; ED914ABD2D351FF800973C45 /* UILabel+SetFontStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+SetFontStyle.swift"; sourceTree = ""; }; ED9857072C4ED02D00694F6C /* MailComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailComposer.swift; sourceTree = ""; }; ED99667D2B94FBC20083CE55 /* ColorPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorPicker.swift; sourceTree = ""; }; + ED9DDF872D6F151000645BC8 /* PlacePageTrackRecordingLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlacePageTrackRecordingLayout.swift; sourceTree = ""; }; + ED9DDF9B2D6F6DA300645BC8 /* TrackRecordingManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackRecordingManagerTests.swift; sourceTree = ""; }; EDA1EAA32CC7ECAD00DBDCAA /* ElevationProfileFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElevationProfileFormatter.swift; sourceTree = ""; }; EDBD68062B625724005DD151 /* LocationServicesDisabledAlert.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LocationServicesDisabledAlert.xib; sourceTree = ""; }; EDBD680A2B62572E005DD151 /* LocationServicesDisabledAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationServicesDisabledAlert.swift; sourceTree = ""; }; @@ -2569,7 +2575,7 @@ 34BC72091B0DECAE0012A34B /* MapViewControls */ = { isa = PBXGroup; children = ( - ED49D76F2CF0E3A8004AF27E /* TrackRecordingViewController.swift */, + ED49D76F2CF0E3A8004AF27E /* TrackRecordingButtonViewController.swift */, 340537621BBED98600D452C6 /* MWMMapViewControlsCommon.h */, 34BC72101B0DECAE0012A34B /* MWMMapViewControlsManager.h */, 34BC72111B0DECAE0012A34B /* MWMMapViewControlsManager.mm */, @@ -2765,6 +2771,7 @@ 4B4153B62BF9709100EE4B02 /* Core */ = { isa = PBXGroup; children = ( + ED9DDF982D6F6D8000645BC8 /* TrackRecorder */, EDF838AB2C00B9C7007E4E67 /* iCloudTests */, 4B4153B72BF970A000EE4B02 /* TextToSpeech */, ); @@ -3031,6 +3038,7 @@ 99C6532123F2F506004322F3 /* IPlacePageLayout.swift */, 99F3EB0223F4178200C713F8 /* PlacePageCommonLayout.swift */, 993DF0B423F6B2EF00AC231A /* PlacePageTrackLayout.swift */, + ED9DDF872D6F151000645BC8 /* PlacePageTrackRecordingLayout.swift */, ); path = Layouts; sourceTree = ""; @@ -3050,6 +3058,7 @@ 99DEF9D523E420D2006BFD21 /* ElevationProfile */ = { isa = PBXGroup; children = ( + ED8A921B2D772904009E063B /* ElevationDescription.swift */, 99514BB223E82B450085D3A7 /* ElevationProfilePresenter.swift */, EDA1EAA32CC7ECAD00DBDCAA /* ElevationProfileFormatter.swift */, 99514BB423E82B450085D3A7 /* ElevationProfileViewController.swift */, @@ -3312,6 +3321,14 @@ path = ColorPicker; sourceTree = ""; }; + ED9DDF982D6F6D8000645BC8 /* TrackRecorder */ = { + isa = PBXGroup; + children = ( + ED9DDF9B2D6F6DA300645BC8 /* TrackRecordingManagerTests.swift */, + ); + path = TrackRecorder; + sourceTree = ""; + }; EDC4E3422C5D1BD3009286A2 /* RecentlyDeletedTests */ = { isa = PBXGroup; children = ( @@ -4488,8 +4505,9 @@ 99F3EB1223F418C900C713F8 /* PlacePageInteractor.swift in Sources */, 340708651F2905A500029ECC /* NavigationInfoArea.swift in Sources */, 993DF0CC23F6BD0600AC231A /* ElevationDetailsPresenter.swift in Sources */, + ED8A921C2D772904009E063B /* ElevationDescription.swift in Sources */, 34AB666B1FC5AA330078E451 /* TransportTransitCell.swift in Sources */, - ED2E32912D10501700807A08 /* TrackRecordingViewController.swift in Sources */, + ED2E32912D10501700807A08 /* TrackRecordingButtonViewController.swift in Sources */, 47E8163323B17734008FD836 /* MWMStorage+UI.m in Sources */, 993DF11123F6BDB100AC231A /* UILabelRenderer.swift in Sources */, 34AB66471FC5AA330078E451 /* RouteManagerTableView.swift in Sources */, @@ -4630,6 +4648,7 @@ 1DFA2F6A20D3B57400FB2C66 /* UIColor+PartnerColor.m in Sources */, 9989273B2449E60200260CE2 /* BottomMenuBuilder.swift in Sources */, 993DF10F23F6BDB100AC231A /* UIActivityIndicatorRenderer.swift in Sources */, + ED9DDF882D6F151000645BC8 /* PlacePageTrackRecordingLayout.swift in Sources */, ED0B1FEF2CAA9A25006E31A4 /* UIView+Highlight.swift in Sources */, 99A614E423CDD1D900D8D8D0 /* UIButton+RuntimeAttributes.m in Sources */, 343E75981E5B1EE20041226A /* MWMCollectionViewController.m in Sources */, @@ -4907,6 +4926,7 @@ EDF838C32C00B9D6007E4E67 /* UbiquitousDirectoryMonitorDelegateMock.swift in Sources */, EDF838BE2C00B9D0007E4E67 /* LocalDirectoryMonitorDelegateMock.swift in Sources */, EDC4E3692C5E6F5B009286A2 /* MockRecentlyDeletedCategoriesManager.swift in Sources */, + ED9DDF9D2D6F6F7900645BC8 /* TrackRecordingManagerTests.swift in Sources */, EDF838BF2C00B9D0007E4E67 /* SynchronizationStateManagerTests.swift in Sources */, ED810EC52D566E9B00ECDE2C /* SearchOnMapTests.swift in Sources */, 4B83AE4B2C2E642100B0C3BC /* TTSTesterTest.m in Sources */, diff --git a/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/Contents.json b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_ascent_24.imageset/Ascent.png b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_ascent_24.imageset/Ascent.png new file mode 100644 index 0000000000..9609ee75c3 Binary files /dev/null and b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_ascent_24.imageset/Ascent.png differ diff --git a/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_ascent_24.imageset/Ascent@2x.png b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_ascent_24.imageset/Ascent@2x.png new file mode 100644 index 0000000000..dd0713561c Binary files /dev/null and b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_ascent_24.imageset/Ascent@2x.png differ diff --git a/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_ascent_24.imageset/Ascent@3x.png b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_ascent_24.imageset/Ascent@3x.png new file mode 100644 index 0000000000..facfa3a313 Binary files /dev/null and b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_ascent_24.imageset/Ascent@3x.png differ diff --git a/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_ascent_24.imageset/Contents.json b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_ascent_24.imageset/Contents.json new file mode 100644 index 0000000000..7d1b47c4be --- /dev/null +++ b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_ascent_24.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "Ascent.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Ascent@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Ascent@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_descent_24.imageset/Contents.json b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_descent_24.imageset/Contents.json new file mode 100644 index 0000000000..f730e3973a --- /dev/null +++ b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_descent_24.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "Descent.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Descent@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Descent@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_descent_24.imageset/Descent.png b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_descent_24.imageset/Descent.png new file mode 100644 index 0000000000..339cf081ec Binary files /dev/null and b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_descent_24.imageset/Descent.png differ diff --git a/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_descent_24.imageset/Descent@2x.png b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_descent_24.imageset/Descent@2x.png new file mode 100644 index 0000000000..317f0619c3 Binary files /dev/null and b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_descent_24.imageset/Descent@2x.png differ diff --git a/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_descent_24.imageset/Descent@3x.png b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_descent_24.imageset/Descent@3x.png new file mode 100644 index 0000000000..9535fee5d6 Binary files /dev/null and b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_descent_24.imageset/Descent@3x.png differ diff --git a/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_max_attitude_24.imageset/Contents.json b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_max_attitude_24.imageset/Contents.json new file mode 100644 index 0000000000..a44c9fc25d --- /dev/null +++ b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_max_attitude_24.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "maxAltitude.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "maxAltitude@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "maxAltitude@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_max_attitude_24.imageset/maxAltitude.png b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_max_attitude_24.imageset/maxAltitude.png new file mode 100644 index 0000000000..d03e455440 Binary files /dev/null and b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_max_attitude_24.imageset/maxAltitude.png differ diff --git a/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_max_attitude_24.imageset/maxAltitude@2x.png b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_max_attitude_24.imageset/maxAltitude@2x.png new file mode 100644 index 0000000000..15bffb8645 Binary files /dev/null and b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_max_attitude_24.imageset/maxAltitude@2x.png differ diff --git a/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_max_attitude_24.imageset/maxAltitude@3x.png b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_max_attitude_24.imageset/maxAltitude@3x.png new file mode 100644 index 0000000000..637945804a Binary files /dev/null and b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_max_attitude_24.imageset/maxAltitude@3x.png differ diff --git a/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_min_attitude_24.imageset/Contents.json b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_min_attitude_24.imageset/Contents.json new file mode 100644 index 0000000000..c0a99ccecb --- /dev/null +++ b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_min_attitude_24.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "minAltitude.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "minAltitude@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "minAltitude@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_min_attitude_24.imageset/minAltitude.png b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_min_attitude_24.imageset/minAltitude.png new file mode 100644 index 0000000000..5de80a7c9e Binary files /dev/null and b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_min_attitude_24.imageset/minAltitude.png differ diff --git a/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_min_attitude_24.imageset/minAltitude@2x.png b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_min_attitude_24.imageset/minAltitude@2x.png new file mode 100644 index 0000000000..a3c35f9024 Binary files /dev/null and b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_min_attitude_24.imageset/minAltitude@2x.png differ diff --git a/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_min_attitude_24.imageset/minAltitude@3x.png b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_min_attitude_24.imageset/minAltitude@3x.png new file mode 100644 index 0000000000..153f73cffc Binary files /dev/null and b/iphone/Maps/OMapsWidgetExtension/Assets.xcassets/ElevationProfile/ic_em_min_attitude_24.imageset/minAltitude@3x.png differ diff --git a/iphone/Maps/OMapsWidgetExtension/LiveActivity/StatisticDetailView.swift b/iphone/Maps/OMapsWidgetExtension/LiveActivity/StatisticDetailView.swift index 65731c6e00..4296ff9c66 100644 --- a/iphone/Maps/OMapsWidgetExtension/LiveActivity/StatisticDetailView.swift +++ b/iphone/Maps/OMapsWidgetExtension/LiveActivity/StatisticDetailView.swift @@ -1,23 +1,24 @@ import SwiftUI struct StatisticDetailView: View { - private let title: String - private let subtitle: String - - init(title: String, subtitle: String) { - self.title = title - self.subtitle = subtitle - } + let viewModel: TrackRecordingLiveActivityAttributes.ContentState.DetailViewModel var body: some View { VStack(alignment: .leading) { - Text(title) - .contentTransition(.numericText()) - .font(.system(size: 14, weight: .bold).monospacedDigit()) - .minimumScaleFactor(0.5) - .lineLimit(1) - .foregroundStyle(.white) - Text(subtitle) + HStack { + Image(uiImage: UIImage(named: viewModel.imageName)!) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + .foregroundStyle(.white) + Text(viewModel.value) + .contentTransition(.numericText()) + .font(.system(size: 14, weight: .bold).monospacedDigit()) + .minimumScaleFactor(0.5) + .lineLimit(1) + .foregroundStyle(.white) + } + Text(viewModel.key) .font(.system(size: 10)) .minimumScaleFactor(0.5) .lineLimit(2) diff --git a/iphone/Maps/OMapsWidgetExtension/LiveActivity/StatisticValueView.swift b/iphone/Maps/OMapsWidgetExtension/LiveActivity/StatisticValueView.swift index 17ce6c229c..ff8ff0f5a9 100644 --- a/iphone/Maps/OMapsWidgetExtension/LiveActivity/StatisticValueView.swift +++ b/iphone/Maps/OMapsWidgetExtension/LiveActivity/StatisticValueView.swift @@ -1,14 +1,10 @@ import SwiftUI struct StatisticValueView: View { - private let value: String - - init(_ value: String) { - self.value = value - } + let viewModel: TrackRecordingLiveActivityAttributes.ContentState.ValueViewModel var body: some View { - Text(value) + Text(viewModel.value) .contentTransition(.numericText()) .minimumScaleFactor(0.1) .font(.title3.bold().monospacedDigit()) diff --git a/iphone/Maps/OMapsWidgetExtension/LiveActivity/TrackRecordingLiveActivityAttributes.swift b/iphone/Maps/OMapsWidgetExtension/LiveActivity/TrackRecordingLiveActivityAttributes.swift index 107365a653..9cb208ece9 100644 --- a/iphone/Maps/OMapsWidgetExtension/LiveActivity/TrackRecordingLiveActivityAttributes.swift +++ b/iphone/Maps/OMapsWidgetExtension/LiveActivity/TrackRecordingLiveActivityAttributes.swift @@ -3,15 +3,21 @@ import AppIntents struct TrackRecordingLiveActivityAttributes: ActivityAttributes { struct ContentState: Codable, Hashable { - struct StatisticsViewModel: Codable, Hashable { - let key: String + struct ValueViewModel: Codable, Hashable { let value: String } - let duration: StatisticsViewModel - let distance: StatisticsViewModel - let ascent: StatisticsViewModel - let descent: StatisticsViewModel - let maxElevation: StatisticsViewModel - let minElevation: StatisticsViewModel + + struct DetailViewModel: Codable, Hashable { + let key: String + let value: String + let imageName: String + } + + let duration: ValueViewModel + let distance: ValueViewModel + let ascent: DetailViewModel + let descent: DetailViewModel + let maxElevation: DetailViewModel + let minElevation: DetailViewModel } } diff --git a/iphone/Maps/OMapsWidgetExtension/LiveActivity/TrackRecordingLiveActivityConfiguration.swift b/iphone/Maps/OMapsWidgetExtension/LiveActivity/TrackRecordingLiveActivityConfiguration.swift index 54fda890e1..5fd8ed036e 100644 --- a/iphone/Maps/OMapsWidgetExtension/LiveActivity/TrackRecordingLiveActivityConfiguration.swift +++ b/iphone/Maps/OMapsWidgetExtension/LiveActivity/TrackRecordingLiveActivityConfiguration.swift @@ -15,7 +15,7 @@ struct TrackRecordingLiveActivityConfiguration: Widget { } compactLeading: { AppLogo() } compactTrailing: { - StatisticValueView(context.state.duration.value) + StatisticValueView(viewModel: context.state.duration) } minimal: { // TODO: Implement the minimal view } diff --git a/iphone/Maps/OMapsWidgetExtension/LiveActivity/TrackRecordingLiveActivityView.swift b/iphone/Maps/OMapsWidgetExtension/LiveActivity/TrackRecordingLiveActivityView.swift index 3050d5e90a..28994dcdf3 100644 --- a/iphone/Maps/OMapsWidgetExtension/LiveActivity/TrackRecordingLiveActivityView.swift +++ b/iphone/Maps/OMapsWidgetExtension/LiveActivity/TrackRecordingLiveActivityView.swift @@ -9,18 +9,18 @@ struct TrackRecordingLiveActivityView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top, spacing: 24) { - StatisticValueView(state.duration.value) - StatisticValueView(state.distance.value) + StatisticValueView(viewModel: state.duration) + StatisticValueView(viewModel: state.distance) Spacer() AppLogo() .frame(width: 20, height: 20) } .padding([.top, .leading, .trailing], 16) HStack(alignment: .top, spacing: 20) { - StatisticDetailView(title: state.ascent.value, subtitle: state.ascent.key) - StatisticDetailView(title: state.descent.value, subtitle: state.descent.key) - StatisticDetailView(title: state.maxElevation.value, subtitle: state.maxElevation.key) - StatisticDetailView(title: state.minElevation.value, subtitle: state.minElevation.key) + StatisticDetailView(viewModel: state.ascent) + StatisticDetailView(viewModel: state.descent) + StatisticDetailView(viewModel: state.maxElevation) + StatisticDetailView(viewModel: state.minElevation) } .padding([.leading, .trailing, .bottom], 16) } @@ -37,28 +37,30 @@ struct TrackRecordingLiveActivityWidget_Previews: PreviewProvider { let activityAttributes = TrackRecordingLiveActivityAttributes() let activityState = TrackRecordingLiveActivityAttributes.ContentState( duration: .init( - key: "Duration", - value: "1h 12min" + value: "10d 10h 12min" ), distance: .init( - key: "Distance", - value: "12 km" + value: "1245 km" ), ascent: .init( key: "Ascent", - value: "100 m" + value: "100 m", + imageName: "ic_em_ascent_24" ), descent: .init( key: "Descent", - value: "100 m" + value: "100 m", + imageName: "ic_em_descent_24" ), maxElevation: .init( key: "Max Elevation", - value: "100 m" + value: "9999 m", + imageName: "ic_em_max_attitude_24" ), minElevation: .init( key: "Min Elevation", - value: "10 m" + value: "1000 m", + imageName: "ic_em_min_attitude_24" ) ) diff --git a/iphone/Maps/Tests/Core/TrackRecorder/TrackRecordingManagerTests.swift b/iphone/Maps/Tests/Core/TrackRecorder/TrackRecordingManagerTests.swift new file mode 100644 index 0000000000..ecb44380d2 --- /dev/null +++ b/iphone/Maps/Tests/Core/TrackRecorder/TrackRecordingManagerTests.swift @@ -0,0 +1,198 @@ +import XCTest +@testable import Organic_Maps__Debug_ + +final class TrackRecordingManagerTests: XCTestCase { + + private var trackRecordingManager: TrackRecordingManager! + + private var mockTrackRecorder: MockTrackRecorder.Type! + private var mockLocationService: MockLocationService.Type! + private var mockActivityManager: MockTrackRecordingActivityManager! + + override func setUp() { + super.setUp() + mockTrackRecorder = MockTrackRecorder.self + mockLocationService = MockLocationService.self + mockActivityManager = MockTrackRecordingActivityManager() + + trackRecordingManager = TrackRecordingManager( + trackRecorder: mockTrackRecorder, + locationService: mockLocationService, + activityManager: mockActivityManager + ) + } + + override func tearDown() { + trackRecordingManager = nil + mockTrackRecorder.reset() + mockLocationService.reset() + mockActivityManager = nil + super.tearDown() + } + + func test_GivenInitialSetup_WhenLocationEnabled_ThenStateIsInactive() { + mockLocationService.locationIsProhibited = false + mockTrackRecorder.trackRecordingIsEnabled = false + + trackRecordingManager.setup() + XCTAssertTrue(trackRecordingManager.recordingState == .inactive) + } + + func test_GivenInitialSetup_WhenLocationDisabled_ThenShouldHandleErrorAndIncativeState() { + mockLocationService.locationIsProhibited = true + + trackRecordingManager.setup() + + XCTAssertTrue(mockLocationService.checkLocationStatusCalled) + XCTAssertTrue(trackRecordingManager.recordingState == .inactive) + } + + func test_GivenStartRecording_WhenLocationEnabled_ThenSuccess() { + mockLocationService.locationIsProhibited = false + mockTrackRecorder.trackRecordingIsEnabled = false + + trackRecordingManager.processAction(.start) + + XCTAssertTrue(mockTrackRecorder.startTrackRecordingCalled) + XCTAssertTrue(mockActivityManager.startCalled) + XCTAssertTrue(trackRecordingManager.recordingState == .active) + } + + func test_GivenStartRecording_WhenLocationDisabled_ThenShouldFail() { + mockLocationService.locationIsProhibited = true + + trackRecordingManager.processAction(.start) { result in + switch result { + case .success: + XCTFail("Should not succeed") + case .error(let error): + switch error { + case .locationIsProhibited: + XCTAssertTrue(true) + default: + XCTFail("Unexpected error: \(error)") + } + } + } + XCTAssertFalse(self.mockTrackRecorder.startTrackRecordingCalled) + XCTAssertTrue(trackRecordingManager.recordingState == .inactive) + } + + func test_GivenStopRecording_WhenLocationEnabled_ThenSuccess() { + mockTrackRecorder.trackRecordingIsEnabled = true + mockTrackRecorder.trackRecordingIsEmpty = false + + trackRecordingManager.processAction(.stopAndSave(name: "Test Track")) { result in + switch result { + case .success: + XCTAssertTrue(true) + case .error(let error): + XCTFail("Unexpected error: \(error)") + } + } + XCTAssertTrue(mockTrackRecorder.stopTrackRecordingCalled) + XCTAssertTrue(mockTrackRecorder.saveTrackRecordingCalled) + XCTAssertTrue(mockActivityManager.stopCalled) + XCTAssertTrue(trackRecordingManager.recordingState == .inactive) + } + + func test_GivenStopRecording_WhenTrackIsEmpty_ThenShouldFail() { + mockTrackRecorder.trackRecordingIsEnabled = true + mockTrackRecorder.trackRecordingIsEmpty = true + + trackRecordingManager.processAction(.stopAndSave(name: "Test Track")) { result in + switch result { + case .success: + XCTFail("Should not succeed") + case .error(let error): + switch error { + case .trackIsEmpty: + XCTAssertTrue(true) + default: + XCTFail("Unexpected error: \(error)") + } + } + } + XCTAssertFalse(mockTrackRecorder.saveTrackRecordingCalled) + XCTAssertTrue(trackRecordingManager.recordingState == .inactive) + } +} + +// MARK: - Mock Classes + +private final class MockTrackRecorder: TrackRecorder { + static var trackRecordingIsEnabled = false + static var trackRecordingIsEmpty = false + static var startTrackRecordingCalled = false + static var stopTrackRecordingCalled = false + static var saveTrackRecordingCalled = false + + static func reset() { + trackRecordingIsEnabled = false + trackRecordingIsEmpty = false + startTrackRecordingCalled = false + stopTrackRecordingCalled = false + saveTrackRecordingCalled = false + } + + static func isTrackRecordingEnabled() -> Bool { + return trackRecordingIsEnabled + } + + static func isTrackRecordingEmpty() -> Bool { + return trackRecordingIsEmpty + } + + static func startTrackRecording() { + startTrackRecordingCalled = true + trackRecordingIsEnabled = true + } + + static func stopTrackRecording() { + stopTrackRecordingCalled = true + trackRecordingIsEnabled = false + } + + static func saveTrackRecording(withName name: String) { + saveTrackRecordingCalled = true + } + + static func setTrackRecordingUpdateHandler(_ handler: ((TrackInfo) -> Void)?) {} + + static func trackRecordingElevationInfo() -> ElevationProfileData { + ElevationProfileData() + } +} + +private final class MockLocationService: LocationService { + static var locationIsProhibited = false + static var checkLocationStatusCalled = false + + static func reset() { + locationIsProhibited = false + checkLocationStatusCalled = false + } + + static func isLocationProhibited() -> Bool { + return locationIsProhibited + } + + static func checkLocationStatus() { + checkLocationStatusCalled = true + } +} + +final class MockTrackRecordingActivityManager: TrackRecordingActivityManager { + var startCalled = false + var stopCalled = false + + func start(with info: TrackInfo) throws { + startCalled = true + } + + func stop() { + stopCalled = true + } + + func update(_ info: TrackInfo) {} +} diff --git a/iphone/Maps/UI/AvailableArea/TrackRecordingButtonArea.swift b/iphone/Maps/UI/AvailableArea/TrackRecordingButtonArea.swift index 5272dd4931..9f1ca5e4a2 100644 --- a/iphone/Maps/UI/AvailableArea/TrackRecordingButtonArea.swift +++ b/iphone/Maps/UI/AvailableArea/TrackRecordingButtonArea.swift @@ -10,7 +10,7 @@ final class TrackRecordingButtonArea: AvailableArea { } override func notifyObserver() { - TrackRecordingViewController.updateAvailableArea(areaFrame) + TrackRecordingButtonViewController.updateAvailableArea(areaFrame) } } diff --git a/iphone/Maps/UI/BottomMenu/Menu/BottomMenuInteractor.swift b/iphone/Maps/UI/BottomMenu/Menu/BottomMenuInteractor.swift index 8457445c3c..d761d01e18 100644 --- a/iphone/Maps/UI/BottomMenu/Menu/BottomMenuInteractor.swift +++ b/iphone/Maps/UI/BottomMenu/Menu/BottomMenuInteractor.swift @@ -79,8 +79,13 @@ extension BottomMenuInteractor: BottomMenuInteractorProtocol { } func toggleTrackRecording() { - trackRecorder.processAction(trackRecorder.recordingState == .active ? .stop : .start) { [weak self] in - self?.close() + switch trackRecorder.recordingState { + case .active: + break + case .inactive: + trackRecorder.processAction(.start) } + close() + MapViewController.shared()?.showTrackRecordingPlacePage() } } diff --git a/iphone/Maps/UI/PlacePage/Components/ActionBarViewController.swift b/iphone/Maps/UI/PlacePage/Components/ActionBarViewController.swift index 1954964d90..f88c528079 100644 --- a/iphone/Maps/UI/PlacePage/Components/ActionBarViewController.swift +++ b/iphone/Maps/UI/PlacePage/Components/ActionBarViewController.swift @@ -62,18 +62,26 @@ final class ActionBarViewController: UIViewController { fatalError() } } + var buttons: [ActionBarButtonType] = [] - if isRoutePlanning { - buttons.append(.routeFrom) - } - if placePageData.infoData?.phone != nil, AppInfo.shared().canMakeCalls { - buttons.append(.call) - } - if !isRoutePlanning { - buttons.append(.routeFrom) + switch placePageData.objectType { + case .POI, .bookmark, .track: + if isRoutePlanning { + buttons.append(.routeFrom) + } + if placePageData.infoData?.phone != nil, AppInfo.shared().canMakeCalls { + buttons.append(.call) + } + if !isRoutePlanning { + buttons.append(.routeFrom) + } + case .trackRecording: + break + @unknown default: + fatalError() } - assert(buttons.count > 0) + guard !buttons.isEmpty else { return } visibleButtons.append(buttons[0]) if buttons.count > 1 { additionalButtons.append(contentsOf: buttons.suffix(from: 1)) @@ -91,8 +99,7 @@ final class ActionBarViewController: UIViewController { case .track: buttons.append(.track) case .trackRecording: - // TODO: implement for track recording - break + buttons.append(.saveTrackRecording) @unknown default: fatalError() } @@ -104,7 +111,14 @@ final class ActionBarViewController: UIViewController { } private func configButton3() { - visibleButtons.append(.routeTo) + switch placePageData.objectType { + case .POI, .bookmark, .track: + visibleButtons.append(.routeTo) + case .trackRecording: + break + @unknown default: + fatalError() + } } private func configButton4() { diff --git a/iphone/Maps/UI/PlacePage/Components/ElevationProfile/ElevationDescription.swift b/iphone/Maps/UI/PlacePage/Components/ElevationProfile/ElevationDescription.swift new file mode 100644 index 0000000000..83877dc23d --- /dev/null +++ b/iphone/Maps/UI/PlacePage/Components/ElevationProfile/ElevationDescription.swift @@ -0,0 +1,26 @@ +enum ElevationDescription { + case ascent + case descent + case maxElevation + case minElevation +} + +extension ElevationDescription { + var title: String { + switch self { + case .ascent: return L("elevation_profile_ascent") + case .descent: return L("elevation_profile_descent") + case .maxElevation: return L("elevation_profile_max_elevation") + case .minElevation: return L("elevation_profile_min_elevation") + } + } + + var imageName: String { + switch self { + case .ascent: return "ic_em_ascent_24" + case .descent: return "ic_em_descent_24" + case .maxElevation: return "ic_em_max_attitude_24" + case .minElevation: return "ic_em_min_attitude_24" + } + } +} diff --git a/iphone/Maps/UI/PlacePage/Components/ElevationProfile/ElevationProfileFormatter.swift b/iphone/Maps/UI/PlacePage/Components/ElevationProfile/ElevationProfileFormatter.swift index 4a17c5c4c5..a6dbab87a8 100644 --- a/iphone/Maps/UI/PlacePage/Components/ElevationProfile/ElevationProfileFormatter.swift +++ b/iphone/Maps/UI/PlacePage/Components/ElevationProfile/ElevationProfileFormatter.swift @@ -5,7 +5,7 @@ final class ElevationProfileFormatter { private enum Constants { static let metricToImperialMultiplier: CGFloat = 0.3048 - static var metricAltitudeStep: CGFloat = 50 + static var metricAltitudeStep: CGFloat = 25 static var imperialAltitudeStep: CGFloat = 100 } diff --git a/iphone/Maps/UI/PlacePage/Components/ElevationProfile/ElevationProfilePresenter.swift b/iphone/Maps/UI/PlacePage/Components/ElevationProfile/ElevationProfilePresenter.swift index 585ae633a5..d0ca125e39 100644 --- a/iphone/Maps/UI/PlacePage/Components/ElevationProfile/ElevationProfilePresenter.swift +++ b/iphone/Maps/UI/PlacePage/Components/ElevationProfile/ElevationProfilePresenter.swift @@ -18,6 +18,12 @@ fileprivate struct DescriptionsViewModel { let title: String let value: UInt let imageName: String + + init(_ description: ElevationDescription, value: UInt) { + self.title = description.title + self.value = value + self.imageName = description.imageName + } } final class ElevationProfilePresenter: NSObject { @@ -50,10 +56,10 @@ final class ElevationProfilePresenter: NSObject { private static func descriptionModels(for trackInfo: TrackInfo) -> [DescriptionsViewModel] { [ - DescriptionsViewModel(title: L("elevation_profile_ascent"), value: trackInfo.ascent, imageName: "ic_em_ascent_24"), - DescriptionsViewModel(title: L("elevation_profile_descent"), value: trackInfo.descent, imageName: "ic_em_descent_24"), - DescriptionsViewModel(title: L("elevation_profile_max_elevation"), value: trackInfo.maxElevation, imageName: "ic_em_max_attitude_24"), - DescriptionsViewModel(title: L("elevation_profile_min_elevation"), value: trackInfo.minElevation, imageName: "ic_em_min_attitude_24") + DescriptionsViewModel(.ascent, value: trackInfo.ascent), + DescriptionsViewModel(.descent, value: trackInfo.descent), + DescriptionsViewModel(.minElevation, value: trackInfo.maxElevation), + DescriptionsViewModel(.maxElevation, value: trackInfo.minElevation) ] } @@ -85,6 +91,11 @@ extension ElevationProfilePresenter: ElevationProfilePresenterProtocol { view?.setChartData(ChartPresentationData(chartData, formatter: formatter)) view?.reloadDescription() + guard !profileData.isTrackRecording else { + view?.isChartViewInfoHidden = true + return + } + view?.setActivePoint(profileData.activePoint) view?.setMyPosition(profileData.myPosition) bookmarkManager.setElevationActivePointChanged(profileData.trackId) { [weak self] distance in diff --git a/iphone/Maps/UI/PlacePage/Components/PlacePageHeader/PlacePageHeaderPresenter.swift b/iphone/Maps/UI/PlacePage/Components/PlacePageHeader/PlacePageHeaderPresenter.swift index 8278ae5e67..daf2b7f323 100644 --- a/iphone/Maps/UI/PlacePage/Components/PlacePageHeader/PlacePageHeaderPresenter.swift +++ b/iphone/Maps/UI/PlacePage/Components/PlacePageHeader/PlacePageHeaderPresenter.swift @@ -51,8 +51,6 @@ extension PlacePageHeaderPresenter: PlacePageHeaderPresenterProtocol { view?.isExpandViewHidden = true view?.isShadowViewHidden = false } - // TODO: (KK) Enable share button for the tracks to share the whole track gpx/kml - view?.isShareButtonHidden = false } func onClosePress() { diff --git a/iphone/Maps/UI/PlacePage/Components/PlacePageHeader/PlacePageHeaderViewController.swift b/iphone/Maps/UI/PlacePage/Components/PlacePageHeader/PlacePageHeaderViewController.swift index 04c05e7a76..b77bfda011 100644 --- a/iphone/Maps/UI/PlacePage/Components/PlacePageHeader/PlacePageHeaderViewController.swift +++ b/iphone/Maps/UI/PlacePage/Components/PlacePageHeader/PlacePageHeaderViewController.swift @@ -2,7 +2,6 @@ protocol PlacePageHeaderViewProtocol: AnyObject { var presenter: PlacePageHeaderPresenterProtocol? { get set } var isExpandViewHidden: Bool { get set } var isShadowViewHidden: Bool { get set } - var isShareButtonHidden: Bool { get set } func setTitle(_ title: String?, secondaryTitle: String?) func showShareTrackMenu() @@ -78,15 +77,6 @@ extension PlacePageHeaderViewController: PlacePageHeaderViewProtocol { } } - var isShareButtonHidden: Bool { - get { - shareButton.isHidden - } - set { - shareButton.isHidden = newValue - } - } - func setTitle(_ title: String?, secondaryTitle: String?) { titleText = title secondaryText = secondaryTitle diff --git a/iphone/Maps/UI/PlacePage/PlacePageBuilder.swift b/iphone/Maps/UI/PlacePage/PlacePageBuilder.swift index cac8307f61..8502a0d496 100644 --- a/iphone/Maps/UI/PlacePage/PlacePageBuilder.swift +++ b/iphone/Maps/UI/PlacePage/PlacePageBuilder.swift @@ -15,8 +15,7 @@ case .track: layout = PlacePageTrackLayout(interactor: interactor, storyboard: storyboard, data: data) case .trackRecording: - // TODO: Implement PlacePageTrackRecordingLayout - fatalError("PlacePageTrackRecordingLayout is not implemented") + layout = PlacePageTrackRecordingLayout(interactor: interactor, storyboard: storyboard, data: data) @unknown default: fatalError() } @@ -34,14 +33,14 @@ data: data, mapViewController: MapViewController.shared()!) let layout: IPlacePageLayout + let storyboard = viewController.storyboard! switch data.objectType { case .POI, .bookmark: - layout = PlacePageCommonLayout(interactor: interactor, storyboard: viewController.storyboard!, data: data) + layout = PlacePageCommonLayout(interactor: interactor, storyboard: storyboard, data: data) case .track: - layout = PlacePageTrackLayout(interactor: interactor, storyboard: viewController.storyboard!, data: data) + layout = PlacePageTrackLayout(interactor: interactor, storyboard: storyboard, data: data) case .trackRecording: - // TODO: Implement PlacePageTrackRecordingLayout - fatalError("PlacePageTrackRecordingLayout is not implemented") + layout = PlacePageTrackRecordingLayout(interactor: interactor, storyboard: storyboard, data: data) @unknown default: fatalError() } diff --git a/iphone/Maps/UI/PlacePage/PlacePageInteractor.swift b/iphone/Maps/UI/PlacePage/PlacePageInteractor.swift index bb2e8161b3..28a1ebb2a1 100644 --- a/iphone/Maps/UI/PlacePage/PlacePageInteractor.swift +++ b/iphone/Maps/UI/PlacePage/PlacePageInteractor.swift @@ -24,14 +24,29 @@ class PlacePageInteractor: NSObject { } private func updatePlacePageIfNeeded() { - let isBookmark = placePageData.bookmarkData != nil && bookmarksManager.hasBookmark(placePageData.bookmarkData!.bookmarkId) - let isTrack = placePageData.trackData != nil && bookmarksManager.hasTrack(placePageData.trackData!.trackId) - guard isBookmark || isTrack else { - presenter?.closeAnimated() - return + func updatePlacePage() { + FrameworkHelper.updatePlacePageData() + placePageData.updateBookmarkStatus() + } + + switch placePageData.objectType { + case .POI, .trackRecording: + break + case .bookmark: + guard bookmarksManager.hasBookmark(placePageData.bookmarkData!.bookmarkId) else { + presenter?.closeAnimated() + return + } + updatePlacePage() + case .track: + guard bookmarksManager.hasTrack(placePageData.trackData!.trackId) else { + presenter?.closeAnimated() + return + } + updatePlacePage() + @unknown default: + fatalError("Unknown object type") } - FrameworkHelper.updatePlacePageData() - placePageData.updateBookmarkStatus() } private func addToBookmarksManagerObserverList() { @@ -239,9 +254,19 @@ extension PlacePageInteractor: ActionBarViewControllerDelegate { fatalError("More button should've been handled in ActionBarViewContoller") case .track: guard placePageData.trackData != nil else { return } - // TODO: This is temporary solution. Remove the dialog and use the MWMPlacePageManagerHelper.removeTrack + // TODO: (KK) This is temporary solution. Remove the dialog and use the MWMPlacePageManagerHelper.removeTrack // directly here when the track recovery mechanism will be implemented. showTrackDeletionConfirmationDialog() + case .saveTrackRecording: + // TODO: (KK) pass name typed by user + TrackRecordingManager.shared.processAction(.stopAndSave(name: "")) { [weak self] result in + switch result { + case .success: + break + case .error: + self?.presenter?.closeAnimated() + } + } @unknown default: fatalError() } @@ -277,8 +302,8 @@ extension PlacePageInteractor: ElevationProfileViewControllerDelegate { } func updateMapPoint(_ point: CLLocationCoordinate2D, distance: Double) { - guard let trackId = placePageData.trackData?.trackId else { return } - BookmarksManager.shared().setElevationActivePoint(point, distance: distance, trackId: trackId) + guard let trackData = placePageData.trackData, trackData.elevationProfileData?.isTrackRecording == false else { return } + BookmarksManager.shared().setElevationActivePoint(point, distance: distance, trackId: trackData.trackId) } } @@ -301,6 +326,10 @@ extension PlacePageInteractor: PlacePageHeaderViewControllerDelegate { shareViewController.present(inParentViewController: mapViewController, anchorView: sourceView) case .track: presenter?.showShareTrackMenu() + case .trackRecording: + let currentLocation = LocationManager.lastLocation()?.coordinate ?? placePageData.locationCoordinate + let shareMyPositionViewController = ActivityViewController.share(forMyPosition: currentLocation) + shareMyPositionViewController.present(inParentViewController: mapViewController, anchorView: sourceView) default: fatalError() } diff --git a/iphone/Maps/UI/PlacePage/PlacePageLayout/ActionBar/MWMActionBarButton.h b/iphone/Maps/UI/PlacePage/PlacePageLayout/ActionBar/MWMActionBarButton.h index a82a84c698..d8c5f33ab1 100644 --- a/iphone/Maps/UI/PlacePage/PlacePageLayout/ActionBar/MWMActionBarButton.h +++ b/iphone/Maps/UI/PlacePage/PlacePageLayout/ActionBar/MWMActionBarButton.h @@ -3,6 +3,7 @@ typedef NS_ENUM(NSInteger, MWMActionBarButtonType) { MWMActionBarButtonTypeBookingSearch, MWMActionBarButtonTypeBookmark, MWMActionBarButtonTypeTrack, + MWMActionBarButtonTypeSaveTrackRecording, MWMActionBarButtonTypeCall, MWMActionBarButtonTypeDownload, MWMActionBarButtonTypeMore, diff --git a/iphone/Maps/UI/PlacePage/PlacePageLayout/ActionBar/MWMActionBarButton.m b/iphone/Maps/UI/PlacePage/PlacePageLayout/ActionBar/MWMActionBarButton.m index 0a7401bafa..dd38a97b9d 100644 --- a/iphone/Maps/UI/PlacePage/PlacePageLayout/ActionBar/MWMActionBarButton.m +++ b/iphone/Maps/UI/PlacePage/PlacePageLayout/ActionBar/MWMActionBarButton.m @@ -19,6 +19,8 @@ NSString *titleForButton(MWMActionBarButtonType type, BOOL isSelected) { case MWMActionBarButtonTypeBookmark: case MWMActionBarButtonTypeTrack: return L(isSelected ? @"delete" : @"save"); + case MWMActionBarButtonTypeSaveTrackRecording: + return L(@"save"); case MWMActionBarButtonTypeRouteFrom: return L(@"p2p_from_here"); case MWMActionBarButtonTypeRouteTo: @@ -55,7 +57,8 @@ NSString *titleForButton(MWMActionBarButtonType type, BOOL isSelected) { self.label.text = titleForButton(self.type, isSelected); self.extraBackground.hidden = YES; self.button.coloring = MWMButtonColoringBlack; - + [self.button.imageView setContentMode:UIViewContentModeScaleAspectFit]; + switch (self.type) { case MWMActionBarButtonTypeDownload: { if (self.mapDownloadProgress) @@ -108,6 +111,9 @@ NSString *titleForButton(MWMActionBarButtonType type, BOOL isSelected) { [self.button setImage:[[UIImage imageNamed:@"ic_route_manager_trash"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate] forState:UIControlStateNormal]; self.button.coloring = MWMButtonColoringRed; break; + case MWMActionBarButtonTypeSaveTrackRecording: + [self.button setImage:[UIImage imageNamed:@"ic_placepage_save_track_recording"] forState:UIControlStateNormal]; + break; case MWMActionBarButtonTypeRouteFrom: [self.button setImage:[UIImage imageNamed:@"ic_route_from"] forState:UIControlStateNormal]; break; diff --git a/iphone/Maps/UI/PlacePage/PlacePageLayout/Layouts/PlacePageTrackRecordingLayout.swift b/iphone/Maps/UI/PlacePage/PlacePageLayout/Layouts/PlacePageTrackRecordingLayout.swift new file mode 100644 index 0000000000..5dba5ce430 --- /dev/null +++ b/iphone/Maps/UI/PlacePage/PlacePageLayout/Layouts/PlacePageTrackRecordingLayout.swift @@ -0,0 +1,92 @@ +final class PlacePageTrackRecordingLayout: IPlacePageLayout { + private var placePageData: PlacePageData + private var interactor: PlacePageInteractor + private let storyboard: UIStoryboard + weak var presenter: PlacePagePresenterProtocol? + + lazy var bodyViewControllers: [UIViewController] = { + return configureViewControllers() + }() + + var actionBar: ActionBarViewController? { + actionBarViewController + } + + var navigationBar: UIViewController? { + placePageNavigationViewController + } + + lazy var headerViewControllers: [UIViewController] = { + [headerViewController] + }() + + lazy var headerViewController: PlacePageHeaderViewController = { + return PlacePageHeaderBuilder.build(data: placePageData, delegate: interactor, headerType: .flexible) + }() + + lazy var placePageNavigationViewController: PlacePageHeaderViewController = { + return PlacePageHeaderBuilder.build(data: placePageData, delegate: interactor, headerType: .fixed) + }() + + lazy var editTrackViewController: PlacePageEditBookmarkOrTrackViewController = { + let vc = storyboard.instantiateViewController(ofType: PlacePageEditBookmarkOrTrackViewController.self) + vc.view.isHidden = true + vc.delegate = interactor + return vc + }() + + lazy var elevationProfileViewController: ElevationProfileViewController? = { + guard let trackData = placePageData.trackData else { + return nil + } + return ElevationProfileBuilder.build(trackInfo: trackData.trackInfo, + elevationProfileData: trackData.elevationProfileData, + delegate: interactor) + }() + + lazy var actionBarViewController: ActionBarViewController = { + let vc = storyboard.instantiateViewController(ofType: ActionBarViewController.self) + vc.placePageData = placePageData + vc.canAddStop = MWMRouter.canAddIntermediatePoint() + vc.isRoutePlanning = MWMNavigationDashboardManager.shared().state != .hidden + vc.delegate = interactor + return vc + }() + + init(interactor: PlacePageInteractor, storyboard: UIStoryboard, data: PlacePageData) { + self.interactor = interactor + self.storyboard = storyboard + self.placePageData = data + } + + private func configureViewControllers() -> [UIViewController] { + var viewControllers = [UIViewController]() + + if let elevationProfileViewController { + viewControllers.append(elevationProfileViewController) + } + + placePageData.onTrackRecordingProgressUpdate = { [weak self] in + self?.updateTrackRecordingRelatedSections() + } + + return viewControllers + } + + func calculateSteps(inScrollView scrollView: UIScrollView, compact: Bool) -> [PlacePageState] { + var steps: [PlacePageState] = [] + let scrollHeight = scrollView.height + steps.append(.closed(-scrollHeight)) + steps.append(.full(0)) + return steps + } +} + +private extension PlacePageTrackRecordingLayout { + func updateTrackRecordingRelatedSections() { + guard let elevationProfileViewController, let trackInfo = placePageData.trackData?.trackInfo else { return } + headerViewController.setTitle(placePageData.previewData.title, secondaryTitle: nil) + elevationProfileViewController.presenter?.update(trackInfo: trackInfo, profileData: placePageData.trackData?.elevationProfileData) + presenter?.layoutIfNeeded() + } +}