feat: move supported languages to DB table, expose via GET /languages

- migration 013: create languages table (code PK, native_name, english_name,
  is_active, sort_order) with all 12 existing languages seeded
- locale: add Language struct, Languages []Language, LoadFromDB() — queries
  languages table at startup and populates both Supported map and Languages
  slice; existing Parse/FromContext/FromRequest unchanged
- main.go: call locale.LoadFromDB after pool is ready
- gemini/recipe.go: remove hardcoded langNames map, use locale.Languages
  linear lookup for English name in prompt
- language/handler.go: new package with GET /languages handler returning
  active languages list (no auth required)
- server.go: register GET /languages as public route
- Flutter: add LanguageRepository + languageRepositoryProvider that fetches
  /languages from backend
- language_provider.dart: replace const supportedLanguages map with
  supportedLanguagesProvider (FutureProvider) backed by LanguageRepository
- profile_provider.dart: remove supportedLanguages.containsKey validation —
  backend is source of truth; sync any non-empty language from preferences
- profile_screen.dart: use supportedLanguagesProvider for display name and
  dropdown (async with loading/error states)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-10 23:08:10 +02:00
parent a225f6c47a
commit e1fbe7b1a2
10 changed files with 161 additions and 58 deletions

View File

@@ -17,6 +17,7 @@ import (
"github.com/food-ai/backend/internal/gemini" "github.com/food-ai/backend/internal/gemini"
"github.com/food-ai/backend/internal/home" "github.com/food-ai/backend/internal/home"
"github.com/food-ai/backend/internal/ingredient" "github.com/food-ai/backend/internal/ingredient"
"github.com/food-ai/backend/internal/locale"
"github.com/food-ai/backend/internal/menu" "github.com/food-ai/backend/internal/menu"
"github.com/food-ai/backend/internal/middleware" "github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/pexels" "github.com/food-ai/backend/internal/pexels"
@@ -72,6 +73,11 @@ func run() error {
defer pool.Close() defer pool.Close()
slog.Info("connected to database") slog.Info("connected to database")
if err := locale.LoadFromDB(ctx, pool); err != nil {
return fmt.Errorf("load languages: %w", err)
}
slog.Info("languages loaded", "count", len(locale.Languages))
// Firebase auth // Firebase auth
firebaseAuth, err := auth.NewFirebaseAuthOrNoop(cfg.FirebaseCredentialsFile) firebaseAuth, err := auth.NewFirebaseAuthOrNoop(cfg.FirebaseCredentialsFile)
if err != nil { if err != nil {

View File

@@ -5,6 +5,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings" "strings"
"github.com/food-ai/backend/internal/locale"
) )
// RecipeGenerator generates recipes using the Gemini AI. // RecipeGenerator generates recipes using the Gemini AI.
@@ -63,22 +65,6 @@ type NutritionInfo struct {
Approximate bool `json:"approximate"` Approximate bool `json:"approximate"`
} }
// langNames maps ISO 639-1 codes to English language names used in the prompt.
var langNames = map[string]string{
"en": "English",
"ru": "Russian",
"es": "Spanish",
"de": "German",
"fr": "French",
"it": "Italian",
"pt": "Portuguese",
"zh": "Chinese (Simplified)",
"ja": "Japanese",
"ko": "Korean",
"ar": "Arabic",
"hi": "Hindi",
}
// goalNames maps internal goal codes to English descriptions used in the prompt. // goalNames maps internal goal codes to English descriptions used in the prompt.
var goalNames = map[string]string{ var goalNames = map[string]string{
"lose": "weight loss", "lose": "weight loss",
@@ -130,9 +116,12 @@ func buildRecipePrompt(req RecipeRequest) string {
if lang == "" { if lang == "" {
lang = "en" lang = "en"
} }
langName, ok := langNames[lang] langName := "English"
if !ok { for _, l := range locale.Languages {
langName = "English" if l.Code == lang {
langName = l.EnglishName
break
}
} }
goal := goalNames[req.UserGoal] goal := goalNames[req.UserGoal]

View File

@@ -0,0 +1,28 @@
package language
import (
"encoding/json"
"net/http"
"github.com/food-ai/backend/internal/locale"
)
type languageItem struct {
Code string `json:"code"`
NativeName string `json:"native_name"`
SortOrder int `json:"sort_order"`
}
// List handles GET /languages — returns the active language list loaded from DB.
func List(w http.ResponseWriter, r *http.Request) {
items := make([]languageItem, 0, len(locale.Languages))
for i, l := range locale.Languages {
items = append(items, languageItem{
Code: l.Code,
NativeName: l.NativeName,
SortOrder: i + 1,
})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"languages": items})
}

View File

@@ -2,8 +2,11 @@ package locale
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"strings" "strings"
"github.com/jackc/pgx/v5/pgxpool"
) )
// Default is the fallback language when no supported language is detected. // Default is the fallback language when no supported language is detected.
@@ -11,19 +14,49 @@ const Default = "en"
// Supported is the set of language codes the application currently handles. // Supported is the set of language codes the application currently handles.
// Keys are ISO 639-1 two-letter codes (lower-case). // Keys are ISO 639-1 two-letter codes (lower-case).
// Populated by LoadFromDB at server startup.
var Supported = map[string]bool{ var Supported = map[string]bool{
"en": true, "en": true,
"ru": true, "ru": true,
"es": true, }
"de": true,
"fr": true, // Language is a supported language record loaded from the DB.
"it": true, type Language struct {
"pt": true, Code string
"zh": true, NativeName string
"ja": true, EnglishName string
"ko": true, }
"ar": true,
"hi": true, // Languages is the ordered list of active languages.
// Populated by LoadFromDB at server startup.
var Languages []Language
// LoadFromDB queries the languages table and updates both Supported and Languages.
// Must be called once at startup before the server begins accepting requests.
func LoadFromDB(ctx context.Context, pool *pgxpool.Pool) error {
rows, err := pool.Query(ctx,
`SELECT code, native_name, english_name FROM languages WHERE is_active ORDER BY sort_order`)
if err != nil {
return fmt.Errorf("load languages from db: %w", err)
}
defer rows.Close()
newSupported := map[string]bool{}
var newLanguages []Language
for rows.Next() {
var l Language
if err := rows.Scan(&l.Code, &l.NativeName, &l.EnglishName); err != nil {
return err
}
newSupported[l.Code] = true
newLanguages = append(newLanguages, l)
}
if err := rows.Err(); err != nil {
return err
}
Supported = newSupported
Languages = newLanguages
return nil
} }
type contextKey struct{} type contextKey struct{}

View File

@@ -8,6 +8,7 @@ import (
"github.com/food-ai/backend/internal/diary" "github.com/food-ai/backend/internal/diary"
"github.com/food-ai/backend/internal/home" "github.com/food-ai/backend/internal/home"
"github.com/food-ai/backend/internal/ingredient" "github.com/food-ai/backend/internal/ingredient"
"github.com/food-ai/backend/internal/language"
"github.com/food-ai/backend/internal/menu" "github.com/food-ai/backend/internal/menu"
"github.com/food-ai/backend/internal/middleware" "github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/product" "github.com/food-ai/backend/internal/product"
@@ -45,6 +46,7 @@ func NewRouter(
// Public // Public
r.Get("/health", healthCheck(pool)) r.Get("/health", healthCheck(pool))
r.Get("/languages", language.List)
r.Route("/auth", func(r chi.Router) { r.Route("/auth", func(r chi.Router) {
r.Post("/login", authHandler.Login) r.Post("/login", authHandler.Login)
r.Post("/refresh", authHandler.Refresh) r.Post("/refresh", authHandler.Refresh)

View File

@@ -0,0 +1,24 @@
-- +goose Up
CREATE TABLE languages (
code VARCHAR(10) PRIMARY KEY,
native_name TEXT NOT NULL,
english_name TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order SMALLINT NOT NULL DEFAULT 0
);
INSERT INTO languages (code, native_name, english_name, sort_order) VALUES
('en', 'English', 'English', 1),
('ru', 'Русский', 'Russian', 2),
('es', 'Español', 'Spanish', 3),
('de', 'Deutsch', 'German', 4),
('fr', 'Français', 'French', 5),
('it', 'Italiano', 'Italian', 6),
('pt', 'Português', 'Portuguese', 7),
('zh', '中文', 'Chinese (Simplified)', 8),
('ja', '日本語', 'Japanese', 9),
('ko', '한국어', 'Korean', 10),
('ar', 'العربية', 'Arabic', 11),
('hi', 'हिन्दी', 'Hindi', 12);
-- +goose Down
DROP TABLE IF EXISTS languages;

View File

@@ -0,0 +1,22 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_provider.dart';
import 'api_client.dart';
class LanguageRepository {
final ApiClient _api;
LanguageRepository(this._api);
Future<Map<String, String>> fetchLanguages() async {
final response = await _api.dio.get('/languages');
final List<dynamic> items = response.data['languages'] as List;
return {
for (final item in items)
item['code'] as String: item['native_name'] as String,
};
}
}
final languageRepositoryProvider = Provider<LanguageRepository>(
(ref) => LanguageRepository(ref.watch(apiClientProvider)),
);

View File

@@ -1,21 +1,12 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Supported ISO 639-1 language codes with their native names. import '../api/language_repository.dart';
/// Must match the backend locale.Supported map.
const supportedLanguages = <String, String>{ /// Fetches and caches the supported languages from the backend.
'en': 'English', /// Returns a map of code → native name (e.g. {'en': 'English', 'ru': 'Русский'}).
'ru': 'Русский', final supportedLanguagesProvider = FutureProvider<Map<String, String>>((ref) {
'es': 'Español', return ref.watch(languageRepositoryProvider).fetchLanguages();
'de': 'Deutsch', });
'fr': 'Français',
'it': 'Italiano',
'pt': 'Português',
'zh': '中文',
'ja': '日本語',
'ko': '한국어',
'ar': 'العربية',
'hi': 'हिन्दी',
};
/// Current app language (ISO 639-1 code). /// Current app language (ISO 639-1 code).
/// Synced from user.preferences['language'] after the profile loads or is updated. /// Synced from user.preferences['language'] after the profile loads or is updated.

View File

@@ -4,6 +4,7 @@ import '../../core/locale/language_provider.dart';
import '../../shared/models/user.dart'; import '../../shared/models/user.dart';
import 'profile_service.dart'; import 'profile_service.dart';
class ProfileNotifier extends StateNotifier<AsyncValue<User>> { class ProfileNotifier extends StateNotifier<AsyncValue<User>> {
final ProfileService _service; final ProfileService _service;
final void Function(String) _setLanguage; final void Function(String) _setLanguage;
@@ -33,7 +34,7 @@ class ProfileNotifier extends StateNotifier<AsyncValue<User>> {
// Propagates the user's preferred language to the global languageProvider. // Propagates the user's preferred language to the global languageProvider.
void _syncLanguage() { void _syncLanguage() {
final lang = state.valueOrNull?.preferences['language'] as String?; final lang = state.valueOrNull?.preferences['language'] as String?;
if (lang != null && supportedLanguages.containsKey(lang)) { if (lang != null && lang.isNotEmpty) {
_setLanguage(lang); _setLanguage(lang);
} }
} }

View File

@@ -123,7 +123,8 @@ class _ProfileBody extends StatelessWidget {
_InfoCard(children: [ _InfoCard(children: [
_InfoRow( _InfoRow(
'Язык', 'Язык',
supportedLanguages[user.preferences['language'] as String? ?? 'ru'] ?? ref.watch(supportedLanguagesProvider).valueOrNull?[
user.preferences['language'] as String? ?? 'ru'] ??
'Русский', 'Русский',
), ),
]), ]),
@@ -590,17 +591,23 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
const SizedBox(height: 20), const SizedBox(height: 20),
// Language // Language
DropdownButtonFormField<String>( ref.watch(supportedLanguagesProvider).when(
value: _language, data: (languages) => DropdownButtonFormField<String>(
decoration: value: _language,
const InputDecoration(labelText: 'Язык интерфейса'), decoration: const InputDecoration(
items: supportedLanguages.entries labelText: 'Язык интерфейса'),
.map((e) => DropdownMenuItem( items: languages.entries
value: e.key, .map((e) => DropdownMenuItem(
child: Text(e.value), value: e.key,
)) child: Text(e.value),
.toList(), ))
onChanged: (v) => setState(() => _language = v), .toList(),
onChanged: (v) => setState(() => _language = v),
),
loading: () => const Center(
child: CircularProgressIndicator()),
error: (_, __) =>
const Text('Не удалось загрузить языки'),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),