libretranslatr
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const TranslateApp());
|
||||
}
|
||||
|
||||
class TranslateApp extends StatelessWidget {
|
||||
const TranslateApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Çeviri',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
themeMode: ThemeMode.system,
|
||||
home: const HomeScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
class HistoryItem {
|
||||
final String sourceLang;
|
||||
final String targetLang;
|
||||
final String sourceText;
|
||||
final String translatedText;
|
||||
final DateTime timestamp;
|
||||
|
||||
HistoryItem({
|
||||
required this.sourceLang,
|
||||
required this.targetLang,
|
||||
required this.sourceText,
|
||||
required this.translatedText,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'sourceLang': sourceLang,
|
||||
'targetLang': targetLang,
|
||||
'sourceText': sourceText,
|
||||
'translatedText': translatedText,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
|
||||
factory HistoryItem.fromJson(Map<String, dynamic> json) => HistoryItem(
|
||||
sourceLang: json['sourceLang'] as String,
|
||||
targetLang: json['targetLang'] as String,
|
||||
sourceText: json['sourceText'] as String,
|
||||
translatedText: json['translatedText'] as String,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
class Language {
|
||||
final String code;
|
||||
final String name;
|
||||
final List<String> targets;
|
||||
|
||||
const Language({
|
||||
required this.code,
|
||||
required this.name,
|
||||
this.targets = const [],
|
||||
});
|
||||
|
||||
factory Language.fromJson(Map<String, dynamic> json) {
|
||||
return Language(
|
||||
code: json['code'] as String,
|
||||
name: json['name'] as String,
|
||||
targets: (json['targets'] as List?)?.map((e) => e.toString()).toList() ??
|
||||
const [],
|
||||
);
|
||||
}
|
||||
|
||||
/// "Otomatik Algıla" seçeneğini temsil eden özel dil.
|
||||
static const auto = Language(code: 'auto', name: 'Otomatik Algıla');
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is Language && other.code == code;
|
||||
|
||||
@override
|
||||
int get hashCode => code.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/history_item.dart';
|
||||
import '../services/history_service.dart';
|
||||
|
||||
class HistoryScreen extends StatefulWidget {
|
||||
const HistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HistoryScreen> createState() => _HistoryScreenState();
|
||||
}
|
||||
|
||||
class _HistoryScreenState extends State<HistoryScreen> {
|
||||
final _historyService = HistoryService();
|
||||
List<HistoryItem> _items = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final items = await _historyService.getAll();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = items;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _clear() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Geçmişi Sil'),
|
||||
content: const Text('Tüm çeviri geçmişi silinsin mi? Bu işlem geri alınamaz.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Vazgeç')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Sil')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await _historyService.clear();
|
||||
_load();
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) {
|
||||
final now = DateTime.now();
|
||||
final sameDay = dt.year == now.year && dt.month == now.month && dt.day == now.day;
|
||||
final time = '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
if (sameDay) return time;
|
||||
return '${dt.day}.${dt.month}.${dt.year} $time';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Geçmiş'),
|
||||
actions: [
|
||||
if (_items.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Geçmişi temizle',
|
||||
onPressed: _clear,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _items.isEmpty
|
||||
? const Center(child: Text('Henüz çeviri geçmişi yok'))
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: _items.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (ctx, i) {
|
||||
final item = _items[i];
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'${item.sourceLang.toUpperCase()} → ${item.targetLang.toUpperCase()}',
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
_formatTime(item.timestamp),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(item.sourceText),
|
||||
const Divider(height: 16),
|
||||
Text(
|
||||
item.translatedText,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../models/language.dart';
|
||||
import '../models/history_item.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/translate_service.dart';
|
||||
import '../services/history_service.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'history_screen.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
final _settingsService = SettingsService();
|
||||
late final TranslateService _translateService = TranslateService(_settingsService);
|
||||
final _historyService = HistoryService();
|
||||
|
||||
final _sourceController = TextEditingController();
|
||||
Timer? _debounce;
|
||||
|
||||
List<Language> _languages = [];
|
||||
Language _sourceLang = Language.auto;
|
||||
Language _targetLang = const Language(code: 'en', name: 'İngilizce');
|
||||
|
||||
String _resultText = '';
|
||||
String? _detectedLangLabel;
|
||||
bool _loadingLanguages = true;
|
||||
bool _translating = false;
|
||||
String? _connectionError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bootstrap();
|
||||
_sourceController.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_sourceController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Uygulama açılışında sunucuya otomatik bağlanmayı dener.
|
||||
Future<void> _bootstrap() async {
|
||||
final hasSettings = await _settingsService.hasSettings();
|
||||
if (!hasSettings) {
|
||||
final saved = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen(isFirstRun: true)),
|
||||
);
|
||||
if (saved != true) {
|
||||
// Kullanıcı ayarları kaydetmeden geri döndüyse yine de tekrar dene.
|
||||
}
|
||||
}
|
||||
await _loadLanguages();
|
||||
}
|
||||
|
||||
Future<void> _loadLanguages() async {
|
||||
setState(() {
|
||||
_loadingLanguages = true;
|
||||
_connectionError = null;
|
||||
});
|
||||
try {
|
||||
final langs = await _translateService.fetchLanguages();
|
||||
setState(() {
|
||||
_languages = langs;
|
||||
// Hedef dil listede yoksa ilk uygun dile düş.
|
||||
if (!_languages.any((l) => l.code == _targetLang.code) && _languages.isNotEmpty) {
|
||||
_targetLang = _languages.firstWhere((l) => l.code != 'auto', orElse: () => _languages.first);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _connectionError = e.toString());
|
||||
} finally {
|
||||
setState(() => _loadingLanguages = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 600), _runTranslate);
|
||||
}
|
||||
|
||||
Future<void> _runTranslate() async {
|
||||
final text = _sourceController.text;
|
||||
if (text.trim().isEmpty) {
|
||||
setState(() {
|
||||
_resultText = '';
|
||||
_detectedLangLabel = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _translating = true);
|
||||
try {
|
||||
String effectiveSource = _sourceLang.code;
|
||||
|
||||
if (_sourceLang.code == 'auto') {
|
||||
final detected = await _translateService.detect(text);
|
||||
if (detected != null) {
|
||||
effectiveSource = detected.language;
|
||||
final match = _languages.firstWhere(
|
||||
(l) => l.code == detected.language,
|
||||
orElse: () => Language(code: detected.language, name: detected.language),
|
||||
);
|
||||
setState(() => _detectedLangLabel = 'Algılanan dil: ${match.name}');
|
||||
}
|
||||
} else {
|
||||
_detectedLangLabel = null;
|
||||
}
|
||||
|
||||
final translated = await _translateService.translate(
|
||||
text: text,
|
||||
sourceLang: effectiveSource,
|
||||
targetLang: _targetLang.code,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _resultText = translated);
|
||||
|
||||
await _historyService.add(HistoryItem(
|
||||
sourceLang: effectiveSource,
|
||||
targetLang: _targetLang.code,
|
||||
sourceText: text,
|
||||
translatedText: translated,
|
||||
timestamp: DateTime.now(),
|
||||
));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Çeviri hatası: $e')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _translating = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _swapLanguages() {
|
||||
if (_sourceLang.code == 'auto') return; // otomatik iken takas anlamsız
|
||||
setState(() {
|
||||
final tmp = _sourceLang;
|
||||
_sourceLang = _targetLang;
|
||||
_targetLang = tmp;
|
||||
final tmpText = _sourceController.text;
|
||||
_sourceController.text = _resultText;
|
||||
_resultText = tmpText;
|
||||
});
|
||||
}
|
||||
|
||||
List<Language> get _sourceOptions => [Language.auto, ..._languages];
|
||||
List<Language> get _targetOptions => _languages;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Çeviri'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Geçmiş',
|
||||
icon: const Icon(Icons.history),
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const HistoryScreen()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Ayarlar',
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
onPressed: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
);
|
||||
_loadLanguages();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loadingLanguages
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _connectionError != null
|
||||
? _buildConnectionError()
|
||||
: _buildTranslator(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConnectionError() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 48, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
Text('Sunucuya bağlanılamadı', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(_connectionError!, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 20),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
children: [
|
||||
OutlinedButton(onPressed: _loadLanguages, child: const Text('Tekrar Dene')),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
);
|
||||
_loadLanguages();
|
||||
},
|
||||
child: const Text('Ayarları Aç'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTranslator() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildLanguageBar(),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(child: _buildInputCard()),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(child: _buildResultCard()),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLanguageBar() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: _buildLangDropdown(isSource: true)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.swap_horiz),
|
||||
tooltip: 'Dilleri değiştir',
|
||||
onPressed: _sourceLang.code == 'auto' ? null : _swapLanguages,
|
||||
),
|
||||
Expanded(child: _buildLangDropdown(isSource: false)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLangDropdown({required bool isSource}) {
|
||||
final options = isSource ? _sourceOptions : _targetOptions;
|
||||
final value = isSource ? _sourceLang : _targetLang;
|
||||
return DropdownButtonFormField<Language>(
|
||||
isExpanded: true,
|
||||
value: options.contains(value) ? value : null,
|
||||
decoration: const InputDecoration(isDense: true),
|
||||
items: options
|
||||
.map((l) => DropdownMenuItem(value: l, child: Text(l.name, overflow: TextOverflow.ellipsis)))
|
||||
.toList(),
|
||||
onChanged: (l) {
|
||||
if (l == null) return;
|
||||
setState(() {
|
||||
if (isSource) {
|
||||
_sourceLang = l;
|
||||
} else {
|
||||
_targetLang = l;
|
||||
}
|
||||
});
|
||||
_runTranslate();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputCard() {
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _sourceController,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'Çevrilecek metni yazın...',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_detectedLangLabel != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(_detectedLangLabel!, style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear, size: 20),
|
||||
tooltip: 'Temizle',
|
||||
onPressed: () => _sourceController.clear(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultCard() {
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _translating
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
_resultText,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 20),
|
||||
tooltip: 'Kopyala',
|
||||
onPressed: _resultText.isEmpty
|
||||
? null
|
||||
: () {
|
||||
Clipboard.setData(ClipboardData(text: _resultText));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Kopyalandı'), duration: Duration(seconds: 1)),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/translate_service.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
final bool isFirstRun;
|
||||
const SettingsScreen({super.key, this.isFirstRun = false});
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final _settings = SettingsService();
|
||||
final _urlController = TextEditingController();
|
||||
final _apiKeyController = TextEditingController();
|
||||
|
||||
bool _testing = false;
|
||||
String? _statusMessage;
|
||||
bool? _statusSuccess;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
_urlController.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final url = await _settings.getServerUrl();
|
||||
final key = await _settings.getApiKey();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_urlController.text = url ?? '';
|
||||
_apiKeyController.text = key ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _testConnection() async {
|
||||
setState(() {
|
||||
_testing = true;
|
||||
_statusMessage = null;
|
||||
});
|
||||
|
||||
// Test için önce girilen değerleri geçici olarak kaydediyoruz.
|
||||
await _settings.save(
|
||||
serverUrl: _urlController.text,
|
||||
apiKey: _apiKeyController.text,
|
||||
);
|
||||
|
||||
try {
|
||||
final service = TranslateService(_settings);
|
||||
final langs = await service.fetchLanguages();
|
||||
setState(() {
|
||||
_statusSuccess = true;
|
||||
_statusMessage = 'Bağlantı başarılı — ${langs.length} dil bulundu.';
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_statusSuccess = false;
|
||||
_statusMessage = 'Bağlanamadı: $e';
|
||||
});
|
||||
} finally {
|
||||
setState(() => _testing = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveAndReturn() async {
|
||||
await _settings.save(
|
||||
serverUrl: _urlController.text,
|
||||
apiKey: _apiKeyController.text,
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Sunucu Ayarları'),
|
||||
automaticallyImplyLeading: !widget.isFirstRun,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.isFirstRun) ...[
|
||||
Text(
|
||||
'LibreTranslate Sunucunuza Bağlanın',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Uygulamanın çeviri yapabilmesi için kendi LibreTranslate sunucunuzun adresini girin.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
TextField(
|
||||
controller: _urlController,
|
||||
keyboardType: TextInputType.url,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Sunucu Adresi',
|
||||
hintText: 'https://translate.benimsunucum.com',
|
||||
prefixIcon: Icon(Icons.dns_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _apiKeyController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'API Key (opsiyonel)',
|
||||
hintText: 'Boş bırakılırsa gönderilmez',
|
||||
prefixIcon: Icon(Icons.key_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _testing ? null : _testConnection,
|
||||
icon: _testing
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.wifi_tethering),
|
||||
label: Text(_testing ? 'Test ediliyor...' : 'Bağlantıyı Test Et'),
|
||||
),
|
||||
if (_statusMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: (_statusSuccess == true
|
||||
? Colors.green
|
||||
: Colors.red)
|
||||
.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
_statusMessage!,
|
||||
style: TextStyle(
|
||||
color: _statusSuccess == true
|
||||
? Colors.green.shade700
|
||||
: Colors.red.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _urlController.text.trim().isEmpty ? null : _saveAndReturn,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Kaydet'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/history_item.dart';
|
||||
|
||||
class HistoryService {
|
||||
static const _key = 'translation_history';
|
||||
static const _maxItems = 200;
|
||||
|
||||
Future<List<HistoryItem>> getAll() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getStringList(_key) ?? [];
|
||||
return raw
|
||||
.map((s) => HistoryItem.fromJson(jsonDecode(s) as Map<String, dynamic>))
|
||||
.toList()
|
||||
.reversed
|
||||
.toList(); // en yeni en üstte
|
||||
}
|
||||
|
||||
Future<void> add(HistoryItem item) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getStringList(_key) ?? [];
|
||||
raw.add(jsonEncode(item.toJson()));
|
||||
final trimmed = raw.length > _maxItems
|
||||
? raw.sublist(raw.length - _maxItems)
|
||||
: raw;
|
||||
await prefs.setStringList(_key, trimmed);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Sunucu URL'si ve API key gibi ayarları kalıcı olarak saklar.
|
||||
class SettingsService {
|
||||
static const _keyServerUrl = 'server_url';
|
||||
static const _keyApiKey = 'api_key';
|
||||
|
||||
Future<String?> getServerUrl() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyServerUrl);
|
||||
}
|
||||
|
||||
Future<String?> getApiKey() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = prefs.getString(_keyApiKey);
|
||||
return (key == null || key.isEmpty) ? null : key;
|
||||
}
|
||||
|
||||
Future<void> save({required String serverUrl, required String apiKey}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
// Sonundaki "/" karakterini temizle, karışıklık olmasın.
|
||||
final cleanUrl = serverUrl.trim().replaceAll(RegExp(r'/+$'), '');
|
||||
await prefs.setString(_keyServerUrl, cleanUrl);
|
||||
await prefs.setString(_keyApiKey, apiKey.trim());
|
||||
}
|
||||
|
||||
Future<bool> hasSettings() async {
|
||||
final url = await getServerUrl();
|
||||
return url != null && url.isNotEmpty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/language.dart';
|
||||
import 'settings_service.dart';
|
||||
|
||||
class TranslateException implements Exception {
|
||||
final String message;
|
||||
TranslateException(this.message);
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class DetectResult {
|
||||
final String language;
|
||||
final int confidence;
|
||||
DetectResult({required this.language, required this.confidence});
|
||||
}
|
||||
|
||||
/// Kullanıcının kendi sunucusundaki LibreTranslate API'sine istek atar.
|
||||
class TranslateService {
|
||||
final SettingsService _settings;
|
||||
TranslateService(this._settings);
|
||||
|
||||
Future<String> _baseUrl() async {
|
||||
final url = await _settings.getServerUrl();
|
||||
if (url == null || url.isEmpty) {
|
||||
throw TranslateException('Sunucu adresi ayarlanmamış. Ayarlar\'dan ekleyin.');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _withApiKey(Map<String, dynamic> body, String? apiKey) {
|
||||
if (apiKey != null && apiKey.isNotEmpty) {
|
||||
body['api_key'] = apiKey;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
String _errorFromBody(String body, int statusCode) {
|
||||
try {
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is Map && decoded['error'] != null) {
|
||||
return decoded['error'].toString();
|
||||
}
|
||||
} catch (_) {
|
||||
// JSON değilse aşağıda genel mesaj kullanılır.
|
||||
}
|
||||
return 'Sunucu hatası (HTTP $statusCode)';
|
||||
}
|
||||
|
||||
/// Sunucuya bağlanıp desteklenen dilleri getirir.
|
||||
/// Hem "bağlantı testi" hem de dil listesini doldurmak için kullanılır.
|
||||
Future<List<Language>> fetchLanguages() async {
|
||||
final base = await _baseUrl();
|
||||
final apiKey = await _settings.getApiKey();
|
||||
final uri = Uri.parse('$base/languages').replace(
|
||||
queryParameters: apiKey != null ? {'api_key': apiKey} : null,
|
||||
);
|
||||
|
||||
final res = await http.get(uri).timeout(const Duration(seconds: 10));
|
||||
if (res.statusCode != 200) {
|
||||
throw TranslateException(_errorFromBody(res.body, res.statusCode));
|
||||
}
|
||||
final List<dynamic> data = jsonDecode(utf8.decode(res.bodyBytes));
|
||||
return data.map((e) => Language.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<String> translate({
|
||||
required String text,
|
||||
required String sourceLang,
|
||||
required String targetLang,
|
||||
}) async {
|
||||
if (text.trim().isEmpty) return '';
|
||||
final base = await _baseUrl();
|
||||
final apiKey = await _settings.getApiKey();
|
||||
|
||||
final body = _withApiKey({
|
||||
'q': text,
|
||||
'source': sourceLang,
|
||||
'target': targetLang,
|
||||
'format': 'text',
|
||||
}, apiKey);
|
||||
|
||||
final res = await http
|
||||
.post(
|
||||
Uri.parse('$base/translate'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(body),
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
throw TranslateException(_errorFromBody(res.body, res.statusCode));
|
||||
}
|
||||
final decoded = jsonDecode(utf8.decode(res.bodyBytes));
|
||||
return decoded['translatedText'] as String;
|
||||
}
|
||||
|
||||
Future<DetectResult?> detect(String text) async {
|
||||
if (text.trim().isEmpty) return null;
|
||||
final base = await _baseUrl();
|
||||
final apiKey = await _settings.getApiKey();
|
||||
|
||||
final body = _withApiKey({'q': text}, apiKey);
|
||||
|
||||
final res = await http
|
||||
.post(
|
||||
Uri.parse('$base/detect'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(body),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
throw TranslateException(_errorFromBody(res.body, res.statusCode));
|
||||
}
|
||||
final List<dynamic> data = jsonDecode(utf8.decode(res.bodyBytes));
|
||||
if (data.isEmpty) return null;
|
||||
final best = data.first as Map<String, dynamic>;
|
||||
return DetectResult(
|
||||
language: best['language'] as String,
|
||||
confidence: ((best['confidence'] ?? 0) as num).round(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppTheme {
|
||||
// Ana vurgu rengi: derin çivit/indigo — çeviri/dil temasıyla uyumlu, sakin ve okunur.
|
||||
static const _seed = Color(0xFF3849A6);
|
||||
|
||||
static ThemeData get light => ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: _seed),
|
||||
appBarTheme: const AppBarTheme(centerTitle: false, elevation: 0),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
);
|
||||
|
||||
static ThemeData get dark => ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: _seed,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(centerTitle: false, elevation: 0),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user