dart
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Mobile App CI / test (push) Waiting to run
Mobile App CI / build-android (push) Blocked by required conditions
Mobile App CI / build-ios (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run

This commit is contained in:
Dmitry 2025-12-11 03:38:08 +03:00
parent f9207e6d0c
commit 2eaa6a9067
9 changed files with 365 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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,
);
}

View file

@ -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<String, dynamic> json) =>
_$VoiceDtoFromJson(json);
Map<String, Object?> toJson() => _$VoiceDtoToJson(this);
}

View file

@ -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<String, dynamic> 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<String, dynamic> _$VoiceDtoToJson(VoiceDto instance) => <String, dynamic>{
'id': instance.id,
'phrase': instance.phrase,
'path': instance.path,
'speaker': instance.speaker,
'url': instance.url,
};

View file

@ -0,0 +1,16 @@
.git
.gitignore
.idea
.vscode
.cursor
build
linux
windows
ios
android
macos
.dart_tool
pubspec.lock
*.log
*.tmp
**/.DS_Store

View file

@ -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

View file

@ -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);
});
});
}

View file

@ -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<Map<String, dynamic>>(
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<Map<String, dynamic>>(
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<VoiceDto>());
expect(result.first.phrase, 'hola');
expect(result.first.url, '/api/v2/voice/1');
});
test('getCardVoices returns empty list on 404', () async {
when(
() => dio.get<Map<String, dynamic>>(
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);
});
}