From 2eaa6a906734d99196c320830005c6f3b22d6a6d Mon Sep 17 00:00:00 2001 From: Dmitry Date: Thu, 11 Dec 2025 03:38:08 +0300 Subject: [PATCH] dart --- mnemo_cards_admin/web/Dockerfile | 25 ++++ mnemo_cards_backend/.dockerignore | 20 +++ .../lib/packs/voice_model_extension.dart | 21 +++ .../lib/src/dtos/voice_dto.dart | 27 ++++ .../lib/src/dtos/voice_dto.g.dart | 120 ++++++++++++++++++ mnemo_cards_web_v2/.dockerignore | 16 +++ mnemo_cards_web_v2/Dockerfile | 27 ++++ .../domain/config/api_config_v2_test.dart | 21 +++ .../http_repository_v2_voices_test.dart | 88 +++++++++++++ 9 files changed, 365 insertions(+) create mode 100644 mnemo_cards_admin/web/Dockerfile create mode 100644 mnemo_cards_backend/.dockerignore create mode 100644 mnemo_cards_backend/lib/packs/voice_model_extension.dart create mode 100644 mnemo_cards_common/lib/src/dtos/voice_dto.dart create mode 100644 mnemo_cards_common/lib/src/dtos/voice_dto.g.dart create mode 100644 mnemo_cards_web_v2/.dockerignore create mode 100644 mnemo_cards_web_v2/Dockerfile create mode 100644 mnemo_cards_web_v2/test/domain/config/api_config_v2_test.dart create mode 100644 mnemo_cards_web_v2/test/domain/services/http_repository_v2_voices_test.dart diff --git a/mnemo_cards_admin/web/Dockerfile b/mnemo_cards_admin/web/Dockerfile new file mode 100644 index 0000000..90f9ea7 --- /dev/null +++ b/mnemo_cards_admin/web/Dockerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1.7 + +# --- Build stage ------------------------------------------------------------ +FROM node:20-alpine AS build +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# --- Runtime stage ---------------------------------------------------------- +FROM nginx:1.27-alpine AS runtime +WORKDIR /usr/share/nginx/html + +# Replace default config with SPA-aware config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +COPY --from=build /app/dist/ ./ + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1/ >/dev/null 2>&1 || exit 1 diff --git a/mnemo_cards_backend/.dockerignore b/mnemo_cards_backend/.dockerignore new file mode 100644 index 0000000..bacb830 --- /dev/null +++ b/mnemo_cards_backend/.dockerignore @@ -0,0 +1,20 @@ +.git +.gitmodules +.github +.idea +.vscode +.cursor +build +linux/build +windows +ios +android +macos +test +.dart_tool +pubspec.lock +*.log +*.tmp +isar/*.isar +isar/*.lock +**/.DS_Store \ No newline at end of file diff --git a/mnemo_cards_backend/lib/packs/voice_model_extension.dart b/mnemo_cards_backend/lib/packs/voice_model_extension.dart new file mode 100644 index 0000000..b358e2d --- /dev/null +++ b/mnemo_cards_backend/lib/packs/voice_model_extension.dart @@ -0,0 +1,21 @@ +import 'package:mnemo_cards_common/mnemo_cards_common.dart'; +import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; + +extension VoiceModelExtension on VoiceModel { + VoiceDto toDto({String? url}) => VoiceDto( + id: id ?? -1, + phrase: phrase, + path: path, + speaker: speaker, + url: url, + ); +} + +extension VoiceDtoExtension on VoiceDto { + VoiceModel toModel() => VoiceModel( + id: id < 0 ? null : id, + phrase: phrase, + path: path, + speaker: speaker, + ); +} diff --git a/mnemo_cards_common/lib/src/dtos/voice_dto.dart b/mnemo_cards_common/lib/src/dtos/voice_dto.dart new file mode 100644 index 0000000..9c91685 --- /dev/null +++ b/mnemo_cards_common/lib/src/dtos/voice_dto.dart @@ -0,0 +1,27 @@ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'voice_dto.g.dart'; + +@JsonSerializable(fieldRename: FieldRename.snake) +@CopyWith() +class VoiceDto { + final int id; + final String phrase; + final String path; + final String speaker; + final String? url; + + const VoiceDto({ + required this.id, + required this.phrase, + required this.path, + required this.speaker, + this.url, + }); + + factory VoiceDto.fromJson(Map json) => + _$VoiceDtoFromJson(json); + + Map toJson() => _$VoiceDtoToJson(this); +} diff --git a/mnemo_cards_common/lib/src/dtos/voice_dto.g.dart b/mnemo_cards_common/lib/src/dtos/voice_dto.g.dart new file mode 100644 index 0000000..7f177da --- /dev/null +++ b/mnemo_cards_common/lib/src/dtos/voice_dto.g.dart @@ -0,0 +1,120 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'voice_dto.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$VoiceDtoCWProxy { + VoiceDto id(int id); + + VoiceDto phrase(String phrase); + + VoiceDto path(String path); + + VoiceDto speaker(String speaker); + + VoiceDto url(String? url); + + /// This function **does support** nullification of nullable fields. All `null` + /// values passed to `non-nullable` fields will be ignored. You can also use + /// `VoiceDto(...).copyWith.fieldName(...)` to override fields one at a time + /// with nullification support. + /// + /// Usage + /// ```dart + /// VoiceDto(...).copyWith(id: 12, name: "My name") + /// ```` + VoiceDto call({ + int? id, + String? phrase, + String? path, + String? speaker, + String? url, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can +/// be used as follows: `instanceOfVoiceDto.copyWith(...)`. Additionally contains +/// functions for specific fields e.g. `instanceOfVoiceDto.copyWith.fieldName(...)` +class _$VoiceDtoCWProxyImpl implements _$VoiceDtoCWProxy { + const _$VoiceDtoCWProxyImpl(this._value); + + final VoiceDto _value; + + @override + VoiceDto id(int id) => this(id: id); + + @override + VoiceDto phrase(String phrase) => this(phrase: phrase); + + @override + VoiceDto path(String path) => this(path: path); + + @override + VoiceDto speaker(String speaker) => this(speaker: speaker); + + @override + VoiceDto url(String? url) => this(url: url); + + @override + VoiceDto call({ + Object? id = const $CopyWithPlaceholder(), + Object? phrase = const $CopyWithPlaceholder(), + Object? path = const $CopyWithPlaceholder(), + Object? speaker = const $CopyWithPlaceholder(), + Object? url = const $CopyWithPlaceholder(), + }) { + return VoiceDto( + id: id == const $CopyWithPlaceholder() || id == null + ? _value.id + // ignore: cast_nullable_to_non_nullable + : id as int, + phrase: phrase == const $CopyWithPlaceholder() || phrase == null + ? _value.phrase + // ignore: cast_nullable_to_non_nullable + : phrase as String, + path: path == const $CopyWithPlaceholder() || path == null + ? _value.path + // ignore: cast_nullable_to_non_nullable + : path as String, + speaker: speaker == const $CopyWithPlaceholder() || speaker == null + ? _value.speaker + // ignore: cast_nullable_to_non_nullable + : speaker as String, + url: url == const $CopyWithPlaceholder() + ? _value.url + // ignore: cast_nullable_to_non_nullable + : url as String?, + ); + } +} + +extension $VoiceDtoCopyWith on VoiceDto { + /// Returns a callable class that can be used as follows: + /// `instanceOfVoiceDto.copyWith(...)` or like so: + /// `instanceOfVoiceDto.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$VoiceDtoCWProxy get copyWith => _$VoiceDtoCWProxyImpl(this); +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +VoiceDto _$VoiceDtoFromJson(Map json) => VoiceDto( + id: (json['id'] as num).toInt(), + phrase: json['phrase'] as String, + path: json['path'] as String, + speaker: json['speaker'] as String, + url: json['url'] as String?, + ); + +Map _$VoiceDtoToJson(VoiceDto instance) => { + 'id': instance.id, + 'phrase': instance.phrase, + 'path': instance.path, + 'speaker': instance.speaker, + 'url': instance.url, + }; diff --git a/mnemo_cards_web_v2/.dockerignore b/mnemo_cards_web_v2/.dockerignore new file mode 100644 index 0000000..1689603 --- /dev/null +++ b/mnemo_cards_web_v2/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitignore +.idea +.vscode +.cursor +build +linux +windows +ios +android +macos +.dart_tool +pubspec.lock +*.log +*.tmp +**/.DS_Store \ No newline at end of file diff --git a/mnemo_cards_web_v2/Dockerfile b/mnemo_cards_web_v2/Dockerfile new file mode 100644 index 0000000..54e3f5b --- /dev/null +++ b/mnemo_cards_web_v2/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1.7 + +# --- Build stage ------------------------------------------------------------ +FROM ghcr.io/cirruslabs/flutter:3.27.4 AS build +WORKDIR /app + +COPY pubspec.* ./ +RUN flutter pub get + +COPY . . + +ENV API_BASE_URL=https://api.mnemo-cards.online + +RUN flutter build web --release \ + --dart-define=API_BASE_URL=${API_BASE_URL} + +# --- Runtime stage ---------------------------------------------------------- +FROM nginx:1.27-alpine AS runtime +WORKDIR /usr/share/nginx/html + +COPY web.nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/build/web/ ./ + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1/ >/dev/null 2>&1 || exit 1 diff --git a/mnemo_cards_web_v2/test/domain/config/api_config_v2_test.dart b/mnemo_cards_web_v2/test/domain/config/api_config_v2_test.dart new file mode 100644 index 0000000..ef9847b --- /dev/null +++ b/mnemo_cards_web_v2/test/domain/config/api_config_v2_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mnemo_cards_web_v2/domain/config/api_config_v2.dart'; + +void main() { + group('ApiConfigV2 voice endpoints', () { + test('builds pack card voices path', () { + expect( + ApiConfigV2.packCardVoices('123', 45), + '/packs/123/cards/45/voices', + ); + }); + + test('builds voice file path and url', () { + expect(ApiConfigV2.voiceFile(7), '/voice/7'); + + final voiceUrl = ApiConfigV2.getVoiceFileUrl(7); + expect(voiceUrl, contains('/api/v2/voice/7')); + expect(voiceUrl.startsWith('http'), isTrue); + }); + }); +} diff --git a/mnemo_cards_web_v2/test/domain/services/http_repository_v2_voices_test.dart b/mnemo_cards_web_v2/test/domain/services/http_repository_v2_voices_test.dart new file mode 100644 index 0000000..a86872e --- /dev/null +++ b/mnemo_cards_web_v2/test/domain/services/http_repository_v2_voices_test.dart @@ -0,0 +1,88 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mnemo_cards_common/mnemo_cards_common.dart'; +import 'package:mnemo_cards_web_v2/domain/config/api_config_v2.dart'; +import 'package:mnemo_cards_web_v2/domain/services/http_repository_v2.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MockDio extends Mock implements Dio {} + +void main() { + late Dio dio; + late HttpRepositoryV2 repository; + late SharedPreferences prefs; + + setUpAll(() { + registerFallbackValue(RequestOptions(path: '/')); + }); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + dio = _MockDio(); + when(() => dio.interceptors).thenReturn(Interceptors()); + repository = HttpRepositoryV2(dio: dio, prefs: prefs); + }); + + test('getCardVoices parses response items', () async { + final requestPath = ApiConfigV2.packCardVoices('1', 2); + final response = Response>( + data: { + 'items': [ + { + 'id': 1, + 'phrase': 'hola', + 'path': 'voice/Lucia/hola.mp3', + 'speaker': 'Lucia', + 'url': '/api/v2/voice/1', + }, + ], + }, + requestOptions: RequestOptions(path: requestPath), + ); + + when( + () => dio.get>( + requestPath, + data: any(named: 'data'), + options: any(named: 'options'), + queryParameters: any(named: 'queryParameters'), + cancelToken: any(named: 'cancelToken'), + onReceiveProgress: any(named: 'onReceiveProgress'), + ), + ).thenAnswer((_) async => response); + + final result = await repository.getCardVoices('1', 2); + + expect(result, hasLength(1)); + expect(result.first, isA()); + expect(result.first.phrase, 'hola'); + expect(result.first.url, '/api/v2/voice/1'); + }); + + test('getCardVoices returns empty list on 404', () async { + when( + () => dio.get>( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + queryParameters: any(named: 'queryParameters'), + cancelToken: any(named: 'cancelToken'), + onReceiveProgress: any(named: 'onReceiveProgress'), + ), + ).thenThrow( + DioException( + response: Response( + statusCode: 404, + requestOptions: RequestOptions(path: '/packs/1/cards/2/voices'), + ), + requestOptions: RequestOptions(path: '/packs/1/cards/2/voices'), + ), + ); + + final result = await repository.getCardVoices('1', 2); + + expect(result, isEmpty); + }); +}