diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..feec76df Binary files /dev/null and b/.DS_Store differ diff --git a/architecture/out/search_system_containers_c4_diagram/search_system_containers.png b/architecture/out/search_system_containers_c4_diagram/search_system_containers.png index 7b25879e..bf48d5b6 100644 Binary files a/architecture/out/search_system_containers_c4_diagram/search_system_containers.png and b/architecture/out/search_system_containers_c4_diagram/search_system_containers.png differ diff --git a/architecture/search_system_containers_c4_diagram.puml b/architecture/search_system_containers_c4_diagram.puml index efad5157..07320624 100644 --- a/architecture/search_system_containers_c4_diagram.puml +++ b/architecture/search_system_containers_c4_diagram.puml @@ -24,7 +24,7 @@ Rel(user, mobile, "Opens up") Rel_R(mobile, searchService, "Gets search results for user queries from", "via Firebase AppCheck") Rel_R(searchService, bggApi, "Gets board games search results from") Rel_D(searchService, nosql, "Gets available board games information based on the search results from") -Rel_D(searchService, cacheQueue, "Sends not found board games to") +Rel_D(searchService, cacheQueue, "Sends not cached or expired board games to") Rel_U(cacheQueueWorker, cacheQueue, "Reads board games from") Rel_U(cacheQueueWorker, nosql, "Stores the board game details and pricing information in") Rel_R(cacheQueueWorker, bggApi, "Gets board games details information from") diff --git a/backend/BGC.SearchApi.UnitTests/Endpoints/SearchEndpointTests.cs b/backend/BGC.SearchApi.UnitTests/Endpoints/SearchEndpointTests.cs index c02b93e7..1f0b3c6b 100644 --- a/backend/BGC.SearchApi.UnitTests/Endpoints/SearchEndpointTests.cs +++ b/backend/BGC.SearchApi.UnitTests/Endpoints/SearchEndpointTests.cs @@ -9,13 +9,14 @@ using BGC.SearchApi.UnitTests.Helpers; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace BGC.SearchApi.UnitTests.Endpoints { public class SearchEndpointTests { + private const string InvalidApiKey = "invalid-api-key"; + private readonly Mock _mockSearchService; public SearchEndpointTests() @@ -23,7 +24,6 @@ public SearchEndpointTests() _mockSearchService = new Mock(); } - private const string InvalidApiKey = "invalid-api-key"; [Fact] public async Task Search_NoApiKey_ThrowsUnauthorizedException() diff --git a/backend/BGC.SearchApi.UnitTests/Helpers/WebApiApp.cs b/backend/BGC.SearchApi.UnitTests/Helpers/WebApiApp.cs index 486778d9..718a6bdc 100644 --- a/backend/BGC.SearchApi.UnitTests/Helpers/WebApiApp.cs +++ b/backend/BGC.SearchApi.UnitTests/Helpers/WebApiApp.cs @@ -7,6 +7,8 @@ namespace BGC.SearchApi.UnitTests.Helpers public sealed class WebApiApp : WebApplicationFactory { public const string ApiKey = "apiKey"; + public const string CacheConnectionString= "connectionString"; + public const string CacheQueueName = "queueName"; public const string MongoDbConnectionString = "mongoDbConnectionString"; protected override IHost CreateHost(IHostBuilder builder) @@ -16,6 +18,8 @@ protected override IHost CreateHost(IHostBuilder builder) configBuilder.Sources.Clear(); configBuilder.AddInMemoryCollection(new Dictionary { + { "AppSettings:CacheSettings:ConnectionString", CacheConnectionString }, + { "AppSettings:CacheSettings:QueueName", CacheQueueName }, { "AppSettings:ApiKeyAuthenticationSettings:ApiKey", ApiKey }, { "AppSettings:MongoDbSettings:ConnectionString", MongoDbConnectionString }, }); diff --git a/backend/BGC.SearchApi.UnitTests/Services/SearchServiceTests.cs b/backend/BGC.SearchApi.UnitTests/Services/SearchServiceTests.cs index b57bb3a7..5238ea99 100644 --- a/backend/BGC.SearchApi.UnitTests/Services/SearchServiceTests.cs +++ b/backend/BGC.SearchApi.UnitTests/Services/SearchServiceTests.cs @@ -13,6 +13,7 @@ public class SearchServiceTests private readonly Mock> _mockLogger; private readonly Mock _mockBggService; private readonly Mock _mockBoardGamesRepository; + private readonly Mock _mockCacheService; private readonly SearchService searchService; @@ -21,8 +22,9 @@ public SearchServiceTests() _mockLogger = new Mock>(); _mockBggService = new Mock(); _mockBoardGamesRepository = new Mock(); + _mockCacheService = new Mock(); - searchService = new SearchService(_mockLogger.Object, _mockBggService.Object, _mockBoardGamesRepository.Object); + searchService = new SearchService(_mockLogger.Object, _mockBggService.Object, _mockBoardGamesRepository.Object, _mockCacheService.Object); } [Fact] @@ -55,10 +57,11 @@ public async Task Search_FindsBggGames_ReturnsGameResults() BoardGames = new List() { new BoardGameSearchResult("1238", "Scythe", 1987), - new BoardGameSearchResult("82374", "My Little Scythe", 2018) - } + new BoardGameSearchResult("82374", "My Little Scythe", 2018), + }, }; _mockBggService.Setup(service => service.Search(searchQuery, It.IsAny())).ReturnsAsync(bggSearchResposne); + _mockBoardGamesRepository.Setup(repository => repository.GetBoardGames(It.IsAny>(), It.IsAny())).ReturnsAsync(Array.Empty); var searchResults = await searchService.Search(searchQuery, CancellationToken.None); searchResults.Should().NotBeEmpty(); @@ -76,15 +79,15 @@ public async Task Search_EnrichesBoardGameDetails_ReturnsEnrichedGameDetailResul BoardGames = new List() { new BoardGameSearchResult("1238", "Scythe", 1987), - new BoardGameSearchResult("82374", "My Little Scythe", 2018) - } + new BoardGameSearchResult("82374", "My Little Scythe", 2018), + }, }; _mockBggService.Setup(service => service.Search(searchQuery, It.IsAny())).ReturnsAsync(bggSearchResposne); var enrichedBoardGameDetails = new BoardGame() { Id = "1238", - ImageUrl = "https://fancy.image.net/funny.jpg" + ImageUrl = "https://fancy.image.net/funny.jpg", }; _mockBoardGamesRepository.Setup(repository => repository.GetBoardGames(It.IsAny>(), It.IsAny())).ReturnsAsync(new[] { enrichedBoardGameDetails }); @@ -94,4 +97,32 @@ public async Task Search_EnrichesBoardGameDetails_ReturnsEnrichedGameDetailResul searchResults.Should().ContainEquivalentOf(new BoardGameSummaryDto("1238", "Scythe", 1987) { ImageUrl = enrichedBoardGameDetails.ImageUrl }); searchResults.Should().ContainEquivalentOf(new BoardGameSummaryDto("82374", "My Little Scythe", 2018)); } + + [Fact] + public async Task Search_NewBoardGames_CachesNewBoardGames() + { + var searchQuery = "Scythe"; + var cachedBoardGameId = "1238"; + var newBoardGameId = "82374"; + var bggSearchResposne = new BoardGameSearchResponse() + { + BoardGames = new List() + { + new BoardGameSearchResult(cachedBoardGameId, "Scythe", 1987), + new BoardGameSearchResult(newBoardGameId, "My Little Scythe", 2018), + }, + }; + var cachedBoardGames = new List() + { + new BoardGame() + { + Id = cachedBoardGameId, + }, + }; + _mockBggService.Setup(service => service.Search(searchQuery, It.IsAny())).ReturnsAsync(bggSearchResposne); + _mockBoardGamesRepository.Setup(repository => repository.GetBoardGames(It.IsAny>(), It.IsAny())).ReturnsAsync(cachedBoardGames); + + var searchResults = await searchService.Search(searchQuery, CancellationToken.None); + _mockCacheService.Verify(service => service.Add(It.Is>(boardGames => boardGames.Contains(newBoardGameId))), Times.Once); + } } \ No newline at end of file diff --git a/backend/BGC.SearchApi/Authentication/ApiKeyAuthenticationSettings.cs b/backend/BGC.SearchApi/Authentication/ApiKeyAuthenticationSettings.cs index 3d9fb4ca..084cfec0 100644 --- a/backend/BGC.SearchApi/Authentication/ApiKeyAuthenticationSettings.cs +++ b/backend/BGC.SearchApi/Authentication/ApiKeyAuthenticationSettings.cs @@ -13,6 +13,6 @@ public class ApiKeyAuthenticationSettings : AuthenticationSchemeOptions /// Gets api key. /// [Required] - public string ApiKey { get; init; } + public string ApiKey { get; init; } = null!; } } diff --git a/backend/BGC.SearchApi/BGC.SearchApi.csproj b/backend/BGC.SearchApi/BGC.SearchApi.csproj index 2792513e..e0537f64 100644 --- a/backend/BGC.SearchApi/BGC.SearchApi.csproj +++ b/backend/BGC.SearchApi/BGC.SearchApi.csproj @@ -8,6 +8,7 @@ + diff --git a/backend/BGC.SearchApi/Models/Dtos/BoardGameSummaryDto.cs b/backend/BGC.SearchApi/Models/Dtos/BoardGameSummaryDto.cs index ca85cc65..9e077c2e 100644 --- a/backend/BGC.SearchApi/Models/Dtos/BoardGameSummaryDto.cs +++ b/backend/BGC.SearchApi/Models/Dtos/BoardGameSummaryDto.cs @@ -2,6 +2,13 @@ public record BoardGameSummaryDto { + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + /// Model is based on the data returned from the BGG XML API. public BoardGameSummaryDto(string id, string name, int yearPublished) { Id = id; @@ -16,7 +23,7 @@ public BoardGameSummaryDto(string id, string name, int yearPublished) public int YearPublished { get; init; } /// - /// Type of the board game (e.g. BoardGame or BoardGameExpansion) + /// Type of the board game (e.g. BoardGame or BoardGameExpansion). /// public string Type { get; set; } = null!; diff --git a/backend/BGC.SearchApi/Models/Settings/AppSettings.cs b/backend/BGC.SearchApi/Models/Settings/AppSettings.cs index 89d7a8b6..e7c8637f 100644 --- a/backend/BGC.SearchApi/Models/Settings/AppSettings.cs +++ b/backend/BGC.SearchApi/Models/Settings/AppSettings.cs @@ -7,8 +7,14 @@ namespace BGC.SearchApi.Models.Settings [ExcludeFromCodeCoverage(Justification = "Settings model don't require testing")] public record AppSettings { + /// + /// Gets board games database. + /// public MongoDbSettings? MongoDb { get; init; } + /// + /// Gets auth settings. + /// public ApiKeyAuthenticationSettings? ApiKeyAuthenticationSettings { get; init; } } } diff --git a/backend/BGC.SearchApi/Models/Settings/CacheSettings.cs b/backend/BGC.SearchApi/Models/Settings/CacheSettings.cs new file mode 100644 index 00000000..ea05fc81 --- /dev/null +++ b/backend/BGC.SearchApi/Models/Settings/CacheSettings.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; + +namespace BGC.SearchApi.Models.Settings +{ + [ExcludeFromCodeCoverage(Justification = "Settings model don't require testing")] + public record CacheSettings + { + /// + /// Gets connection string for sending messages. + /// + [Required] + public string SendConnectionString { get; init; } = null!; + + /// + /// Gets queue name. + /// + [Required] + public string QueueName { get; init; } = null!; + } +} diff --git a/backend/BGC.SearchApi/Models/Settings/MongoDbSettings.cs b/backend/BGC.SearchApi/Models/Settings/MongoDbSettings.cs index 872ab434..1715b5e1 100644 --- a/backend/BGC.SearchApi/Models/Settings/MongoDbSettings.cs +++ b/backend/BGC.SearchApi/Models/Settings/MongoDbSettings.cs @@ -6,7 +6,10 @@ namespace BGC.SearchApi.Models.Settings [ExcludeFromCodeCoverage(Justification = "Settings model don't require testing")] public record MongoDbSettings { + /// + /// Gets connection string. + /// [Required] - public string? ConnectionString { get; init; } + public string ConnectionString { get; init; } = null!; } } diff --git a/backend/BGC.SearchApi/Program.cs b/backend/BGC.SearchApi/Program.cs index 2c188bcc..e83dd7ed 100644 --- a/backend/BGC.SearchApi/Program.cs +++ b/backend/BGC.SearchApi/Program.cs @@ -19,6 +19,10 @@ var builder = WebApplication.CreateBuilder(args); var appSettingsConfigurationSection = builder.Configuration.GetSection(nameof(AppSettings)); +builder.Services.AddOptions() + .Bind(appSettingsConfigurationSection.GetSection(nameof(CacheSettings))) + .ValidateDataAnnotations() + .ValidateOnStart(); builder.Services.AddOptions() .Bind(appSettingsConfigurationSection.GetSection(nameof(MongoDbSettings))) .ValidateDataAnnotations() @@ -50,6 +54,7 @@ return new MongoClient(mongoDbSettings!.Value.ConnectionString); }); +builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); @@ -101,5 +106,5 @@ await Results.Problem(statusCode: statusCodeContext.HttpContext.Response.StatusC /// /// Entry point for the API. /// -/// MK Having this is required because otherwise the integration tests using WebApplicationFactory won't work. +/// MK Declaring as a partial class is required because otherwise the integration tests using WebApplicationFactory won't work. public partial class Program { } \ No newline at end of file diff --git a/backend/BGC.SearchApi/Services/CacheService.cs b/backend/BGC.SearchApi/Services/CacheService.cs new file mode 100644 index 00000000..cc551f40 --- /dev/null +++ b/backend/BGC.SearchApi/Services/CacheService.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +using Azure.Messaging.ServiceBus; + +using BGC.SearchApi.Models.Settings; +using BGC.SearchApi.Services.Interfaces; + +using Microsoft.Extensions.Options; + +using MongoDB.Bson.IO; + +namespace BGC.SearchApi.Services +{ + /// + public class CacheService : ICacheService + { + private const string OperationTypePropertyName = "operationType"; + + private const string AppOperationName = "add"; + private const string UpdateOperationName = "update"; + + private readonly ServiceBusClient _client; + private readonly ServiceBusSender _sender; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// + /// + public CacheService(IOptions cacheSettings, ILogger logger) + { + var clientOptions = new ServiceBusClientOptions() + { + TransportType = ServiceBusTransportType.AmqpWebSockets, + }; + _client = new ServiceBusClient(cacheSettings.Value.SendConnectionString, clientOptions); + _sender = _client.CreateSender(cacheSettings.Value.QueueName); + _logger = logger; + } + + /// + public async Task Add(IEnumerable boardGameIds) + { + await SendMessagesToCacheQueue(boardGameIds, AppOperationName); + } + + /// + public async Task Update(IEnumerable boardGameIds) + { + await SendMessagesToCacheQueue(boardGameIds, UpdateOperationName); + } + + private async Task SendMessagesToCacheQueue(IEnumerable boardGameIds, string operationName) + { + if (!boardGameIds.Any()) + { + return; + } + + using ServiceBusMessageBatch messageBatch = await _sender.CreateMessageBatchAsync(); + + foreach (var boardGameId in boardGameIds) + { + var payload = new { boardGameId = boardGameId }; + var message = new ServiceBusMessage(JsonSerializer.Serialize(payload)); + message.ApplicationProperties[OperationTypePropertyName] = operationName; + if (!messageBatch.TryAddMessage(message)) + { + _logger.LogError($"Failed to add message to the batch for the board game {boardGameId}"); + continue; + } + } + + try + { + _logger.LogInformation($"Sending board games {string.Join(",", boardGameIds)} to the queue for caching..."); + await _sender.SendMessagesAsync(messageBatch); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to send message to the queue for caching."); + } + } + } +} diff --git a/backend/BGC.SearchApi/Services/Interfaces/ICacheService.cs b/backend/BGC.SearchApi/Services/Interfaces/ICacheService.cs new file mode 100644 index 00000000..6607fc6e --- /dev/null +++ b/backend/BGC.SearchApi/Services/Interfaces/ICacheService.cs @@ -0,0 +1,22 @@ +namespace BGC.SearchApi.Services.Interfaces +{ + /// + /// Cache service. + /// + public interface ICacheService + { + /// + /// Adds a board game to cache. + /// + /// + /// A representing the asynchronous operation. + Task Add(IEnumerable boardGameIds); + + /// + /// Updates already cached board game. + /// + /// + /// A representing the asynchronous operation. + Task Update(IEnumerable boardGameIds); + } +} diff --git a/backend/BGC.SearchApi/Services/Interfaces/ISearchService.cs b/backend/BGC.SearchApi/Services/Interfaces/ISearchService.cs index 4c17d200..ec850a33 100644 --- a/backend/BGC.SearchApi/Services/Interfaces/ISearchService.cs +++ b/backend/BGC.SearchApi/Services/Interfaces/ISearchService.cs @@ -2,7 +2,16 @@ namespace BGC.SearchApi.Services.Interface; +/// +/// Search service interface. +/// public interface ISearchService { + /// + /// Search board games with a . + /// + /// + /// + /// A collection of . Task> Search(string query, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/backend/BGC.SearchApi/Services/SearchService.cs b/backend/BGC.SearchApi/Services/SearchService.cs index a352936a..d4fced04 100644 --- a/backend/BGC.SearchApi/Services/SearchService.cs +++ b/backend/BGC.SearchApi/Services/SearchService.cs @@ -1,5 +1,7 @@ +using System.Collections.Generic; using System.Net; +using BGC.SearchApi.Models.Domain; using BGC.SearchApi.Models.Dtos; using BGC.SearchApi.Models.Exceptions; using BGC.SearchApi.Repositories.Interfaces; @@ -8,19 +10,30 @@ namespace BGC.SearchApi.Services; +/// public class SearchService : ISearchService { private readonly ILogger _logger; private readonly IBggService _bggService; private readonly IBoardGamesRepository _boardGamesRepository; + private readonly ICacheService _cacheService; - public SearchService(ILogger logger, IBggService bggService, IBoardGamesRepository boardGamesRepository) + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + /// + public SearchService(ILogger logger, IBggService bggService, IBoardGamesRepository boardGamesRepository, ICacheService cacheService) { _logger = logger; _bggService = bggService; _boardGamesRepository = boardGamesRepository; + _cacheService = cacheService; } + /// public async Task> Search(string query, CancellationToken cancellationToken) { try @@ -31,12 +44,18 @@ public async Task> Search(string query, return Array.Empty(); } - // TODO If detailed info doesn't exists, queue a message to retrieve it + var boardGameSummaries = bggSearchResponse.BoardGames.Select(boardGame => new BoardGameSummaryDto(boardGame.Id, boardGame.Name, boardGame.YearPublished)).ToArray(); - var boardGames = bggSearchResponse.BoardGames.Select(boardGame => new BoardGameSummaryDto(boardGame.Id, boardGame.Name, boardGame.YearPublished)).ToArray(); - await EnrichBoardGameDetails(boardGames, cancellationToken); + var boardGamesDetails = await _boardGamesRepository.GetBoardGames(boardGameSummaries.Select(boardGame => boardGame.Id), cancellationToken); + var boardGamesDetailsDict = boardGamesDetails.ToDictionary(boardGame => boardGame.Id); + + EnrichBoardGameDetails(boardGameSummaries, boardGamesDetailsDict); - return boardGames; +#pragma warning disable CS4014 // Intentionally not awaiting this call, as it should be done in the background + CacheBoardGames(boardGameSummaries, boardGamesDetailsDict); +#pragma warning restore CS4014 // Intentionally not awaiting this call, as it should be done in the background + + return boardGameSummaries; } catch (Exception ex) { @@ -46,12 +65,10 @@ public async Task> Search(string query, } } - private async Task EnrichBoardGameDetails(IReadOnlyCollection boardGames, CancellationToken cancellationToken) + private void EnrichBoardGameDetails(IReadOnlyCollection boardGames, IDictionary boardGamesDetailsDict) { try { - var boardGamesDetails = await _boardGamesRepository.GetBoardGames(boardGames.Select(boardGame => boardGame.Id), cancellationToken); - var boardGamesDetailsDict = boardGamesDetails.ToDictionary(boardGame => boardGame.Id); foreach (var boardGame in boardGames) { if (!boardGamesDetailsDict.TryGetValue(boardGame.Id, out var boardGameDetails)) @@ -75,6 +92,13 @@ private async Task EnrichBoardGameDetails(IReadOnlyCollection boardGameSummaries, Dictionary boardGamesDetailsDict) + { + var newBoardGameIds = boardGameSummaries.Select(boardGame => boardGame.Id).Except(boardGamesDetailsDict.Values.Select(boardGame => boardGame.Id)); + await _cacheService.Add(newBoardGameIds); + // TODO Handle expired board game details } } \ No newline at end of file diff --git a/board_games_companion/ios/Podfile.lock b/board_games_companion/ios/Podfile.lock new file mode 100644 index 00000000..1f990f30 --- /dev/null +++ b/board_games_companion/ios/Podfile.lock @@ -0,0 +1,277 @@ +PODS: + - DKImagePickerController/Core (4.3.4): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.4) + - DKImagePickerController/PhotoGallery (4.3.4): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.4) + - DKPhotoGallery (0.0.17): + - DKPhotoGallery/Core (= 0.0.17) + - DKPhotoGallery/Model (= 0.0.17) + - DKPhotoGallery/Preview (= 0.0.17) + - DKPhotoGallery/Resource (= 0.0.17) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.17): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.17): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter + - Firebase/Analytics (10.6.0): + - Firebase/Core + - Firebase/Core (10.6.0): + - Firebase/CoreOnly + - FirebaseAnalytics (~> 10.6.0) + - Firebase/CoreOnly (10.6.0): + - FirebaseCore (= 10.6.0) + - Firebase/Crashlytics (10.6.0): + - Firebase/CoreOnly + - FirebaseCrashlytics (~> 10.6.0) + - firebase_analytics (10.1.6): + - Firebase/Analytics (= 10.6.0) + - firebase_core + - Flutter + - firebase_core (2.8.0): + - Firebase/CoreOnly (= 10.6.0) + - Flutter + - firebase_crashlytics (3.0.17): + - Firebase/Crashlytics (= 10.6.0) + - firebase_core + - Flutter + - FirebaseAnalytics (10.6.0): + - FirebaseAnalytics/AdIdSupport (= 10.6.0) + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/MethodSwizzler (~> 7.8) + - GoogleUtilities/Network (~> 7.8) + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - FirebaseAnalytics/AdIdSupport (10.6.0): + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleAppMeasurement (= 10.6.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/MethodSwizzler (~> 7.8) + - GoogleUtilities/Network (~> 7.8) + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - FirebaseCore (10.6.0): + - FirebaseCoreInternal (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/Logger (~> 7.8) + - FirebaseCoreExtension (10.11.0): + - FirebaseCore (~> 10.0) + - FirebaseCoreInternal (10.11.0): + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - FirebaseCrashlytics (10.6.0): + - FirebaseCore (~> 10.5) + - FirebaseInstallations (~> 10.0) + - FirebaseSessions (~> 10.5) + - GoogleDataTransport (~> 9.2) + - GoogleUtilities/Environment (~> 7.8) + - nanopb (< 2.30910.0, >= 2.30908.0) + - PromisesObjC (~> 2.1) + - FirebaseInstallations (10.11.0): + - FirebaseCore (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/UserDefaults (~> 7.8) + - PromisesObjC (~> 2.1) + - FirebaseSessions (10.11.0): + - FirebaseCore (~> 10.5) + - FirebaseCoreExtension (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleDataTransport (~> 9.2) + - GoogleUtilities/Environment (~> 7.10) + - nanopb (< 2.30910.0, >= 2.30908.0) + - PromisesSwift (~> 2.1) + - Flutter (1.0.0) + - FMDB (2.7.5): + - FMDB/standard (= 2.7.5) + - FMDB/standard (2.7.5) + - GoogleAppMeasurement (10.6.0): + - GoogleAppMeasurement/AdIdSupport (= 10.6.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/MethodSwizzler (~> 7.8) + - GoogleUtilities/Network (~> 7.8) + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - GoogleAppMeasurement/AdIdSupport (10.6.0): + - GoogleAppMeasurement/WithoutAdIdSupport (= 10.6.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/MethodSwizzler (~> 7.8) + - GoogleUtilities/Network (~> 7.8) + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - GoogleAppMeasurement/WithoutAdIdSupport (10.6.0): + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/MethodSwizzler (~> 7.8) + - GoogleUtilities/Network (~> 7.8) + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - GoogleDataTransport (9.2.3): + - GoogleUtilities/Environment (~> 7.7) + - nanopb (< 2.30910.0, >= 2.30908.0) + - PromisesObjC (< 3.0, >= 1.2) + - GoogleUtilities/AppDelegateSwizzler (7.11.1): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Environment (7.11.1): + - PromisesObjC (< 3.0, >= 1.2) + - GoogleUtilities/Logger (7.11.1): + - GoogleUtilities/Environment + - GoogleUtilities/MethodSwizzler (7.11.1): + - GoogleUtilities/Logger + - GoogleUtilities/Network (7.11.1): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (7.11.1)" + - GoogleUtilities/Reachability (7.11.1): + - GoogleUtilities/Logger + - GoogleUtilities/UserDefaults (7.11.1): + - GoogleUtilities/Logger + - image_picker_ios (0.0.1): + - Flutter + - in_app_review (0.2.0): + - Flutter + - nanopb (2.30909.0): + - nanopb/decode (= 2.30909.0) + - nanopb/encode (= 2.30909.0) + - nanopb/decode (2.30909.0) + - nanopb/encode (2.30909.0) + - package_info (0.0.1): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - PromisesObjC (2.2.0) + - PromisesSwift (2.2.0): + - PromisesObjC (= 2.2.0) + - SDWebImage (5.16.0): + - SDWebImage/Core (= 5.16.0) + - SDWebImage/Core (5.16.0) + - share_plus (0.0.1): + - Flutter + - sqflite (0.0.2): + - Flutter + - FMDB (>= 2.7.5) + - SwiftyGif (5.4.4) + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - file_picker (from `.symlinks/plugins/file_picker/ios`) + - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) + - firebase_core (from `.symlinks/plugins/firebase_core/ios`) + - firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`) + - Flutter (from `Flutter`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - in_app_review (from `.symlinks/plugins/in_app_review/ios`) + - package_info (from `.symlinks/plugins/package_info/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - sqflite (from `.symlinks/plugins/sqflite/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - Firebase + - FirebaseAnalytics + - FirebaseCore + - FirebaseCoreExtension + - FirebaseCoreInternal + - FirebaseCrashlytics + - FirebaseInstallations + - FirebaseSessions + - FMDB + - GoogleAppMeasurement + - GoogleDataTransport + - GoogleUtilities + - nanopb + - PromisesObjC + - PromisesSwift + - SDWebImage + - SwiftyGif + +EXTERNAL SOURCES: + file_picker: + :path: ".symlinks/plugins/file_picker/ios" + firebase_analytics: + :path: ".symlinks/plugins/firebase_analytics/ios" + firebase_core: + :path: ".symlinks/plugins/firebase_core/ios" + firebase_crashlytics: + :path: ".symlinks/plugins/firebase_crashlytics/ios" + Flutter: + :path: Flutter + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" + in_app_review: + :path: ".symlinks/plugins/in_app_review/ios" + package_info: + :path: ".symlinks/plugins/package_info/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + sqflite: + :path: ".symlinks/plugins/sqflite/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac + DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 + file_picker: ce3938a0df3cc1ef404671531facef740d03f920 + Firebase: f13680471b021937f2230ea8503c7809d8c29806 + firebase_analytics: 97d12c9683531ba3f923a798a95362e7590f0757 + firebase_core: 58542d7399889ebdbb034baa72d081e54c5c814d + firebase_crashlytics: 5f6296621a0e8ed7d15a7499c8131fbdcf176e3b + FirebaseAnalytics: 9f382605c5ee412b039212f054bf7a403d9850c1 + FirebaseCore: fa80ad16a62d52f67274b5b88304c3a318bbf9a4 + FirebaseCoreExtension: cacdad57fdb60e0b86dcbcac058ec78237946759 + FirebaseCoreInternal: 9e46c82a14a3b3a25be4e1e151ce6d21536b89c0 + FirebaseCrashlytics: ede07e7f433a0a2270112baf2d156b111cfb422d + FirebaseInstallations: 2a2c6859354cbec0a228a863d4daf6de7c74ced4 + FirebaseSessions: a62ba5c45284adb7714f4126cfbdb32b17c260bd + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a + GoogleAppMeasurement: 686b48c3c895f3c55c70719041913d5d150b74f6 + GoogleDataTransport: f0308f5905a745f94fb91fea9c6cbaf3831cb1bd + GoogleUtilities: 9aa0ad5a7bc171f8bae016300bfcfa3fb8425749 + image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 + in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d + nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 + package_info: 873975fc26034f0b863a300ad47e7f1ac6c7ec62 + path_provider_foundation: c68054786f1b4f3343858c1e1d0caaded73f0be9 + PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef + PromisesSwift: cf9eb58666a43bbe007302226e510b16c1e10959 + SDWebImage: 2aea163b50bfcb569a2726b6a754c54a4506fcf6 + share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 + sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 + SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f + url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 + +PODFILE CHECKSUM: 84b7f0ad7428cb894ad73bf70c77827d7438b18a + +COCOAPODS: 1.12.1 diff --git a/board_games_companion/ios/Runner.xcodeproj/project.pbxproj b/board_games_companion/ios/Runner.xcodeproj/project.pbxproj index 49117883..e620e90f 100644 --- a/board_games_companion/ios/Runner.xcodeproj/project.pbxproj +++ b/board_games_companion/ios/Runner.xcodeproj/project.pbxproj @@ -153,6 +153,7 @@ 3B06AD1E1E4923F5004D2608 /* Thin Binary */, D02AFCB5254E4BFD00C36357 /* ShellScript */, 173C006E3010407C6C42EADC /* [CP] Embed Pods Frameworks */, + D0EAC3432A713F62008D8C1C /* ShellScript */, ); buildRules = ( ); @@ -349,6 +350,25 @@ shellPath = "${PODS_ROOT}/FirebaseCrashlytics/run"; shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n"; }; + D0EAC3432A713F62008D8C1C /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}", + "$(SRCROOT)/$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "$PODS_ROOT/FirebaseCrashlytics/upload-symbols --build-phase --validate -ai 1:718227562022:ios:56c660a2206709a39390e7\n$PODS_ROOT/FirebaseCrashlytics/upload-symbols --build-phase -ai 1:718227562022:ios:56c660a2206709a39390e7\n"; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/board_games_companion/lib/common/app_text.dart b/board_games_companion/lib/common/app_text.dart index aff77bf1..654ae13b 100644 --- a/board_games_companion/lib/common/app_text.dart +++ b/board_games_companion/lib/common/app_text.dart @@ -100,14 +100,18 @@ class AppText { static const playthroughsHistoryPageNoGamesTitle = "You haven't logged any games yet"; static const playthroughsHistoryPageNoGamesSubtitle = 'This page will fill up with the history of plays, once you start logging games.'; + static const playthroughsHistoryPageGameNumberSubtitle = 'game'; + static const playthroughsHistoryPageDaysAgo = 'days ago'; + static const playthroughsHistoryPageToday = 'today'; + static const playthroughsHistoryPageYesterday = 'yesterday'; static const playthroughsGameSettingsGameClassificationScore = 'Score based game'; static const playthroughsGameSettingsGameClssificationNoScore = 'No score based game'; static const playthroughsGameSettingsGameClassificationSectionTitle = 'Classification'; static const playthroughsGameSettingsGameFamilySectionTitle = 'Family'; static const playthroughsGameSettingsScoreDetailsSectionTitle = 'Details'; - static const playthroughsGameSettingsGameFamilyHighestScore = 'Highest score'; - static const playthroughsGameSettingsGameFamilyLowestScore = 'Lowest score'; + static const playthroughsGameSettingsGameFamilyHighestScore = 'Highest score wins'; + static const playthroughsGameSettingsGameFamilyLowestScore = 'Lowest score wins'; static const playthroughsGameSettingsGameFamilyCoop = 'Cooperative'; static const searchBoardGamesPageSearchInstructions = diff --git a/board_games_companion/lib/extensions/int_extensions.dart b/board_games_companion/lib/extensions/int_extensions.dart index d2b39110..ce2ad9e7 100644 --- a/board_games_companion/lib/extensions/int_extensions.dart +++ b/board_games_companion/lib/extensions/int_extensions.dart @@ -4,7 +4,7 @@ import '../common/app_text.dart'; import '../common/constants.dart'; extension IntExtensions on int? { - String toOrdinalAbbreviations() { + String toOrdinalAbbreviation() { if (this == null) { return ''; } diff --git a/board_games_companion/lib/injectable.config.dart b/board_games_companion/lib/injectable.config.dart index e0640929..63b70563 100644 --- a/board_games_companion/lib/injectable.config.dart +++ b/board_games_companion/lib/injectable.config.dart @@ -6,56 +6,56 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'package:board_games_companion/pages/board_game_details/board_game_details_view_model.dart' - as _i45; + as _i46; import 'package:board_games_companion/pages/collections/collection_search_result_view_model.dart' - as _i32; -import 'package:board_games_companion/pages/collections/collections_view_model.dart' as _i33; -import 'package:board_games_companion/pages/create_board_game/create_board_game_view_model.dart' +import 'package:board_games_companion/pages/collections/collections_view_model.dart' as _i34; +import 'package:board_games_companion/pages/create_board_game/create_board_game_view_model.dart' + as _i35; import 'package:board_games_companion/pages/edit_playthrough/edit_playthrough_view_model.dart' - as _i46; + as _i47; import 'package:board_games_companion/pages/edit_playthrough/playthrough_note_view_model.dart' as _i12; -import 'package:board_games_companion/pages/home/home_view_model.dart' as _i47; +import 'package:board_games_companion/pages/home/home_view_model.dart' as _i48; import 'package:board_games_companion/pages/hot_board_games/hot_board_games_view_model.dart' - as _i36; + as _i37; import 'package:board_games_companion/pages/player/player_view_model.dart' - as _i27; + as _i28; import 'package:board_games_companion/pages/players/players_view_model.dart' as _i11; import 'package:board_games_companion/pages/plays/plays_view_model.dart' - as _i37; -import 'package:board_games_companion/pages/playthroughs/playthrough_migration_view_model.dart' as _i38; +import 'package:board_games_companion/pages/playthroughs/playthrough_migration_view_model.dart' + as _i39; import 'package:board_games_companion/pages/playthroughs/playthrough_players_selection_view_model.dart' as _i13; import 'package:board_games_companion/pages/playthroughs/playthrough_statistics_view_model.dart' - as _i39; -import 'package:board_games_companion/pages/playthroughs/playthroughs_game_settings_view_model.dart' as _i40; -import 'package:board_games_companion/pages/playthroughs/playthroughs_history_view_model.dart' +import 'package:board_games_companion/pages/playthroughs/playthroughs_game_settings_view_model.dart' as _i41; -import 'package:board_games_companion/pages/playthroughs/playthroughs_log_game_view_model.dart' +import 'package:board_games_companion/pages/playthroughs/playthroughs_history_view_model.dart' as _i42; -import 'package:board_games_companion/pages/playthroughs/playthroughs_view_model.dart' +import 'package:board_games_companion/pages/playthroughs/playthroughs_log_game_view_model.dart' as _i43; -import 'package:board_games_companion/pages/settings/settings_view_model.dart' +import 'package:board_games_companion/pages/playthroughs/playthroughs_view_model.dart' as _i44; +import 'package:board_games_companion/pages/settings/settings_view_model.dart' + as _i45; import 'package:board_games_companion/services/analytics_service.dart' as _i22; import 'package:board_games_companion/services/board_games_filters_service.dart' - as _i4; + as _i23; import 'package:board_games_companion/services/board_games_geek_service.dart' - as _i24; -import 'package:board_games_companion/services/board_games_search_service.dart' as _i25; -import 'package:board_games_companion/services/board_games_service.dart' +import 'package:board_games_companion/services/board_games_search_service.dart' as _i26; -import 'package:board_games_companion/services/environment_service.dart' as _i6; -import 'package:board_games_companion/services/file_service.dart' as _i7; +import 'package:board_games_companion/services/board_games_service.dart' + as _i27; +import 'package:board_games_companion/services/environment_service.dart' as _i5; +import 'package:board_games_companion/services/file_service.dart' as _i6; import 'package:board_games_companion/services/player_service.dart' as _i9; import 'package:board_games_companion/services/playthroughs_service.dart' - as _i28; + as _i29; import 'package:board_games_companion/services/preferences_service.dart' as _i14; import 'package:board_games_companion/services/rate_and_review_service.dart' @@ -65,24 +65,25 @@ import 'package:board_games_companion/services/search_service.dart' as _i18; import 'package:board_games_companion/services/user_service.dart' as _i20; import 'package:board_games_companion/stores/app_store.dart' as _i3; import 'package:board_games_companion/stores/board_games_filters_store.dart' - as _i23; -import 'package:board_games_companion/stores/board_games_store.dart' as _i31; + as _i24; +import 'package:board_games_companion/stores/board_games_store.dart' as _i32; import 'package:board_games_companion/stores/game_playthroughs_details_store.dart' - as _i35; + as _i36; import 'package:board_games_companion/stores/players_store.dart' as _i10; -import 'package:board_games_companion/stores/playthroughs_store.dart' as _i29; +import 'package:board_games_companion/stores/playthroughs_store.dart' as _i30; import 'package:board_games_companion/stores/scores_store.dart' as _i17; import 'package:board_games_companion/stores/search_store.dart' as _i19; import 'package:board_games_companion/stores/user_store.dart' as _i21; import 'package:board_games_companion/utilities/analytics_route_observer.dart' - as _i30; + as _i31; import 'package:board_games_companion/utilities/custom_http_client_adapter.dart' - as _i5; -import 'package:firebase_analytics/firebase_analytics.dart' as _i8; + as _i4; +import 'package:firebase_analytics/firebase_analytics.dart' as _i7; import 'package:get_it/get_it.dart' as _i1; +import 'package:hive/hive.dart' as _i8; import 'package:injectable/injectable.dart' as _i2; -import 'services/injectable_register_module.dart' as _i48; +import 'services/injectable_register_module.dart' as _i49; // ignore_for_file: unnecessary_lambdas // ignore_for_file: lines_longer_than_80_chars @@ -99,14 +100,17 @@ _i1.GetIt $initGetIt( ); final registerModule = _$RegisterModule(); gh.singleton<_i3.AppStore>(_i3.AppStore()); - gh.singleton<_i4.BoardGamesFiltersService>(_i4.BoardGamesFiltersService()); - gh.factory<_i5.CustomHttpClientAdapter>(() => _i5.CustomHttpClientAdapter()); - gh.singleton<_i6.EnvironmentService>(_i6.EnvironmentService()); - gh.singleton<_i7.FileService>(_i7.FileService()); - gh.singleton<_i8.FirebaseAnalytics>(registerModule.firebaseAnalytics); - gh.singleton<_i8.FirebaseAnalyticsObserver>( + gh.factory<_i4.CustomHttpClientAdapter>(() => _i4.CustomHttpClientAdapter()); + gh.singleton<_i5.EnvironmentService>(_i5.EnvironmentService()); + gh.singleton<_i6.FileService>(_i6.FileService()); + gh.singleton<_i7.FirebaseAnalytics>(registerModule.firebaseAnalytics); + gh.singleton<_i7.FirebaseAnalyticsObserver>( registerModule.firebaseAnalyticsObserver); - gh.singleton<_i9.PlayerService>(_i9.PlayerService(gh<_i7.FileService>())); + gh.factory<_i8.HiveInterface>(() => registerModule.hive); + gh.singleton<_i9.PlayerService>(_i9.PlayerService( + gh<_i8.HiveInterface>(), + gh<_i6.FileService>(), + )); gh.singleton<_i10.PlayersStore>(_i10.PlayersStore(gh<_i9.PlayerService>())); gh.factory<_i11.PlayersViewModel>( () => _i11.PlayersViewModel(gh<_i10.PlayersStore>())); @@ -114,140 +118,147 @@ _i1.GetIt $initGetIt( () => _i12.PlaythroughNoteViewModel()); gh.factory<_i13.PlaythroughPlayersSelectionViewModel>( () => _i13.PlaythroughPlayersSelectionViewModel(gh<_i10.PlayersStore>())); - gh.singleton<_i14.PreferencesService>(_i14.PreferencesService()); + gh.singleton<_i14.PreferencesService>( + _i14.PreferencesService(gh<_i8.HiveInterface>())); gh.singleton<_i15.RateAndReviewService>( _i15.RateAndReviewService(gh<_i14.PreferencesService>())); - gh.singleton<_i16.ScoreService>(_i16.ScoreService()); + gh.singleton<_i16.ScoreService>(_i16.ScoreService(gh<_i8.HiveInterface>())); gh.singleton<_i17.ScoresStore>(_i17.ScoresStore(gh<_i16.ScoreService>())); - gh.singleton<_i18.SearchService>(_i18.SearchService()); + gh.singleton<_i18.SearchService>(_i18.SearchService(gh<_i8.HiveInterface>())); gh.singleton<_i19.SearchStore>(_i19.SearchStore(gh<_i18.SearchService>())); - gh.singleton<_i20.UserService>(_i20.UserService()); + gh.singleton<_i20.UserService>(_i20.UserService(gh<_i8.HiveInterface>())); gh.singleton<_i21.UserStore>(_i21.UserStore(gh<_i20.UserService>())); gh.singleton<_i22.AnalyticsService>(_i22.AnalyticsService( - gh<_i8.FirebaseAnalytics>(), + gh<_i7.FirebaseAnalytics>(), gh<_i15.RateAndReviewService>(), )); - gh.singleton<_i23.BoardGamesFiltersStore>(_i23.BoardGamesFiltersStore( - gh<_i4.BoardGamesFiltersService>(), + gh.singleton<_i23.BoardGamesFiltersService>( + _i23.BoardGamesFiltersService(gh<_i8.HiveInterface>())); + gh.singleton<_i24.BoardGamesFiltersStore>(_i24.BoardGamesFiltersStore( + gh<_i23.BoardGamesFiltersService>(), gh<_i22.AnalyticsService>(), )); - gh.singleton<_i24.BoardGamesGeekService>( - _i24.BoardGamesGeekService(gh<_i5.CustomHttpClientAdapter>())); - gh.singleton<_i25.BoardGamesSearchService>( - _i25.BoardGamesSearchService(gh<_i6.EnvironmentService>())); - gh.singleton<_i26.BoardGamesService>( - _i26.BoardGamesService(gh<_i24.BoardGamesGeekService>())); - gh.factory<_i27.PlayerViewModel>( - () => _i27.PlayerViewModel(gh<_i10.PlayersStore>())); - gh.singleton<_i28.PlaythroughService>( - _i28.PlaythroughService(gh<_i16.ScoreService>())); - gh.singleton<_i29.PlaythroughsStore>(_i29.PlaythroughsStore( - gh<_i28.PlaythroughService>(), + gh.singleton<_i25.BoardGamesGeekService>( + _i25.BoardGamesGeekService(gh<_i4.CustomHttpClientAdapter>())); + gh.singleton<_i26.BoardGamesSearchService>( + _i26.BoardGamesSearchService(gh<_i5.EnvironmentService>())); + gh.singleton<_i27.BoardGamesService>(_i27.BoardGamesService( + gh<_i8.HiveInterface>(), + gh<_i25.BoardGamesGeekService>(), + )); + gh.factory<_i28.PlayerViewModel>( + () => _i28.PlayerViewModel(gh<_i10.PlayersStore>())); + gh.singleton<_i29.PlaythroughService>(_i29.PlaythroughService( + gh<_i8.HiveInterface>(), + gh<_i16.ScoreService>(), + )); + gh.singleton<_i30.PlaythroughsStore>(_i30.PlaythroughsStore( + gh<_i29.PlaythroughService>(), gh<_i17.ScoresStore>(), )); - gh.factory<_i30.AnalyticsRouteObserver>( - () => _i30.AnalyticsRouteObserver(gh<_i22.AnalyticsService>())); - gh.singleton<_i31.BoardGamesStore>(_i31.BoardGamesStore( - gh<_i26.BoardGamesService>(), - gh<_i28.PlaythroughService>(), + gh.factory<_i31.AnalyticsRouteObserver>( + () => _i31.AnalyticsRouteObserver(gh<_i22.AnalyticsService>())); + gh.singleton<_i32.BoardGamesStore>(_i32.BoardGamesStore( + gh<_i27.BoardGamesService>(), + gh<_i29.PlaythroughService>(), )); - gh.factory<_i32.CollectionSearchResultViewModel>( - () => _i32.CollectionSearchResultViewModel(gh<_i31.BoardGamesStore>())); - gh.factory<_i33.CollectionsViewModel>(() => _i33.CollectionsViewModel( + gh.factory<_i33.CollectionSearchResultViewModel>( + () => _i33.CollectionSearchResultViewModel(gh<_i32.BoardGamesStore>())); + gh.factory<_i34.CollectionsViewModel>(() => _i34.CollectionsViewModel( gh<_i21.UserStore>(), - gh<_i31.BoardGamesStore>(), - gh<_i23.BoardGamesFiltersStore>(), + gh<_i32.BoardGamesStore>(), + gh<_i24.BoardGamesFiltersStore>(), gh<_i17.ScoresStore>(), - gh<_i29.PlaythroughsStore>(), + gh<_i30.PlaythroughsStore>(), gh<_i10.PlayersStore>(), )); - gh.factory<_i34.CreateBoardGameViewModel>(() => _i34.CreateBoardGameViewModel( - gh<_i31.BoardGamesStore>(), - gh<_i7.FileService>(), + gh.factory<_i35.CreateBoardGameViewModel>(() => _i35.CreateBoardGameViewModel( + gh<_i32.BoardGamesStore>(), + gh<_i6.FileService>(), )); - gh.singleton<_i35.GamePlaythroughsDetailsStore>( - _i35.GamePlaythroughsDetailsStore( - gh<_i29.PlaythroughsStore>(), + gh.singleton<_i36.GamePlaythroughsDetailsStore>( + _i36.GamePlaythroughsDetailsStore( + gh<_i30.PlaythroughsStore>(), gh<_i17.ScoresStore>(), gh<_i10.PlayersStore>(), - gh<_i31.BoardGamesStore>(), + gh<_i32.BoardGamesStore>(), )); - gh.singleton<_i36.HotBoardGamesViewModel>(_i36.HotBoardGamesViewModel( - gh<_i31.BoardGamesStore>(), - gh<_i24.BoardGamesGeekService>(), + gh.singleton<_i37.HotBoardGamesViewModel>(_i37.HotBoardGamesViewModel( + gh<_i32.BoardGamesStore>(), + gh<_i25.BoardGamesGeekService>(), gh<_i22.AnalyticsService>(), )); - gh.factory<_i37.PlaysViewModel>(() => _i37.PlaysViewModel( - gh<_i29.PlaythroughsStore>(), - gh<_i31.BoardGamesStore>(), + gh.factory<_i38.PlaysViewModel>(() => _i38.PlaysViewModel( + gh<_i30.PlaythroughsStore>(), + gh<_i32.BoardGamesStore>(), gh<_i10.PlayersStore>(), gh<_i17.ScoresStore>(), gh<_i22.AnalyticsService>(), )); - gh.factory<_i38.PlaythroughMigrationViewModel>(() => - _i38.PlaythroughMigrationViewModel( - gh<_i35.GamePlaythroughsDetailsStore>())); - gh.singleton<_i39.PlaythroughStatisticsViewModel>( - _i39.PlaythroughStatisticsViewModel( + gh.factory<_i39.PlaythroughMigrationViewModel>(() => + _i39.PlaythroughMigrationViewModel( + gh<_i36.GamePlaythroughsDetailsStore>())); + gh.singleton<_i40.PlaythroughStatisticsViewModel>( + _i40.PlaythroughStatisticsViewModel( gh<_i9.PlayerService>(), gh<_i17.ScoresStore>(), - gh<_i35.GamePlaythroughsDetailsStore>(), + gh<_i36.GamePlaythroughsDetailsStore>(), )); - gh.factory<_i40.PlaythroughsGameSettingsViewModel>( - () => _i40.PlaythroughsGameSettingsViewModel( - gh<_i31.BoardGamesStore>(), - gh<_i35.GamePlaythroughsDetailsStore>(), + gh.factory<_i41.PlaythroughsGameSettingsViewModel>( + () => _i41.PlaythroughsGameSettingsViewModel( + gh<_i32.BoardGamesStore>(), + gh<_i36.GamePlaythroughsDetailsStore>(), )); - gh.factory<_i41.PlaythroughsHistoryViewModel>(() => - _i41.PlaythroughsHistoryViewModel( - gh<_i35.GamePlaythroughsDetailsStore>())); - gh.factory<_i42.PlaythroughsLogGameViewModel>( - () => _i42.PlaythroughsLogGameViewModel( + gh.factory<_i42.PlaythroughsHistoryViewModel>(() => + _i42.PlaythroughsHistoryViewModel( + gh<_i36.GamePlaythroughsDetailsStore>())); + gh.factory<_i43.PlaythroughsLogGameViewModel>( + () => _i43.PlaythroughsLogGameViewModel( gh<_i10.PlayersStore>(), - gh<_i35.GamePlaythroughsDetailsStore>(), + gh<_i36.GamePlaythroughsDetailsStore>(), gh<_i22.AnalyticsService>(), )); - gh.factory<_i43.PlaythroughsViewModel>(() => _i43.PlaythroughsViewModel( - gh<_i35.GamePlaythroughsDetailsStore>(), + gh.factory<_i44.PlaythroughsViewModel>(() => _i44.PlaythroughsViewModel( + gh<_i36.GamePlaythroughsDetailsStore>(), gh<_i10.PlayersStore>(), gh<_i22.AnalyticsService>(), - gh<_i26.BoardGamesService>(), + gh<_i27.BoardGamesService>(), gh<_i21.UserStore>(), )); - gh.singleton<_i44.SettingsViewModel>(_i44.SettingsViewModel( - gh<_i7.FileService>(), - gh<_i26.BoardGamesService>(), - gh<_i4.BoardGamesFiltersService>(), + gh.singleton<_i45.SettingsViewModel>(_i45.SettingsViewModel( + gh<_i6.FileService>(), + gh<_i27.BoardGamesService>(), + gh<_i23.BoardGamesFiltersService>(), gh<_i9.PlayerService>(), gh<_i20.UserService>(), - gh<_i28.PlaythroughService>(), + gh<_i29.PlaythroughService>(), gh<_i16.ScoreService>(), gh<_i14.PreferencesService>(), gh<_i3.AppStore>(), gh<_i21.UserStore>(), - gh<_i31.BoardGamesStore>(), + gh<_i32.BoardGamesStore>(), )); - gh.factory<_i45.BoardGameDetailsViewModel>( - () => _i45.BoardGameDetailsViewModel( - gh<_i31.BoardGamesStore>(), + gh.factory<_i46.BoardGameDetailsViewModel>( + () => _i46.BoardGameDetailsViewModel( + gh<_i32.BoardGamesStore>(), gh<_i22.AnalyticsService>(), )); - gh.factory<_i46.EditPlaythoughViewModel>(() => - _i46.EditPlaythoughViewModel(gh<_i35.GamePlaythroughsDetailsStore>())); - gh.factory<_i47.HomeViewModel>(() => _i47.HomeViewModel( + gh.factory<_i47.EditPlaythoughViewModel>(() => + _i47.EditPlaythoughViewModel(gh<_i36.GamePlaythroughsDetailsStore>())); + gh.factory<_i48.HomeViewModel>(() => _i48.HomeViewModel( gh<_i22.AnalyticsService>(), gh<_i15.RateAndReviewService>(), gh<_i11.PlayersViewModel>(), - gh<_i23.BoardGamesFiltersStore>(), - gh<_i33.CollectionsViewModel>(), - gh<_i36.HotBoardGamesViewModel>(), - gh<_i37.PlaysViewModel>(), + gh<_i24.BoardGamesFiltersStore>(), + gh<_i34.CollectionsViewModel>(), + gh<_i37.HotBoardGamesViewModel>(), + gh<_i38.PlaysViewModel>(), gh<_i3.AppStore>(), gh<_i19.SearchStore>(), - gh<_i31.BoardGamesStore>(), - gh<_i25.BoardGamesSearchService>(), + gh<_i32.BoardGamesStore>(), + gh<_i26.BoardGamesSearchService>(), )); return getIt; } -class _$RegisterModule extends _i48.RegisterModule {} +class _$RegisterModule extends _i49.RegisterModule {} diff --git a/board_games_companion/lib/main.dart b/board_games_companion/lib/main.dart index 9d1803c9..c9c5ab82 100644 --- a/board_games_companion/lib/main.dart +++ b/board_games_companion/lib/main.dart @@ -36,7 +36,9 @@ import 'models/sort_by.dart'; import 'services/preferences_service.dart'; Future main() async { - Fimber.plantTree(DebugTree()); + Fimber.plantTree(DebugTree( + useColors: true, + )); runZonedGuarded(() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/board_games_companion/lib/models/playthroughs/playthrough_details.dart b/board_games_companion/lib/models/playthroughs/playthrough_details.dart index 51003889..13cfc2ce 100644 --- a/board_games_companion/lib/models/playthroughs/playthrough_details.dart +++ b/board_games_companion/lib/models/playthroughs/playthrough_details.dart @@ -22,7 +22,7 @@ class PlaythroughDetails with _$PlaythroughDetails { const PlaythroughDetails._(); - int? get daysSinceStart => DateTime.now().toUtc().difference(playthrough.startDate).inDays; + int get daysSinceStart => DateTime.now().toUtc().difference(playthrough.startDate).inDays; Duration get duration { final nowUtc = DateTime.now().toUtc(); diff --git a/board_games_companion/lib/pages/playthroughs/playthroughs_history_page.dart b/board_games_companion/lib/pages/playthroughs/playthroughs_history_page.dart index c040cce1..efff3c10 100644 --- a/board_games_companion/lib/pages/playthroughs/playthroughs_history_page.dart +++ b/board_games_companion/lib/pages/playthroughs/playthroughs_history_page.dart @@ -348,13 +348,18 @@ class _PlaythroughGameStats extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ CalendarCard(playthroughDetails.startDate), + if (playthroughDetails.daysSinceStart == 0) + const Text(AppText.playthroughsHistoryPageToday), + if (playthroughDetails.daysSinceStart == 1) + const Text(AppText.playthroughsHistoryPageYesterday), + if (playthroughDetails.daysSinceStart > 1) + _PlaythroughItemDetail( + playthroughDetails.daysSinceStart.toString(), + AppText.playthroughsHistoryPageDaysAgo, + ), _PlaythroughItemDetail( - playthroughDetails.daysSinceStart?.toString(), - 'day(s) ago', - ), - _PlaythroughItemDetail( - '$playthroughNumber${playthroughNumber.toOrdinalAbbreviations()}', - 'game', + '$playthroughNumber${playthroughNumber.toOrdinalAbbreviation()}', + AppText.playthroughsHistoryPageGameNumberSubtitle, ), _PlaythroughDuration(playthroughDetails: playthroughDetails), ], diff --git a/board_games_companion/lib/pages/playthroughs/playthroughs_page.dart b/board_games_companion/lib/pages/playthroughs/playthroughs_page.dart index 6cb154d6..06d3f40c 100644 --- a/board_games_companion/lib/pages/playthroughs/playthroughs_page.dart +++ b/board_games_companion/lib/pages/playthroughs/playthroughs_page.dart @@ -200,9 +200,11 @@ class PlaythroughsPageState extends BasePageState } catch (e, stack) { FirebaseCrashlytics.instance.recordError(e, stack); } finally { - setState(() { - _showImportGamesLoadingIndicator = false; - }); + if (mounted) { + setState(() { + _showImportGamesLoadingIndicator = false; + }); + } } } diff --git a/board_games_companion/lib/services/analytics_service.dart b/board_games_companion/lib/services/analytics_service.dart index b133851e..04dd5fcf 100644 --- a/board_games_companion/lib/services/analytics_service.dart +++ b/board_games_companion/lib/services/analytics_service.dart @@ -1,3 +1,4 @@ +import 'package:fimber/fimber.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:injectable/injectable.dart'; @@ -14,6 +15,7 @@ class AnalyticsService { required String name, Map? parameters, }) async { + Fimber.i('Captured an $name event with $parameters'); await _firebaseAnalytics.logEvent(name: name, parameters: parameters); await _rateAndReviewService.increaseNumberOfSignificantActions(); } diff --git a/board_games_companion/lib/services/board_games_filters_service.dart b/board_games_companion/lib/services/board_games_filters_service.dart index 6147079d..c8e9e667 100644 --- a/board_games_companion/lib/services/board_games_filters_service.dart +++ b/board_games_companion/lib/services/board_games_filters_service.dart @@ -6,6 +6,8 @@ import 'hive_base_service.dart'; @singleton class BoardGamesFiltersService extends BaseHiveService { + BoardGamesFiltersService(super._hive); + static const String _collectionFiltersPreferenceKey = 'collectionFilters'; Future retrieveCollectionFiltersPreferences() async { diff --git a/board_games_companion/lib/services/board_games_service.dart b/board_games_companion/lib/services/board_games_service.dart index de40842e..fc9a5e62 100644 --- a/board_games_companion/lib/services/board_games_service.dart +++ b/board_games_companion/lib/services/board_games_service.dart @@ -10,7 +10,7 @@ import 'hive_base_service.dart'; @singleton class BoardGamesService extends BaseHiveService { - BoardGamesService(this._boardGameGeekService); + BoardGamesService(super.hive, this._boardGameGeekService); final BoardGamesGeekService _boardGameGeekService; diff --git a/board_games_companion/lib/services/hive_base_service.dart b/board_games_companion/lib/services/hive_base_service.dart index 93dbb6f5..4caddf72 100644 --- a/board_games_companion/lib/services/hive_base_service.dart +++ b/board_games_companion/lib/services/hive_base_service.dart @@ -6,6 +6,10 @@ import 'package:uuid/uuid.dart'; import '../common/hive_boxes.dart'; abstract class BaseHiveService { + BaseHiveService(this._hive); + + final HiveInterface _hive; + @protected final uuid = const Uuid(); @@ -13,14 +17,14 @@ abstract class BaseHiveService { String get _boxName => HiveBoxes.boxesNamesMap[TService] ?? ''; - bool get _isBoxOpen => Hive.isBoxOpen(_boxName); + bool get _isBoxOpen => _hive.isBoxOpen(_boxName); void closeBox() { if (_boxName.isNullOrBlank || !_isBoxOpen) { return; } - Hive.box(_boxName).close(); + _hive.box(_boxName).close(); } Future ensureBoxOpen() async { @@ -29,7 +33,7 @@ abstract class BaseHiveService { } if (!_isBoxOpen) { - storageBox = await Hive.openBox(_boxName); + storageBox = await _hive.openBox(_boxName); } return storageBox != null; diff --git a/board_games_companion/lib/services/injectable_register_module.dart b/board_games_companion/lib/services/injectable_register_module.dart index fcce7ee9..0e11c959 100644 --- a/board_games_companion/lib/services/injectable_register_module.dart +++ b/board_games_companion/lib/services/injectable_register_module.dart @@ -1,4 +1,5 @@ import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:hive/hive.dart'; import 'package:injectable/injectable.dart'; @module @@ -6,6 +7,9 @@ abstract class RegisterModule { @singleton FirebaseAnalytics get firebaseAnalytics => FirebaseAnalytics.instance; + @injectable + HiveInterface get hive => Hive; + @singleton FirebaseAnalyticsObserver get firebaseAnalyticsObserver => FirebaseAnalyticsObserver(analytics: firebaseAnalytics); diff --git a/board_games_companion/lib/services/player_service.dart b/board_games_companion/lib/services/player_service.dart index 9fabe70a..f02a197c 100644 --- a/board_games_companion/lib/services/player_service.dart +++ b/board_games_companion/lib/services/player_service.dart @@ -11,7 +11,7 @@ import 'hive_base_service.dart'; @singleton class PlayerService extends BaseHiveService { - PlayerService(this.fileService); + PlayerService(super.hive, this.fileService); final FileService fileService; diff --git a/board_games_companion/lib/services/playthroughs_service.dart b/board_games_companion/lib/services/playthroughs_service.dart index 7d5d418b..60b5c999 100644 --- a/board_games_companion/lib/services/playthroughs_service.dart +++ b/board_games_companion/lib/services/playthroughs_service.dart @@ -1,3 +1,4 @@ +import 'package:fimber/fimber.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:injectable/injectable.dart'; @@ -11,7 +12,7 @@ import 'score_service.dart'; @singleton class PlaythroughService extends BaseHiveService { - PlaythroughService(this.scoreService); + PlaythroughService(super.hive, this.scoreService); final ScoreService scoreService; @@ -57,6 +58,7 @@ class PlaythroughService extends BaseHiveService? notes, }) async { + Fimber.d('Creating a Playthrough...'); if ((boardGameId.isEmpty) || (playerIds.isEmpty)) { return null; } @@ -85,9 +87,10 @@ class PlaythroughService extends BaseHiveService { + PreferencesService(super.hive); + static const String _firstTimeAppLaunchDateKey = 'firstTimeLaunchDate'; static const String _appLaunchDateKey = 'applaunchDate'; static const String _remindMeLaterDateKey = 'remindMeLater'; diff --git a/board_games_companion/lib/services/score_service.dart b/board_games_companion/lib/services/score_service.dart index 1ec4cbbc..50e6899f 100644 --- a/board_games_companion/lib/services/score_service.dart +++ b/board_games_companion/lib/services/score_service.dart @@ -1,4 +1,5 @@ import 'package:basics/basics.dart'; +import 'package:fimber/fimber.dart'; import 'package:injectable/injectable.dart'; import '../models/hive/score.dart'; @@ -6,10 +7,14 @@ import 'hive_base_service.dart'; @singleton class ScoreService extends BaseHiveService { + ScoreService(super.hive); + Future addOrUpdateScore(Score score) async { + Fimber.d('Saving a score $score'); if ((score.playthroughId?.isEmpty ?? true) || (score.playerId.isEmpty) || (score.boardGameId.isEmpty)) { + Fimber.e('Score is missing required properties to be saved.'); return false; } diff --git a/board_games_companion/lib/services/search_service.dart b/board_games_companion/lib/services/search_service.dart index 7321e3a4..62f4c634 100644 --- a/board_games_companion/lib/services/search_service.dart +++ b/board_games_companion/lib/services/search_service.dart @@ -6,6 +6,8 @@ import 'hive_base_service.dart'; @singleton class SearchService extends BaseHiveService { + SearchService(super.hive); + Future addOrUpdateScore(SearchHistoryEntry searchHistoryEntry) async { if (!await ensureBoxOpen()) { return false; diff --git a/board_games_companion/lib/services/user_service.dart b/board_games_companion/lib/services/user_service.dart index 7203241c..d4015c7e 100644 --- a/board_games_companion/lib/services/user_service.dart +++ b/board_games_companion/lib/services/user_service.dart @@ -5,6 +5,8 @@ import 'hive_base_service.dart'; @singleton class UserService extends BaseHiveService { + UserService(super.hive); + Future retrieveUser() async { if (!await ensureBoxOpen()) { return null; diff --git a/board_games_companion/lib/stores/game_playthroughs_details_store.dart b/board_games_companion/lib/stores/game_playthroughs_details_store.dart index 7420d1e6..4e1817c3 100644 --- a/board_games_companion/lib/stores/game_playthroughs_details_store.dart +++ b/board_games_companion/lib/stores/game_playthroughs_details_store.dart @@ -1,6 +1,7 @@ // ignore_for_file: library_private_types_in_public_api import 'package:collection/collection.dart'; +import 'package:fimber/fimber.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:injectable/injectable.dart'; import 'package:mobx/mobx.dart'; @@ -122,6 +123,7 @@ abstract class _GamePlaythroughsDetailsStore with Store { return null; } + Fimber.i('Mapping Playthrough to PlaythroughDetails'); final newPlaythroughDetails = createPlaythroughDetails(newPlaythrough); playthroughsDetails.add(newPlaythroughDetails); return newPlaythroughDetails; diff --git a/board_games_companion/lib/stores/scores_store.dart b/board_games_companion/lib/stores/scores_store.dart index a9ed636d..1ab4e7ad 100644 --- a/board_games_companion/lib/stores/scores_store.dart +++ b/board_games_companion/lib/stores/scores_store.dart @@ -1,5 +1,6 @@ // ignore_for_file: library_private_types_in_public_api +import 'package:fimber/fimber.dart'; import 'package:injectable/injectable.dart'; import 'package:mobx/mobx.dart'; @@ -24,9 +25,12 @@ abstract class _ScoresStore with Store { } Future refreshScores(String playthroughId) async { + Fimber.d('Refreshing $playthroughId scores'); final playthroughScores = await _scoreService.retrieveScoresForPlaythrough(playthroughId); + // Using for loops because the score gets updated while the loop executes // ignore: prefer_foreach for (final score in playthroughScores) { + Fimber.d('Updating $score'); _addOrUpdateScore(score); } } diff --git a/board_games_companion/lib/utilities/analytics_route_observer.dart b/board_games_companion/lib/utilities/analytics_route_observer.dart index 744ae751..5dd622e1 100644 --- a/board_games_companion/lib/utilities/analytics_route_observer.dart +++ b/board_games_companion/lib/utilities/analytics_route_observer.dart @@ -52,9 +52,9 @@ class AnalyticsRouteObserver extends RouteObserver> { Fimber.d( 'Pushed a route $routeName. Current page is ${route.toScreenName()} with backing class ${route.toScreenClassName()}', ); - Fimber.d( - 'Pushed route arguments ${route.settings.arguments}', - ); + if (route.settings.arguments != null) { + Fimber.d('Pushed route arguments ${route.settings.arguments}'); + } switch (routeName) { case BoardGamesDetailsPage.pageRoute: diff --git a/board_games_companion/pubspec.lock b/board_games_companion/pubspec.lock index 7684fa15..798dd084 100644 --- a/board_games_companion/pubspec.lock +++ b/board_games_companion/pubspec.lock @@ -241,6 +241,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.0" + coverage: + dependency: transitive + description: + name: coverage + sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097" + url: "https://pub.dev" + source: hosted + version: "1.6.3" cross_file: dependency: transitive description: @@ -888,6 +896,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + mocktail: + dependency: "direct main" + description: + name: mocktail + sha256: "80a996cd9a69284b3dc521ce185ffe9150cde69767c2d3a0720147d93c0cef53" + url: "https://pub.dev" + source: hosted + version: "0.3.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" numberpicker: dependency: "direct main" description: @@ -1152,6 +1176,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" shelf_web_socket: dependency: transitive description: @@ -1197,6 +1237,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" source_span: dependency: transitive description: @@ -1277,6 +1333,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + test: + dependency: transitive + description: + name: test + sha256: a5fcd2d25eeadbb6589e80198a47d6a464ba3e2049da473943b8af9797900c2d + url: "https://pub.dev" + source: hosted + version: "1.22.0" test_api: dependency: transitive description: @@ -1285,6 +1349,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.16" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0ef9755ec6d746951ba0aabe62f874b707690b5ede0fecc818b138fcc9b14888" + url: "https://pub.dev" + source: hosted + version: "0.4.20" timing: dependency: transitive description: @@ -1389,6 +1461,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: e7fb6c2282f7631712b69c19d1bff82f3767eea33a2321c14fa59ad67ea391c7 + url: "https://pub.dev" + source: hosted + version: "9.4.0" watcher: dependency: transitive description: @@ -1405,6 +1485,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "67d3a8b6c79e1987d19d848b0892e582dbb0c66c57cc1fef58a177dd2aa2823d" + url: "https://pub.dev" + source: hosted + version: "1.2.0" win32: dependency: transitive description: diff --git a/board_games_companion/pubspec.yaml b/board_games_companion/pubspec.yaml index ef4fcf00..67a1de9d 100644 --- a/board_games_companion/pubspec.yaml +++ b/board_games_companion/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: image_picker: ^0.8.4+2 logger: ^1.1.0 mobx: ^2.0.7+4 + mocktail: ^0.3.0 numberpicker: ^2.1.1 path: ^1.8.0 path_provider: ^2.0.5 diff --git a/board_games_companion/test/mocks/hive_interface_mock.dart b/board_games_companion/test/mocks/hive_interface_mock.dart new file mode 100644 index 00000000..880beafd --- /dev/null +++ b/board_games_companion/test/mocks/hive_interface_mock.dart @@ -0,0 +1,4 @@ +import 'package:hive/hive.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockHiveInterface extends Mock implements HiveInterface {} diff --git a/board_games_companion/test/mocks/playthrough_hive_box_mock.dart b/board_games_companion/test/mocks/playthrough_hive_box_mock.dart new file mode 100644 index 00000000..0e8f1d39 --- /dev/null +++ b/board_games_companion/test/mocks/playthrough_hive_box_mock.dart @@ -0,0 +1,5 @@ +import 'package:board_games_companion/models/hive/playthrough.dart'; +import 'package:hive/hive.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockPlaythroughHiveBox extends Mock implements Box {} diff --git a/board_games_companion/test/mocks/score_hive_box_mock.dart b/board_games_companion/test/mocks/score_hive_box_mock.dart new file mode 100644 index 00000000..52465dfa --- /dev/null +++ b/board_games_companion/test/mocks/score_hive_box_mock.dart @@ -0,0 +1,5 @@ +import 'package:board_games_companion/models/hive/score.dart'; +import 'package:hive/hive.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockScoreHiveBox extends Mock implements Box {} diff --git a/board_games_companion/test/mocks/score_service_mock.dart b/board_games_companion/test/mocks/score_service_mock.dart new file mode 100644 index 00000000..38aaa1f1 --- /dev/null +++ b/board_games_companion/test/mocks/score_service_mock.dart @@ -0,0 +1,4 @@ +import 'package:board_games_companion/services/score_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockScoreService extends Mock implements ScoreService {} diff --git a/board_games_companion/test/services/playthrough_service_test.dart b/board_games_companion/test/services/playthrough_service_test.dart new file mode 100644 index 00000000..c9da0560 --- /dev/null +++ b/board_games_companion/test/services/playthrough_service_test.dart @@ -0,0 +1,159 @@ +import 'package:board_games_companion/common/enums/playthrough_status.dart'; +import 'package:board_games_companion/models/hive/player.dart'; +import 'package:board_games_companion/models/hive/playthrough.dart'; +import 'package:board_games_companion/models/hive/score.dart'; +import 'package:board_games_companion/models/player_score.dart'; +import 'package:board_games_companion/services/playthroughs_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../mocks/hive_interface_mock.dart'; +import '../mocks/playthrough_hive_box_mock.dart'; +import '../mocks/score_service_mock.dart'; + +void main() { + late MockHiveInterface mockHiveInterface; + late MockPlaythroughHiveBox mockPlaythroughHiveBox; + late MockScoreService mockScoreService; + + late PlaythroughService playthroughService; + + final samplePlaythrough = Playthrough( + id: '123', + boardGameId: '987', + playerIds: [], + scoreIds: [], + startDate: DateTime.now(), + ); + + const sampleScore = Score( + boardGameId: '', + id: '', + playerId: '', + ); + + setUp(() { + mockScoreService = MockScoreService(); + when(() => mockScoreService.addOrUpdateScore(any())).thenAnswer((_) => Future.value(true)); + mockPlaythroughHiveBox = MockPlaythroughHiveBox(); + when(() => mockPlaythroughHiveBox.put(any(), any())).thenAnswer((_) => Future.value()); + mockHiveInterface = MockHiveInterface(); + when(() => mockHiveInterface.isBoxOpen(any())).thenAnswer((_) => false); + when(() => mockHiveInterface.openBox(any())) + .thenAnswer((_) async => mockPlaythroughHiveBox); + + playthroughService = PlaythroughService(mockHiveInterface, mockScoreService); + }); + + setUpAll(() { + // MK Required fallback of a dummy objects when mocktail needs to return a model of such type + registerFallbackValue(samplePlaythrough); + registerFallbackValue(sampleScore); + }); + + tearDown(() { + reset(mockPlaythroughHiveBox); + reset(mockHiveInterface); + reset(mockScoreService); + }); + + group('Create playthrough ', () { + test( + 'GIVEN playthrough service ' + 'WHEN creating a playthrough ' + 'AND board game id is empty ' + 'THEN the playthrough does not get created ', () async { + const boardGameId = ''; + final createdPlaythrough = await playthroughService.createPlaythrough( + boardGameId, + [], + {}, + DateTime.now(), + null, + ); + + expect(createdPlaythrough, isNull); + verifyNever(() => mockPlaythroughHiveBox.put(any(), any())); + }); + + test( + 'GIVEN playthrough service ' + 'WHEN creating a playthrough ' + 'AND duration is not provided ' + 'THEN the playthrough should be in Started status ', () async { + const boardGameId = '123'; + const playerIds = ['434']; + const Duration? duration = null; + final createdPlaythrough = await playthroughService.createPlaythrough( + boardGameId, + playerIds, + {}, + DateTime.now(), + duration, + ); + + expect(createdPlaythrough!.status, PlaythroughStatus.Started); + }); + + test( + 'GIVEN playthrough service ' + 'WHEN creating a playthrough ' + 'AND duration is provided ' + 'THEN the playthrough should be in Finished status ', () async { + const boardGameId = '123'; + const playerIds = ['434']; + const duration = Duration(seconds: 100); + final createdPlaythrough = await playthroughService.createPlaythrough( + boardGameId, + playerIds, + {}, + DateTime.now(), + duration, + ); + + expect(createdPlaythrough!.status, PlaythroughStatus.Finished); + }); + + test( + 'GIVEN playthrough service ' + 'WHEN creating a playthrough ' + 'AND player scores are provided ' + 'THEN the playthrough should be saved with the player scores ', () async { + const boardGameId = '123'; + const playerIds = ['434', '564']; + final playerScores = { + playerIds[0]: PlayerScore( + player: Player(id: playerIds[0]), + score: Score( + id: '12938', + boardGameId: boardGameId, + playerId: playerIds[0], + ), + ), + playerIds[1]: PlayerScore( + player: Player(id: playerIds[1]), + score: Score( + id: '98223', + boardGameId: boardGameId, + playerId: playerIds[1], + ), + ), + }; + const duration = Duration(seconds: 100); + final createdPlaythrough = await playthroughService.createPlaythrough( + boardGameId, + playerIds, + playerScores, + DateTime.now(), + duration, + ); + + verify(() => mockScoreService.addOrUpdateScore(any())).called(playerIds.length); + expect(createdPlaythrough!.playerIds, playerIds); + expect( + createdPlaythrough.scoreIds, + playerScores.values.map((playerScore) => playerScore.score.id), + ); + }); + }); +} diff --git a/board_games_companion/test/services/score_service_test.dart b/board_games_companion/test/services/score_service_test.dart new file mode 100644 index 00000000..44f79b06 --- /dev/null +++ b/board_games_companion/test/services/score_service_test.dart @@ -0,0 +1,77 @@ +import 'package:board_games_companion/models/hive/score.dart'; +import 'package:board_games_companion/services/score_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../mocks/hive_interface_mock.dart'; +import '../mocks/score_hive_box_mock.dart'; + +void main() { + late MockHiveInterface mockHiveInterface; + late MockScoreHiveBox mockScoreHiveBox; + + late ScoreService scoreService; + + const sampleScore = Score( + boardGameId: '', + id: '', + playerId: '', + ); + + setUp(() { + mockScoreHiveBox = MockScoreHiveBox(); + when(() => mockScoreHiveBox.put(any(), any())).thenAnswer((_) => Future.value()); + mockHiveInterface = MockHiveInterface(); + when(() => mockHiveInterface.isBoxOpen(any())).thenAnswer((_) => false); + when(() => mockHiveInterface.openBox(any())).thenAnswer((_) async => mockScoreHiveBox); + + scoreService = ScoreService(mockHiveInterface); + }); + + setUpAll(() { + // MK Required fallback of a dummy score when mocktail needs to return a model of such type + registerFallbackValue(sampleScore); + }); + + tearDown(() { + reset(mockHiveInterface); + reset(mockScoreHiveBox); + }); + + group('Saving score ', () { + void verifyScore(Score score, bool expectedResult) { + test( + 'GIVEN a score $score ' + 'WHEN saving it ' + 'THEN saving should ${expectedResult ? "succeed" : "fail"} ', () async { + final createResult = await scoreService.addOrUpdateScore(score); + expect(createResult, expectedResult); + }); + } + + verifyScore(sampleScore, false); + verifyScore(sampleScore.copyWith(id: '123'), false); + verifyScore(sampleScore.copyWith(id: '123', boardGameId: '321'), false); + verifyScore(sampleScore.copyWith(id: '123', boardGameId: '321', playerId: '834'), false); + verifyScore( + sampleScore.copyWith(id: '123', boardGameId: '321', playerId: '834', playthroughId: '123'), + true, + ); + + test( + 'GIVEN a score ' + 'WHEN saving it ' + 'THEN the score should be put in the hive box ', () async { + final validScore = sampleScore.copyWith( + id: '123', + boardGameId: '321', + playerId: '834', + playthroughId: '123', + ); + + await scoreService.addOrUpdateScore(validScore); + + verify(() => mockScoreHiveBox.put(validScore.id, validScore)).called(1); + }); + }); +} diff --git a/board_games_companion/test/widget_test.dart b/board_games_companion/test/widget_test.dart deleted file mode 100644 index 570e0e47..00000000 --- a/board_games_companion/test/widget_test.dart +++ /dev/null @@ -1,8 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -void main() {} diff --git a/cloud_infrastructure/terraform/dev/main.tf b/cloud_infrastructure/terraform/dev/main.tf index d47b68af..3356562e 100644 --- a/cloud_infrastructure/terraform/dev/main.tf +++ b/cloud_infrastructure/terraform/dev/main.tf @@ -38,7 +38,8 @@ variable "resources" { sku = string }) queue = object({ - name = string + name = string + send_policy_name = string }) }) cache_function = object({ @@ -48,6 +49,10 @@ variable "resources" { name = string }) }) + key_vault = object({ + name = string + sku = string + }) }) nullable = false } @@ -69,11 +74,17 @@ provider "azurerm" { features {} } +data "azurerm_client_config" "current" {} + resource "azurerm_resource_group" "rg" { name = var.resource_group.name location = var.resource_group.location } +### +### Storage +### + resource "azurerm_storage_account" "sa" { name = var.resources.storage_account.name resource_group_name = azurerm_resource_group.rg.name @@ -82,6 +93,10 @@ resource "azurerm_storage_account" "sa" { account_replication_type = "LRS" } +### +### Logs +### + resource "azurerm_log_analytics_workspace" "log" { name = var.resources.analytics_workspace.name resource_group_name = azurerm_resource_group.rg.name @@ -90,13 +105,6 @@ resource "azurerm_log_analytics_workspace" "log" { retention_in_days = var.resources.analytics_workspace.retention_in_days } -resource "azurerm_container_app_environment" "cae" { - name = var.resources.container_app_environemnt.name - resource_group_name = azurerm_resource_group.rg.name - location = var.resources.container_app_environemnt.location - log_analytics_workspace_id = azurerm_log_analytics_workspace.log.id -} - resource "azurerm_application_insights" "search_service_appi" { name = var.resources.application_insights.search_service.name resource_group_name = azurerm_resource_group.rg.name @@ -105,6 +113,17 @@ resource "azurerm_application_insights" "search_service_appi" { application_type = "web" } +### +### Container Apps +### + +resource "azurerm_container_app_environment" "cae" { + name = var.resources.container_app_environemnt.name + resource_group_name = azurerm_resource_group.rg.name + location = var.resources.container_app_environemnt.location + log_analytics_workspace_id = azurerm_log_analytics_workspace.log.id +} + resource "azurerm_container_app" "search_service_ca" { name = var.resources.container_apps.search_service.name container_app_environment_id = azurerm_container_app_environment.cae.id @@ -120,6 +139,10 @@ resource "azurerm_container_app" "search_service_ca" { } } + identity { + type = "SystemAssigned" + } + ingress { external_enabled = true target_port = 80 @@ -145,7 +168,9 @@ resource "azurerm_container_app" "search_service_ca" { } } - +### +### Service Bus +### resource "azurerm_servicebus_namespace" "sbns" { name = var.resources.cache_service_bus.namespace.name resource_group_name = azurerm_resource_group.rg.name @@ -158,6 +183,17 @@ resource "azurerm_servicebus_queue" "sbq" { namespace_id = azurerm_servicebus_namespace.sbns.id } +resource "azurerm_servicebus_queue_authorization_rule" "sbq_send_policy" { + name = var.resources.cache_service_bus.queue.send_policy_name + queue_id = azurerm_servicebus_queue.sbq.id + + listen = false + send = true + manage = false +} + +### Functions + resource "azurerm_service_plan" "asp" { name = var.resources.cache_function.service_plan.name resource_group_name = azurerm_resource_group.rg.name @@ -166,6 +202,7 @@ resource "azurerm_service_plan" "asp" { sku_name = "Y1" } + resource "azurerm_linux_function_app" "func" { name = var.resources.cache_function.name resource_group_name = azurerm_resource_group.rg.name @@ -183,3 +220,65 @@ resource "azurerm_linux_function_app" "func" { } } +### +### Key Vault +### + +resource "azurerm_key_vault" "kv" { + name = var.resources.key_vault.name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + tenant_id = data.azurerm_client_config.current.tenant_id + soft_delete_retention_days = 7 + purge_protection_enabled = false + + sku_name = var.resources.key_vault.sku + + access_policy { + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = data.azurerm_client_config.current.object_id + + key_permissions = [ + "Create", + "Get", + ] + + secret_permissions = [ + "Get", + "List", + "Delete", + "Recover", + "Backup", + "Restore", + "Set", + ] + } + + access_policy { + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = azurerm_container_app.search_service_ca.identity.0.principal_id + + secret_permissions = [ + "Get", + ] + } +} + +resource "azurerm_key_vault_secret" "kv_queue_send_connection_string" { + name = "AppSettings--CacheSettings--SendConnectionString" + value = azurerm_servicebus_queue_authorization_rule.sbq_send_policy.primary_connection_string + key_vault_id = azurerm_key_vault.kv.id +} + +resource "azurerm_key_vault_secret" "kv_queue_send_name" { + name = "AppSettings--CacheSettings--QueueName" + value = var.resources.cache_service_bus.queue.send_policy_name + key_vault_id = azurerm_key_vault.kv.id +} + +resource "azurerm_key_vault_secret" "kv_application_insights_connection_string" { + name = "ApplicationInsights--ConnectionString" + value = azurerm_application_insights.search_service_appi.connection_string + key_vault_id = azurerm_key_vault.kv.id +} + diff --git a/cloud_infrastructure/terraform/dev/terraform.tfvars.json b/cloud_infrastructure/terraform/dev/terraform.tfvars.json index 6dc0f428..0cc63893 100644 --- a/cloud_infrastructure/terraform/dev/terraform.tfvars.json +++ b/cloud_infrastructure/terraform/dev/terraform.tfvars.json @@ -36,7 +36,8 @@ "sku": "Basic" }, "queue": { - "name": "bgc-cache-service-bus-dev-sbq" + "name": "bgc-cache-service-bus-dev-sbq", + "send_policy_name": "bgc-cache-service-bus-dev-send-policy" } }, "cache_function": { @@ -45,6 +46,10 @@ "service_plan": { "name": "bgc-search-queue-function-dev-asp" } + }, + "key_vault": { + "name": "bgc-dev-kv", + "sku": "standard" } } } \ No newline at end of file diff --git a/cloud_infrastructure/terraform/prod/main.tf b/cloud_infrastructure/terraform/prod/main.tf index b66903cf..d653527e 100644 --- a/cloud_infrastructure/terraform/prod/main.tf +++ b/cloud_infrastructure/terraform/prod/main.tf @@ -160,6 +160,15 @@ resource "azurerm_servicebus_queue" "sbq" { namespace_id = azurerm_servicebus_namespace.sbns.id } +resource "azurerm_servicebus_queue_authorization_rule" "sbqsendpolicy" { + name = var.resources.cache_service_bus.queue.send_policy_name + queue_id = azurerm_servicebus_queue.sbq.id + + listen = false + send = true + manage = false +} + resource "azurerm_service_plan" "asp" { name = var.resources.cache_function.service_plan.name resource_group_name = azurerm_resource_group.rg.name diff --git a/cloud_infrastructure/terraform/prod/terraform.tfvars.json b/cloud_infrastructure/terraform/prod/terraform.tfvars.json index 4b555969..70c43fab 100644 --- a/cloud_infrastructure/terraform/prod/terraform.tfvars.json +++ b/cloud_infrastructure/terraform/prod/terraform.tfvars.json @@ -36,7 +36,8 @@ "sku": "Basic" }, "queue": { - "name": "bgc-cache-service-bus-prod-sbq" + "name": "bgc-cache-service-bus-prod-sbq", + "send_policy_name": "bgc-cache-service-bus-prod-send-policy" } }, "cache_function": { diff --git a/pipelines/mobile_app/build-pipeline.yml b/pipelines/mobile_app/build-pipeline.yml index 28a7272d..fbd89390 100644 --- a/pipelines/mobile_app/build-pipeline.yml +++ b/pipelines/mobile_app/build-pipeline.yml @@ -15,9 +15,63 @@ variables: value: board_games_companion - group: BGC-GLOBAL +parameters: + - name: runTests + displayName: Run tests + type: boolean + default: true + - name: buildIOS + displayName: Build iOS + type: boolean + default: true + - name: buildAndroid + displayName: Build Android + type: boolean + default: true + jobs: - - job: Build - timeoutInMinutes: 20 + - job: Test + timeoutInMinutes: 10 + condition: eq('${{ parameters.runTests }}', 'true') + pool: + vmImage: "macOS-latest" + + steps: + - task: FlutterInstall@0 + displayName: "Install Flutter" + inputs: + mode: "auto" + channel: "stable" + version: "custom" + customVersion: $(flutter.version) + + - task: FlutterTest@0 + displayName: "Run tests" + inputs: + projectDirectory: "$(Build.SourcesDirectory)/$(appDirectoryName)" + generateCodeCoverageReport: true + + - task: reportgenerator@5 + displayName: "Generate coverage report" + inputs: + reports: "$(Build.SourcesDirectory)/$(appDirectoryName)/coverage/lcov.info" + targetdir: "$(Build.SourcesDirectory)/$(appDirectoryName)/coverage/" + sourcedirs: "$(Build.SourcesDirectory)/$(appDirectoryName)" + reporttypes: "HtmlInline_AzurePipelines;Cobertura" + # Exclude all generated files from the code coverage + filefilters: "-lib/**/*.g.dart" + tag: "$(app.version).$(Build.BuildID)" + + - task: PublishCodeCoverageResults@2 + displayName: "Publish code coverage results" + inputs: + summaryFileLocation: "$(Build.SourcesDirectory)/$(appDirectoryName)/coverage/Cobertura.xml" + pathToSources: "$(Build.SourcesDirectory)/$(appDirectoryName)" + + - job: BuildDroid + dependsOn: Test + condition: eq('${{ parameters.buildAndroid }}', 'true') + timeoutInMinutes: 10 pool: vmImage: "macOS-latest" @@ -46,6 +100,22 @@ jobs: buildNumber: "$(Build.BuildID)" buildName: "$(app.version).$(Build.BuildID)" + - job: BuildIOS + dependsOn: Test + condition: eq('${{ parameters.buildIOS }}', 'true') + timeoutInMinutes: 10 + pool: + vmImage: "macOS-latest" + + steps: + - task: FlutterInstall@0 + displayName: "Install Flutter" + inputs: + mode: "auto" + channel: "stable" + version: "custom" + customVersion: $(flutter.version) + - task: FlutterBuild@0 displayName: "Build iOS" inputs: