diff --git a/.gitea/workflows/build-and-deploy.yml b/.gitea/workflows/build-and-deploy.yml index 185c498..9dc92cf 100644 --- a/.gitea/workflows/build-and-deploy.yml +++ b/.gitea/workflows/build-and-deploy.yml @@ -7,7 +7,7 @@ on: jobs: build: - runs-on: build-host + runs-on: builder env: SERVICE_NAME: flutter IMAGE_SHA: gitea.dsrptlab.com/mapflow/flutter:${{ github.sha }} @@ -22,20 +22,9 @@ jobs: auth="$(printf '%s:%s' "${{ secrets.REGISTRY_USERNAME }}" "${{ secrets.REGISTRY_TOKEN }}" | base64 | tr -d '\n')" printf '{"auths":{"gitea.dsrptlab.com":{"auth":"%s"}}}\n' "$auth" > ~/.docker/config.json - - name: Free Docker build space - run: | - set -euo pipefail - docker buildx prune --builder builder --all --max-used-space 40gb -f || true - - name: Build and push image run: | set -euo pipefail - builder="builder" - if ! docker buildx inspect "$builder" >/dev/null 2>&1; then - docker buildx create --name "$builder" --driver docker-container --buildkitd-config /etc/buildkit/buildkitd.toml - fi - docker buildx use "$builder" - docker buildx inspect --bootstrap docker buildx build \ --push \ --tag "$IMAGE_SHA" \ @@ -70,8 +59,3 @@ jobs: -d "$payload")" cat "$response_file" [ "$status_code" = "200" ] - - - name: Prune shared BuildKit cache - run: | - set -euo pipefail - docker buildx prune --builder builder --all --max-used-space 40gb -f diff --git a/lib/api/mapflow_api.dart b/lib/api/mapflow_api.dart index d7d4d04..35f191a 100644 --- a/lib/api/mapflow_api.dart +++ b/lib/api/mapflow_api.dart @@ -51,6 +51,7 @@ class MapflowApi { lastName photoUrl languageCode + isAdmin } } '''); @@ -73,6 +74,7 @@ class MapflowApi { lastName photoUrl languageCode + isAdmin } } } @@ -97,6 +99,7 @@ class MapflowApi { lastName photoUrl languageCode + isAdmin } } } @@ -137,12 +140,13 @@ class MapflowApi { telegramId username firstName - lastName - photoUrl - languageCode - } + lastName + photoUrl + languageCode + isAdmin } } + } ''', variables: {'token': token}, ); @@ -255,6 +259,8 @@ class MapflowApi { required LatLng coordinate, required int durationSeconds, required String audioObjectKey, + required String audioContentBase64, + required String audioMimeType, }) async { if (!hasTelegramAuth) { throw StateError('Telegram authorization is required.'); @@ -276,11 +282,43 @@ class MapflowApi { 'longitude': coordinate.longitude, 'durationSeconds': durationSeconds, 'audioObjectKey': audioObjectKey, + 'audioContentBase64': audioContentBase64, + 'audioMimeType': audioMimeType, }, }, ); } + Future> fetchVoiceExperiences() async { + final data = await _graphql(''' + query VoiceExperiences { + voiceExperiences { + id + status + durationSeconds + transcript + analysis + createdAt + place { + name + } + user { + telegramId + username + firstName + } + } + } + '''); + + final experiences = data['voiceExperiences'] as List; + return experiences + .map( + (item) => VoiceExperienceDebug.fromJson(item as Map), + ) + .toList(); + } + Future> _graphql( String query, { Map? variables, diff --git a/lib/models/place_models.dart b/lib/models/place_models.dart index d8adc23..7b0bc0f 100644 --- a/lib/models/place_models.dart +++ b/lib/models/place_models.dart @@ -10,6 +10,7 @@ class AppUser { required this.lastName, required this.photoUrl, required this.languageCode, + required this.isAdmin, }); factory AppUser.fromJson(Map json) { @@ -21,6 +22,7 @@ class AppUser { lastName: json['lastName'] as String?, photoUrl: json['photoUrl'] as String?, languageCode: json['languageCode'] as String?, + isAdmin: json['isAdmin'] as bool? ?? false, ); } @@ -31,6 +33,51 @@ class AppUser { final String? lastName; final String? photoUrl; final String? languageCode; + final bool isAdmin; +} + +class VoiceExperienceDebug { + const VoiceExperienceDebug({ + required this.id, + required this.placeName, + required this.userName, + required this.status, + required this.durationSeconds, + required this.transcript, + required this.analysis, + required this.createdAt, + }); + + factory VoiceExperienceDebug.fromJson(Map json) { + final place = json['place'] as Map; + final user = json['user'] as Map?; + final firstName = user?['firstName'] as String?; + final username = user?['username'] as String?; + final telegramId = user?['telegramId'] as String?; + return VoiceExperienceDebug( + id: json['id'] as String, + placeName: place['name'] as String, + userName: firstName?.trim().isNotEmpty == true + ? firstName! + : username?.trim().isNotEmpty == true + ? '@$username' + : telegramId ?? '', + status: json['status'] as String, + durationSeconds: json['durationSeconds'] as int, + transcript: json['transcript'] as String?, + analysis: json['analysis'] as Map?, + createdAt: DateTime.parse(json['createdAt'] as String), + ); + } + + final String id; + final String placeName; + final String userName; + final String status; + final int durationSeconds; + final String? transcript; + final Map? analysis; + final DateTime createdAt; } enum PlaceTrait { diff --git a/lib/screens/mapflow_shell.dart b/lib/screens/mapflow_shell.dart index a4fa62a..1087a2f 100644 --- a/lib/screens/mapflow_shell.dart +++ b/lib/screens/mapflow_shell.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -97,13 +98,26 @@ class _MapContent extends ConsumerWidget { SafeArea( child: Align( alignment: Alignment.topLeft, - child: _UserAvatar( - user: state.currentUser, - onLogout: () { - telegram_session.clearMapflowSession(); - ref.invalidate(placeControllerProvider); - telegram_session.reloadApp(); - }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _UserAvatar( + user: state.currentUser, + onLogout: () { + telegram_session.clearMapflowSession(); + ref.invalidate(placeControllerProvider); + telegram_session.reloadApp(); + }, + ), + if (state.currentUser?.isAdmin == true) + _AdminReviewsButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AdminVoiceExperiencesScreen(), + ), + ), + ), + ], ), ), ), @@ -259,6 +273,30 @@ class _UserAvatar extends StatelessWidget { } } +class _AdminReviewsButton extends StatelessWidget { + const _AdminReviewsButton({required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: FilledButton.icon( + onPressed: onPressed, + icon: const Icon(Icons.table_rows_outlined, size: 18), + label: const Text('Отзывы'), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFFFFFBF5), + foregroundColor: const Color(0xFF17211D), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + ), + ); + } +} + enum _AvatarAction { logout } class _AvatarImage extends StatelessWidget { @@ -842,7 +880,18 @@ class _AddExperienceFlowState extends ConsumerState { onSelect: (place) async { setState(() => _submitting = true); controller.setReviewPlace(place.name); - await controller.publishReview(place: place); + final file = _waveController.file; + if (file == null) { + throw StateError('Voice recording file is required.'); + } + final bytes = await file.readAsBytes(); + await controller.publishReview( + place: place, + audioObjectKey: + 'web-recording-${DateTime.now().microsecondsSinceEpoch}-${file.name}', + audioContentBase64: base64Encode(bytes), + audioMimeType: file.mimeType ?? 'audio/wav', + ); if (!context.mounted) { return; } @@ -884,6 +933,114 @@ class _AddExperienceFlowState extends ConsumerState { } } +class AdminVoiceExperiencesScreen extends StatefulWidget { + const AdminVoiceExperiencesScreen({super.key}); + + @override + State createState() => + _AdminVoiceExperiencesScreenState(); +} + +class _AdminVoiceExperiencesScreenState + extends State { + late final Future> _future = MapflowApi() + .fetchVoiceExperiences(); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFFFFBF5), + appBar: AppBar(title: const Text('Отзывы')), + body: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text(snapshot.error.toString())); + } + + final reviews = snapshot.data ?? const []; + return ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: reviews.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + return _AdminVoiceExperienceRow(review: reviews[index]); + }, + ); + }, + ), + ); + } +} + +class _AdminVoiceExperienceRow extends StatelessWidget { + const _AdminVoiceExperienceRow({required this.review}); + + final VoiceExperienceDebug review; + + @override + Widget build(BuildContext context) { + final tags = review.analysis?['tags']; + final tagText = tags is List ? tags.join(', ') : ''; + + return Material( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + review.placeName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w900), + ), + ), + Text( + review.status, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ], + ), + const SizedBox(height: 6), + Text( + '${review.userName} · ${review.durationSeconds}s', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFF6B6258)), + ), + if (review.transcript?.trim().isNotEmpty == true) ...[ + const SizedBox(height: 8), + Text( + review.transcript!, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + if (tagText.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + tagText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFE11D48)), + ), + ], + ], + ), + ), + ); + } +} + class _IntroStep extends StatelessWidget { const _IntroStep({required this.onNext}); diff --git a/lib/state/place_controller.dart b/lib/state/place_controller.dart index 031edee..47e512c 100644 --- a/lib/state/place_controller.dart +++ b/lib/state/place_controller.dart @@ -167,7 +167,12 @@ class PlaceController extends AsyncNotifier { ); } - Future publishReview({required PlaceRecommendation place}) async { + Future publishReview({ + required PlaceRecommendation place, + required String audioObjectKey, + required String audioContentBase64, + required String audioMimeType, + }) async { final value = state.requireValue; if (!value.hasTelegramAuth) { throw StateError('Открой через Telegram, чтобы оставить голос.'); @@ -180,7 +185,9 @@ class PlaceController extends AsyncNotifier { googleName: place.name, coordinate: place.coordinate, durationSeconds: draft.duration.inSeconds, - audioObjectKey: 'web-recording-${DateTime.now().microsecondsSinceEpoch}', + audioObjectKey: audioObjectKey, + audioContentBase64: audioContentBase64, + audioMimeType: audioMimeType, ); final places = await _api.fetchPlaces();