import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:path/path.dart' as p; import 'package:url_launcher/url_launcher.dart'; import '../../cekirdek/sabitler.dart'; import '../../cekirdek/kimlik.dart'; import '../../durum/proje_durumu.dart'; import '../../modeller/job.dart'; import '../../modeller/proje.dart'; import '../../modeller/segment.dart'; import '../../servisler/ai/ollama_prompt_zinciri.dart'; import '../../servisler/ffmpeg/ffmpeg_servis.dart'; import '../../servisler/ffmpeg/kare_cikarici.dart'; import '../../servisler/job_orkestratoru.dart'; import '../../servisler/ltx/ltx_baglanti_servisi.dart'; import '../../servisler/ltx/ltx_client.dart'; import '../../servisler/wangp/wangp_baslatici.dart'; import '../../durum/aktif_job_durumu.dart'; import '../../servisler/segment_olusturucu.dart'; import 'ab_karsilastirma.dart'; import 'wangp_baslat_karti.dart'; import 'wangp_coklu_konusmaci_paneli.dart'; import 'wangp_gelismis_panel.dart'; import 'wangp_profil_secici.dart'; import 'wangp_video_sihirbazi.dart'; import 'lipsync_duzeltici_dialog.dart'; import '../ortak/lora_yoneticisi_modal.dart'; import '../ortak/debounce_text_alani.dart'; import '../ortak/mini_ses_oynatici.dart'; import '../ortak/prompt_alani_gecmisli.dart'; import '../ortak/ses_aralik_oynatici.dart'; class InspectorPaneli extends ConsumerWidget { const InspectorPaneli({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final proje = ref.watch(projeProvider); final secId = ref.watch(secilenSegmentIdProvider); if (proje == null || secId == null) { return const Center( child: Padding( padding: EdgeInsets.all(20), child: Text( 'Bir segment seçin', style: TextStyle(color: Colors.grey), ), ), ); } final seg = proje.segmentler.firstWhere( (s) => s.id == secId, orElse: () => Segment(id: '', sira: -1, basla: 0, bit: 0), ); if (seg.id.isEmpty) return const SizedBox.shrink(); return SingleChildScrollView( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary, borderRadius: BorderRadius.circular(4), ), child: Text( 'S${seg.sira + 1}', style: const TextStyle( color: Colors.black, fontWeight: FontWeight.bold, fontSize: 11, ), ), ), const SizedBox(width: 8), Text( '${(seg.bit - seg.basla).toStringAsFixed(1)}s', style: Theme.of(context).textTheme.labelSmall, ), const Spacer(), if (seg.analizMeta?.enerjiSeviyesi != null) Chip( label: Text( seg.analizMeta!.enerjiSeviyesi!, style: const TextStyle(fontSize: 10), ), padding: EdgeInsets.zero, ), ], ), const SizedBox(height: 12), _AktifJobBar(seg: seg), _Alan( etiket: 'MOTOR', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SegmentedButton( segments: const [ ButtonSegment( value: SegmentMotor.ltx, label: Text('LTX Desktop'), icon: Icon(Icons.movie_outlined, size: 14)), ButtonSegment( value: SegmentMotor.wangp, label: Text('WanGP Standalone'), icon: Icon(Icons.tune, size: 14)), ], selected: {seg.motor}, onSelectionChanged: (s) => _guncelle(ref, seg, (x) => x.copyWith(motor: s.first)), showSelectedIcon: false, ), if (seg.motor == SegmentMotor.wangp) ...[ const SizedBox(height: 6), Text( 'WanGP Standalone: tam kontrol (175 model + LoRA + CFG + multipliers). ' 'Port 7861 açık olmalı — top bar\'daki "WanGP başlat" rozetinden başlat.', style: Theme.of(context).textTheme.labelSmall?.copyWith( color: Theme.of(context).colorScheme.primary, ), ), ] else ...[ const SizedBox(height: 6), Text( 'LTX Desktop: kısıtlı parametre seti. Tam kontrol + ' 'standart LoRA için WanGP Standalone\'a geç.', style: Theme.of(context).textTheme.labelSmall, ), const SizedBox(height: 8), // LTX Desktop LoRA durumu — backend GenerateVideoRequest'inde // kullanıcı LoRA alanı YOK. Sadece IC-LoRA pipeline ayrı. // GPU Rahat Modu butonu — LTX Desktop ayarını sakinleştir Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.green.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(6), border: Border.all( color: Colors.green.withValues(alpha: 0.3)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.thermostat, size: 14, color: Colors.green), const SizedBox(width: 6), Text('GPU Rahat Modu', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w700, color: Colors.green.shade700)), ], ), const SizedBox(height: 4), Text( 'LTX Desktop\'ın spatial upscaler\'ını ve prompt ' 'enhancer\'ını kapatır. GPU yükü %30-50 azalır, ' 'sıcaklık 10-15°C düşer. Kalite biraz azalır ' '(upscale yok). Kalıcı ayar.', style: Theme.of(context) .textTheme .labelSmall ?.copyWith(color: Colors.green.shade900), ), const SizedBox(height: 6), Row( children: [ Expanded( child: FilledButton.tonalIcon( icon: const Icon(Icons.ac_unit, size: 14), label: const Text('Rahat Modu Uygula', style: TextStyle(fontSize: 11)), onPressed: () async { final cli = LtxClient( url: ref.read(ltxBaglantiProvider).url, token: ref.read(ltxBaglantiProvider).token); final ok = await cli.rahatModuUygula(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(ok ? '✓ Rahat modu uygulandı — render artık az GPU yorulacak' : '✗ LTX Desktop bağlantı hatası — açık mı?')), ); } }, ), ), const SizedBox(width: 6), Expanded( child: OutlinedButton.icon( icon: const Icon( Icons.high_quality, size: 14), label: const Text('Kalite Modu', style: TextStyle(fontSize: 11)), onPressed: () async { final cli = LtxClient( url: ref.read(ltxBaglantiProvider).url, token: ref.read(ltxBaglantiProvider).token); final ok = await cli.kaliteModuUygula(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(ok ? '✓ Kalite modu — upscaler+enhancer açıldı' : '✗ Bağlantı hatası')), ); } }, ), ), ], ), ], ), ), // LoRA Notu Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Theme.of(context) .colorScheme .tertiaryContainer .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(6), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.info_outline, size: 14, color: Theme.of(context) .colorScheme .tertiary), const SizedBox(width: 6), Text('LoRA Notu', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w700, color: Theme.of(context) .colorScheme .tertiary)), const Spacer(), TextButton.icon( icon: const Icon( Icons.bookmark_add_outlined, size: 12), label: const Text('LoRA Yöneticisi', style: TextStyle(fontSize: 10)), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 0), minimumSize: const Size(0, 24), ), onPressed: () => LoraYoneticisiModal.goster( context, LoraModulu.video), ), ], ), const SizedBox(height: 4), Text( 'LTX Desktop backend (port 8001) standart kullanıcı ' 'LoRA\'yı henüz desteklemiyor — sadece IC-LoRA ' '(canny/pose/depth control) ayrı endpoint\'le. ' 'Stil/karakter LoRA kullanmak için motoru WanGP\'ye ' 'çevir.', style: Theme.of(context) .textTheme .labelSmall ?.copyWith( color: Theme.of(context) .colorScheme .onTertiaryContainer), ), ], ), ), ], ], ), ), Padding( padding: const EdgeInsets.only(bottom: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text('PROMPT (boş = global)', style: Theme.of(context).textTheme.labelSmall), const Spacer(), IconButton( icon: const Icon(Icons.auto_fix_high, size: 14), tooltip: 'Video Üretim Sihirbazı (chip\'lerle detaylı seçim)', padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 24, minHeight: 24), onPressed: () => WanGpVideoSihirbazi.goster( context, segment: seg, guncelle: (fn) => _guncelle(ref, seg, fn), ), ), IconButton( icon: const Icon(Icons.bookmark_add_outlined, size: 14), tooltip: 'Video LoRA Yöneticisi (LTX + WanGP ortak klasör)', padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 24, minHeight: 24), onPressed: () => LoraYoneticisiModal.goster( context, LoraModulu.video), ), IconButton( icon: const Icon(Icons.auto_awesome, size: 14), tooltip: 'Ollama ile öner', padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 24, minHeight: 24), onPressed: () => _aiPromptOner(context, ref, seg, proje), ), ], ), const SizedBox(height: 4), PromptAlaniGecmisli( kimlik: 'prompt-${seg.id}', baslangicDeger: seg.prompt, hint: proje.global.varsayilanPrompt, maxSatir: 3, onDegisti: (v) => _guncelle(ref, seg, (s) => s.copyWith(prompt: v)), ), ], ), ), if (seg.motor == SegmentMotor.wangp) ...[ // WanGP kapalıysa belirgin başlatma kartı (ACE-Step paterni gibi) const WanGpBaslatKarti(sadeceKapaliyken: false), _Alan( etiket: 'WANGP REFERANS YÜZ', child: Row(children: [ Expanded( child: Text( seg.wangpReferansYuzPath != null ? p.basename(seg.wangpReferansYuzPath!) : 'Seçilmedi', style: const TextStyle(fontSize: 11), overflow: TextOverflow.ellipsis, ), ), IconButton( icon: const Icon(Icons.folder_open, size: 16), onPressed: () async { final r = await FilePicker.platform.pickFiles(type: FileType.image); if (r?.files.single.path != null) { _guncelle(ref, seg, (x) => x.copyWith(wangpReferansYuzPath: r!.files.single.path)); } }, ), if (seg.wangpReferansYuzPath != null) IconButton( icon: const Icon(Icons.clear, size: 16), onPressed: () => _guncelle(ref, seg, (x) => x.copyWith(wangpReferansYuzPath: null)), ), ]), ), _Alan( etiket: 'WANGP LoRA (opsiyonel)', child: DebounceTextAlani( kimlik: 'wangp-lora-${seg.id}', baslangicDeger: seg.wangpLoraPath, hint: 'LoRA dosyası yolu', onDegisti: (v) => _guncelle(ref, seg, (x) => x.copyWith(wangpLoraPath: v)), ), ), WanGpCokluKonusmaciPaneli( segment: seg, guncelle: (fn) => _guncelle(ref, seg, fn), ), WanGpProfilSecici( segment: seg, guncelle: (fn) => _guncelle(ref, seg, fn), ), WanGpGelismisPanel( segment: seg, guncelle: (fn) => _guncelle(ref, seg, fn), ), ], _Alan( etiket: 'NEGATİF PROMPT', child: DebounceTextAlani( kimlik: 'neg-${seg.id}', baslangicDeger: seg.negatifPrompt, hint: proje.global.varsayilanNegatifPrompt, maxSatir: 2, onDegisti: (v) => _guncelle(ref, seg, (s) => s.copyWith(negatifPrompt: v)), ), ), _Alan( etiket: 'SÜRE', child: SegmentedButton( segments: const [ ButtonSegment(value: null, label: Text('Otom')), ButtonSegment(value: 5, label: Text('5s')), ButtonSegment(value: 6, label: Text('6s')), ButtonSegment(value: 8, label: Text('8s')), ButtonSegment(value: 10, label: Text('10s')), ButtonSegment(value: 20, label: Text('20s')), ], selected: {seg.sure}, onSelectionChanged: (s) => _guncelle(ref, seg, (x) => x.copyWith(sure: s.first)), showSelectedIcon: false, ), ), _Alan( etiket: 'ÇÖZÜNÜRLÜK', child: DropdownButtonFormField( value: seg.cozunurluk, hint: Text('Global: ${proje.global.cozunurluk}'), items: [ const DropdownMenuItem(value: null, child: Text('Global')), ...Sabitler.cozunurlukler.map( (c) => DropdownMenuItem(value: c, child: Text(c)), ), ], onChanged: (v) => _guncelle(ref, seg, (s) => s.copyWith(cozunurluk: v)), ), ), _Alan( etiket: 'ORAN', child: SegmentedButton( segments: const [ ButtonSegment(value: null, label: Text('Global')), ButtonSegment(value: '16:9', label: Text('16:9')), ButtonSegment(value: '9:16', label: Text('9:16')), ], selected: {seg.oran}, onSelectionChanged: (v) => _guncelle(ref, seg, (s) => s.copyWith(oran: v.first)), showSelectedIcon: false, ), ), _Alan( etiket: 'MODEL', child: SegmentedButton( segments: const [ ButtonSegment(value: null, label: Text('Global')), ButtonSegment(value: 'fast', label: Text('Fast')), ButtonSegment(value: 'pro', label: Text('Pro')), ], selected: {seg.model}, onSelectionChanged: (v) => _guncelle(ref, seg, (s) => s.copyWith(model: v.first)), showSelectedIcon: false, ), ), _Alan( etiket: 'GUIDANCE SCALE (CFG)${seg.guidanceScale != null ? " · özel" : " · global (${proje.global.guidanceScale.toStringAsFixed(1)})"}', child: Row( children: [ Expanded( child: Slider( value: seg.guidanceScale ?? proje.global.guidanceScale, min: 1, max: 15, divisions: 28, label: (seg.guidanceScale ?? proje.global.guidanceScale) .toStringAsFixed(1), onChanged: (v) => _guncelle( ref, seg, (s) => s.copyWith(guidanceScale: v)), ), ), if (seg.guidanceScale != null) IconButton( icon: const Icon(Icons.refresh, size: 16), tooltip: 'Global\'e döndür', padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), onPressed: () => _guncelle( ref, seg, (s) => s.copyWith(guidanceScale: null)), ), ], ), ), _Alan( etiket: 'STEPS${seg.steps != null ? " · özel" : " · global (${proje.global.steps})"}', child: Row( children: [ Expanded( child: Slider( value: (seg.steps ?? proje.global.steps).toDouble(), min: 10, max: 50, divisions: 40, label: (seg.steps ?? proje.global.steps).toString(), onChanged: (v) => _guncelle( ref, seg, (s) => s.copyWith(steps: v.round())), ), ), if (seg.steps != null) IconButton( icon: const Icon(Icons.refresh, size: 16), tooltip: 'Global\'e döndür', padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), onPressed: () => _guncelle(ref, seg, (s) => s.copyWith(steps: null)), ), ], ), ), _Alan( etiket: 'KAMERA HAREKETİ', child: DropdownButtonFormField( value: seg.kameraHareketi, hint: Text('Global: ${proje.global.kameraHareketi}'), items: [ const DropdownMenuItem(value: null, child: Text('Global')), ...Sabitler.kameraHareketleri.map( (k) => DropdownMenuItem(value: k, child: Text(k)), ), ], onChanged: (v) => _guncelle(ref, seg, (s) => s.copyWith(kameraHareketi: v)), ), ), _Alan( etiket: 'SES', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Switch( value: seg.ses ?? proje.global.ses, onChanged: (v) => _guncelle(ref, seg, (s) => s.copyWith(ses: v)), ), const SizedBox(width: 8), Text( (seg.ses ?? proje.global.ses) ? 'Açık (lipsync)' : 'Kapalı (sessiz video)', style: const TextStyle(fontSize: 12), ), ], ), const SizedBox(height: 6), _SesOverrideKutusu( seg: seg, proje: proje, onGuncelle: (g) => _guncelle(ref, seg, g), ), ], ), ), _Alan( etiket: 'GİRİŞ GÖRSELİ', child: _GirisGorseliBolumu(seg: seg, proje: proje, onGuncelle: (g) => _guncelle(ref, seg, g)), ), _Alan( etiket: 'FACE SWAP', child: _FaceSwapKutusu( seg: seg, proje: proje, onGuncelle: (g) => _guncelle(ref, seg, g), ), ), _Alan( etiket: 'SEED', child: Row( children: [ Expanded( child: DebounceTextAlani( kimlik: 'seed-${seg.id}', baslangicDeger: seg.seed?.toString(), hint: 'Rastgele', klavye: TextInputType.number, onDegisti: (v) => _guncelle( ref, seg, (s) => s.copyWith(seed: v == null ? null : int.tryParse(v)), ), ), ), IconButton( icon: const Icon(Icons.shuffle, size: 18), onPressed: () => _guncelle( ref, seg, (s) => s.copyWith(seed: DateTime.now().microsecond), ), ), ], ), ), const SizedBox(height: 16), Row( children: [ Expanded( child: FilledButton.icon( icon: const Icon(Icons.play_arrow, size: 16), label: Text( seg.durum == SegmentDurum.tamam ? 'Yeniden Üret' : 'Üret', ), onPressed: () => _uret(context, ref, proje, seg), ), ), const SizedBox(width: 8), TextButton.icon( icon: const Icon(Icons.auto_mode, size: 14), label: const Text('5 Seed', style: TextStyle(fontSize: 11)), onPressed: () => _seedBatch(context, ref, proje, seg), ), const SizedBox(width: 8), IconButton.outlined( tooltip: 'Bu ayarları diğer segmentlere kopyala', icon: const Icon(Icons.content_copy, size: 16), onPressed: () => _ayarlariYayPaylas(context, ref, seg, proje), ), ], ), const SizedBox(height: 8), OutlinedButton.icon( icon: const Icon(Icons.fast_forward, size: 14), label: Text( 'Bu ve sonrakileri üret ' '(${proje.segmentler.length - seg.sira} segment, sıralı)', style: const TextStyle(fontSize: 11), ), onPressed: () => _buVeSonrakileriUret(context, ref, proje, seg), ), _Alan( etiket: 'NOTLAR (kullanıcı, render\'a etki etmez)', child: DebounceTextAlani( kimlik: 'notlar-${seg.id}', baslangicDeger: seg.notlar, hint: 'Bu segment hakkında not...', maxSatir: 2, onDegisti: (v) => _guncelle(ref, seg, (s) => s.copyWith(notlar: v ?? '')), ), ), if (seg.varyantYollari.isNotEmpty) ...[ const SizedBox(height: 16), Row( children: [ Text('VARYANTLAR (${seg.varyantYollari.length})', style: Theme.of(context).textTheme.labelSmall), const Spacer(), // Lipsync düzeltici — render edilmiş video varsa göster if (seg.videoYolu != null) ...[ TextButton.icon( icon: const Icon(Icons.auto_fix_high, size: 12), label: const Text('Lipsync Düzelt', style: TextStyle(fontSize: 11)), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), minimumSize: const Size(60, 24), ), onPressed: () => LipsyncDuzelticiDialog.goster( context, segment: seg, proje: proje, ), ), ], if (seg.varyantYollari.length > 1) ...[ TextButton.icon( icon: const Icon(Icons.compare, size: 12), label: const Text('A/B karşılaştır', style: TextStyle(fontSize: 11)), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), minimumSize: const Size(60, 24), ), onPressed: () => AbKarsilastirma.goster( context, videoYollari: seg.varyantYollari.length > 4 ? seg.varyantYollari.sublist(seg.varyantYollari.length - 4) : seg.varyantYollari, etiketler: List.generate( seg.varyantYollari.length, (i) => 'v${i + 1}', ), ), ), TextButton.icon( icon: const Icon(Icons.cleaning_services, size: 12), label: const Text('Eskileri sil', style: TextStyle(fontSize: 11)), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), minimumSize: const Size(60, 24), ), onPressed: () => _sadeceSonVaryantTut(context, ref, seg), ), ], ], ), const SizedBox(height: 4), Wrap( spacing: 6, runSpacing: 4, children: List.generate(seg.varyantYollari.length, (i) { final yol = seg.varyantYollari[i]; final secili = seg.videoYolu == yol; return InputChip( label: Text('v${i + 1}'), selected: secili, onPressed: () => _varyantSec(context, ref, seg, yol), onDeleted: () => _varyantSil(context, ref, seg, yol), deleteIcon: const Icon(Icons.close, size: 14), deleteIconColor: Colors.red, ); }), ), ], ], ), ); } Future _uret( BuildContext context, WidgetRef ref, Proje _projeIgnore, Segment _segIgnore, ) async { if (!await _herhangiMotorAcikMi(context, ref)) return; // Closure yerine en güncel state'i oku — slider'da yapılan değişiklikler // henüz disk'e yazılmamış olsa bile state'te olur. final notifier = ref.read(projeProvider.notifier); await notifier.hemenKaydet(); final proje = ref.read(projeProvider)!; final seg = proje.segmentler.firstWhere((s) => s.id == _segIgnore.id); final harita = await SegmentOlusturucu.sesParcalariniHazirla(proje); await JobOrkestratoru.ornek.ekle(Job( id: FikoYTId.yeni(), tip: JobTipi.segmentUret, projeId: proje.id, params: { 'segmentId': seg.id, 'sesYolu': harita[seg.id], 'girisGorseli': proje.kaynak.girisGorseliPath, }, oncelik: 200, )); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( 'S${seg.sira + 1} kuyruğa eklendi (${seg.basla.toStringAsFixed(1)}-${seg.bit.toStringAsFixed(1)}s)'), duration: const Duration(seconds: 2), ), ); } } /// Varyant chip tıklandığında: videoYolu güncellenir + ffmpeg ile yeni /// videodan son kare çıkarılıp `sonKareYolu` güncellenir. Bu sayede bir /// sonraki segment yeni varyantın son karesini giriş görseli olarak alır. Future _varyantSec( BuildContext context, WidgetRef ref, Segment seg, String yol, ) async { // 1. videoYolu'nu hemen değiştir (UI tepki versin) _guncelle(ref, seg, (s) => s.copyWith(videoYolu: yol)); // 2. ffmpeg ile yeni varyantın son karesini çıkar (zaman uyumsuz) if (!File(yol).existsSync()) return; try { final dosyaAdi = p.basenameWithoutExtension(yol); final klasor = p.dirname(yol); final sonKarePath = p.join(klasor, '${dosyaAdi}_son_kare.png'); final sure = (seg.bit - seg.basla); await KareCikarici.sonKareCikar(yol, sonKarePath, sure: sure); if (File(sonKarePath).existsSync()) { _guncelle(ref, seg, (s) => s.copyWith(sonKareYolu: sonKarePath)); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( 'v${seg.varyantYollari.indexOf(yol) + 1} aktif — ' 'son kare güncellendi (sonraki segment için)'), duration: const Duration(seconds: 2), ), ); } } } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Son kare çıkartma hatası: $e'), backgroundColor: Colors.orange, ), ); } } } Future _varyantSil( BuildContext context, WidgetRef ref, Segment seg, String yol, ) async { final onay = await showDialog( context: context, builder: (c) => AlertDialog( title: const Text('Varyantı sil?'), content: Text('Bu video dosyası kalıcı olarak silinecek:\n\n${p.basename(yol)}'), actions: [ TextButton( onPressed: () => Navigator.pop(c, false), child: const Text('İptal')), FilledButton( style: FilledButton.styleFrom(backgroundColor: Colors.red), onPressed: () => Navigator.pop(c, true), child: const Text('Sil')), ], ), ); if (onay != true) return; try { final f = File(yol); if (await f.exists()) await f.delete(); } catch (_) {} _guncelle(ref, seg, (s) { final yeniListe = s.varyantYollari.where((v) => v != yol).toList(); return s.copyWith( varyantYollari: yeniListe, videoYolu: s.videoYolu == yol ? (yeniListe.isEmpty ? null : yeniListe.last) : s.videoYolu, durum: yeniListe.isEmpty ? SegmentDurum.bos : s.durum, ); }); } Future _sadeceSonVaryantTut( BuildContext context, WidgetRef ref, Segment seg, ) async { if (seg.varyantYollari.length <= 1) return; final onay = await showDialog( context: context, builder: (c) => AlertDialog( title: const Text('Eski varyantları sil?'), content: Text( 'Sadece son üretilen varyant kalır. ${seg.varyantYollari.length - 1} dosya kalıcı silinecek.'), actions: [ TextButton( onPressed: () => Navigator.pop(c, false), child: const Text('İptal')), FilledButton( style: FilledButton.styleFrom(backgroundColor: Colors.red), onPressed: () => Navigator.pop(c, true), child: const Text('Sil')), ], ), ); if (onay != true) return; final son = seg.varyantYollari.last; for (final v in seg.varyantYollari) { if (v == son) continue; try { final f = File(v); if (await f.exists()) await f.delete(); } catch (_) {} } _guncelle( ref, seg, (s) => s.copyWith( varyantYollari: [son], videoYolu: son, ), ); } void _guncelle( WidgetRef ref, Segment seg, Segment Function(Segment) fn, ) { ref.read(projeProvider.notifier).guncelle((p) { return p.copyWith( segmentler: p.segmentler .map((s) => s.id == seg.id ? fn(s) : s) .toList(), ); }); } void _ayarlariYayPaylas( BuildContext context, WidgetRef ref, Segment seg, Proje proje, ) async { final secimler = {}; await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, setS) => AlertDialog( title: const Text('Hangi segmentlere uygulansın?'), content: SizedBox( width: 400, child: Column( mainAxisSize: MainAxisSize.min, children: [ Wrap( spacing: 6, children: proje.segmentler .where((s) => s.id != seg.id) .map((s) => FilterChip( label: Text('S${s.sira + 1}'), selected: secimler.contains(s.id), onSelected: (v) => setS(() { v ? secimler.add(s.id) : secimler.remove(s.id); }), )) .toList(), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('İptal'), ), FilledButton( onPressed: () { ref.read(projeProvider.notifier).guncelle((p) { return p.copyWith( segmentler: p.segmentler.map((s) { if (!secimler.contains(s.id)) return s; return s.copyWith( prompt: seg.prompt, negatifPrompt: seg.negatifPrompt, model: seg.model, cozunurluk: seg.cozunurluk, oran: seg.oran, sure: seg.sure, kameraHareketi: seg.kameraHareketi, guidanceScale: seg.guidanceScale, steps: seg.steps, ses: seg.ses, motor: seg.motor, wangpReferansYuzPath: seg.wangpReferansYuzPath, wangpLoraPath: seg.wangpLoraPath, ); }).toList(), ); }); Navigator.pop(ctx); }, child: const Text('Uygula'), ), ], ), ), ); } Future _aiPromptOner( BuildContext context, WidgetRef ref, Segment seg, Proje proje, ) async { showDialog( context: context, barrierDismissible: false, builder: (_) => const AlertDialog( content: Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), SizedBox(width: 12), Text('Ollama düşünüyor…'), ], ), ), ); final ollama = OllamaPromptZinciri(); if (!await ollama.sagliklimi()) { if (context.mounted) Navigator.of(context).pop(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Ollama sunucusuna bağlanılamadı (127.0.0.1:11434)')), ); } return; } final prompts = await ollama.zincirOlustur( segmentler: [ { 'sira': seg.sira, 'sure': seg.bit - seg.basla, 'enerji': seg.analizMeta?.enerjiSeviyesi ?? '', 'mevcut': seg.prompt ?? '', 'basla': seg.basla, 'bit': seg.bit, }, ], tema: proje.global.varsayilanPrompt, stil: 'sinematik', ); if (context.mounted) Navigator.of(context).pop(); final oneri = prompts.isNotEmpty ? prompts.first.trim() : ''; if (oneri.isEmpty) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Ollama boş yanıt döndü')), ); } return; } if (!context.mounted) return; final uygula = await showDialog( context: context, builder: (c) => AlertDialog( title: const Text('Önerilen prompt'), content: SingleChildScrollView(child: SelectableText(oneri)), actions: [ TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('İptal')), FilledButton(onPressed: () => Navigator.pop(c, true), child: const Text('Uygula')), ], ), ); if (uygula == true) { _guncelle(ref, seg, (s) => s.copyWith(prompt: oneri)); } } Future _seedBatch( BuildContext context, WidgetRef ref, Proje proje, Segment seg, ) async { final ltx = ref.read(ltxBaglantiProvider); if (!ltx.baglandi) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( backgroundColor: Colors.red.shade700, content: Text( ltx.hata ?? 'LTX Desktop\'a bağlanılamadı', style: const TextStyle(color: Colors.white), ), ), ); return; } await ref.read(projeProvider.notifier).hemenKaydet(); final harita = await SegmentOlusturucu.sesParcalariniHazirla(proje); for (int k = 0; k < 5; k++) { final seed = DateTime.now().microsecondsSinceEpoch + k * 1000; await JobOrkestratoru.ornek.ekle(Job( id: FikoYTId.yeni(), tip: JobTipi.segmentUret, projeId: proje.id, params: { 'segmentId': seg.id, 'sesYolu': harita[seg.id], 'girisGorseli': proje.kaynak.girisGorseliPath, 'seedOverride': seed, }, )); } if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('5 varyant kuyruğa eklendi')), ); } } /// Seçili segmentten itibaren tüm sonraki segmentleri sıralı kuyruğa atar. /// Önceki segment son karesi → sonraki giriş görseli zinciri korunur. Future _buVeSonrakileriUret( BuildContext context, WidgetRef ref, Proje proje, Segment seg, ) async { final ltx = ref.read(ltxBaglantiProvider); if (!ltx.baglandi) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( backgroundColor: Colors.red.shade700, content: Text( ltx.hata ?? 'LTX Desktop\'a bağlanılamadı', style: const TextStyle(color: Colors.white), ), ), ); return; } final hedefler = proje.segmentler .where((s) => s.sira >= seg.sira) .toList() ..sort((a, b) => a.sira.compareTo(b.sira)); if (hedefler.isEmpty) return; final onay = await showDialog( context: context, builder: (c) => AlertDialog( title: Text('S${seg.sira + 1} ve sonrasını üret'), content: Text( '${hedefler.length} segment sıralı olarak kuyruğa eklenecek.\n\n' 'Sıralı çalıştığı için her segmentin sonu, bir sonrakinin ' 'giriş görseli olacak (zincir devam eder).', ), actions: [ TextButton( onPressed: () => Navigator.pop(c, false), child: const Text('İptal')), FilledButton( onPressed: () => Navigator.pop(c, true), child: const Text('Başlat')), ], ), ); if (onay != true || !context.mounted) return; await ref.read(projeProvider.notifier).hemenKaydet(); final guncelProje = ref.read(projeProvider)!; final harita = await SegmentOlusturucu.sesParcalariniHazirla(guncelProje); for (final s in hedefler) { await JobOrkestratoru.ornek.ekle(Job( id: FikoYTId.yeni(), tip: JobTipi.segmentUret, projeId: guncelProje.id, params: { 'segmentId': s.id, 'sesYolu': harita[s.id], 'girisGorseli': guncelProje.kaynak.girisGorseliPath, }, oncelik: 1000 - s.sira, // sıralı garantisi )); } if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( '${hedefler.length} segment sıralı kuyruğa eklendi (zincir korunur)'), ), ); } } } /// LTX veya WanGP'den herhangi biri açık mı? Değilse SnackBar göster. Future _herhangiMotorAcikMi(BuildContext context, WidgetRef ref) async { final ltx = ref.read(ltxBaglantiProvider); final wangpAcik = await WanGpBaslatici.calisiyorMu(); if (ltx.baglandi || wangpAcik) return true; if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( backgroundColor: Colors.red.shade700, content: const Text( 'Hiçbir motor açık değil. LTX-Desktop-WanGP-Baslat.bat veya ' 'WanGP-Standalone-Baslat.bat çalıştır.', style: TextStyle(color: Colors.white), ), action: SnackBarAction( label: 'AYARLAR', textColor: Colors.white, onPressed: () => context.go('/ayarlar'), ), duration: const Duration(seconds: 6), ), ); } return false; } /// (Kullanılmıyor — eski sürüm) // ignore: unused_element class _SesAraligiVeOynatici extends StatelessWidget { final Segment seg; final Proje proje; final void Function(Segment Function(Segment)) onGuncelle; const _SesAraligiVeOynatici({ required this.seg, required this.proje, required this.onGuncelle, }); @override Widget build(BuildContext context) { final sesYolu = proje.kaynak.sesPath; final sesVar = sesYolu.isNotEmpty && File(sesYolu).existsSync(); final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (sesVar) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(6), border: Border.all(color: theme.dividerColor), ), child: SesAralikOynatici( key: ValueKey('aralik-${seg.id}'), dosyaYolu: sesYolu, basla: seg.basla, bit: seg.bit, onAralikDegisti: (b, e) => onGuncelle((s) => s.copyWith(basla: b, bit: e)), ), ) else _SesAraligi(seg: seg, proje: proje, onGuncelle: onGuncelle), const SizedBox(height: 6), Wrap( spacing: 4, runSpacing: 4, children: [ for (final sure in const [5, 6, 8, 10, 20]) _hizliBtn(context, sure), ], ), const SizedBox(height: 4), Text( 'Tutamakları sürükle ile başla/bit ayarla. Kırmızı çizgi oynama konumu. İzinli üretim süreleri: 5/6/8/10/20s.', style: theme.textTheme.labelSmall, ), ], ); } Widget _hizliBtn(BuildContext context, int sure) { final mevcut = (seg.bit - seg.basla).abs(); final aktif = (mevcut - sure).abs() < 0.05; return InkWell( onTap: () => onGuncelle((s) => s.copyWith(bit: s.basla + sure)), child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: aktif ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), border: Border.all(color: Theme.of(context).dividerColor), ), child: Text( '${sure}s', style: TextStyle( fontSize: 11, color: aktif ? Colors.black : null, ), ), ), ); } } class _SesAraligi extends StatefulWidget { final Segment seg; final Proje proje; final void Function(Segment Function(Segment)) onGuncelle; const _SesAraligi({ required this.seg, required this.proje, required this.onGuncelle, }); @override State<_SesAraligi> createState() => _SesAraligiState(); } class _SesAraligiState extends State<_SesAraligi> { RangeValues? _surukleDeger; String _format(double sn) { final dk = (sn ~/ 60).toString().padLeft(2, '0'); final s = (sn % 60).toStringAsFixed(2).padLeft(5, '0'); return '$dk:$s'; } /// Şarkı veya proje toplam süresi (max). double _toplamSure() { final sonSeg = widget.proje.segmentler.isEmpty ? widget.seg.bit : widget.proje.segmentler .map((s) => s.bit) .reduce((a, b) => a > b ? a : b); return sonSeg < 5 ? 5 : sonSeg + 30; // En azından 30s daha esnetilebilir } void _hizliAyarla(double sure) { final yeniBit = widget.seg.basla + sure; widget.onGuncelle((s) => s.copyWith(basla: s.basla, bit: yeniBit)); } Future _baslangiciDuzenle(BuildContext ctx) async { final ctrl = TextEditingController(text: widget.seg.basla.toStringAsFixed(2)); final r = await showDialog( context: ctx, builder: (c) => AlertDialog( title: const Text('Başlangıç saniyesi'), content: TextField( controller: ctrl, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration(suffixText: 'sn'), autofocus: true, ), actions: [ TextButton(onPressed: () => Navigator.pop(c), child: const Text('İptal')), FilledButton( onPressed: () => Navigator.pop(c, double.tryParse(ctrl.text)), child: const Text('Uygula'), ), ], ), ); if (r != null && r >= 0 && r < widget.seg.bit) { widget.onGuncelle((s) => s.copyWith(basla: r)); } } Future _bitisiDuzenle(BuildContext ctx) async { final ctrl = TextEditingController(text: widget.seg.bit.toStringAsFixed(2)); final r = await showDialog( context: ctx, builder: (c) => AlertDialog( title: const Text('Bitiş saniyesi'), content: TextField( controller: ctrl, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration(suffixText: 'sn'), autofocus: true, ), actions: [ TextButton(onPressed: () => Navigator.pop(c), child: const Text('İptal')), FilledButton( onPressed: () => Navigator.pop(c, double.tryParse(ctrl.text)), child: const Text('Uygula'), ), ], ), ); if (r != null && r > widget.seg.basla) { widget.onGuncelle((s) => s.copyWith(bit: r)); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final maks = _toplamSure(); final basla = (_surukleDeger?.start ?? widget.seg.basla).clamp(0.0, maks); final bit = (_surukleDeger?.end ?? widget.seg.bit).clamp(0.01, maks); final sure = bit - basla; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ InkWell( onTap: () => _baslangiciDuzenle(context), child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: Text(_format(basla), style: const TextStyle(fontSize: 11, fontFamily: 'monospace')), ), ), const Expanded( child: Center( child: Icon(Icons.arrow_right_alt, size: 16), ), ), InkWell( onTap: () => _bitisiDuzenle(context), child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: Text(_format(bit), style: const TextStyle(fontSize: 11, fontFamily: 'monospace')), ), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: theme.colorScheme.primary.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), ), child: Text( '${sure.toStringAsFixed(2)}s', style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, color: theme.colorScheme.primary, ), ), ), ], ), RangeSlider( values: RangeValues(basla, bit), min: 0, max: maks, divisions: maks > 0 ? (maks * 10).round() : 100, labels: RangeLabels(_format(basla), _format(bit)), onChanged: (v) => setState(() => _surukleDeger = v), onChangeEnd: (v) { widget.onGuncelle((s) => s.copyWith(basla: v.start, bit: v.end)); setState(() => _surukleDeger = null); }, ), Wrap( spacing: 4, runSpacing: 4, children: [ _hizliButon(context, '5s', 5), _hizliButon(context, '6s', 6), _hizliButon(context, '8s', 8), _hizliButon(context, '10s', 10), _hizliButon(context, '20s', 20), ], ), const SizedBox(height: 4), Text( 'LTX bu aralıktaki sesi alır. İzinli üretim süreleri: 5/6/8/10/20s.', style: theme.textTheme.labelSmall, ), ], ); } Widget _hizliButon(BuildContext context, String etiket, double sure) { final aktif = (widget.seg.bit - widget.seg.basla).abs() - sure < 0.05; return InkWell( onTap: () => _hizliAyarla(sure), child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: aktif ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), border: Border.all( color: Theme.of(context).dividerColor, ), ), child: Text( etiket, style: TextStyle( fontSize: 11, color: aktif ? Colors.black : null, ), ), ), ); } } class _AktifJobBar extends ConsumerWidget { final Segment seg; const _AktifJobBar({required this.seg}); @override Widget build(BuildContext context, WidgetRef ref) { final ajob = ref.watch(segmentAktifJobProvider(seg.id)); return ajob.maybeWhen( data: (j) { if (j == null) return const SizedBox.shrink(); final theme = Theme.of(context); final calisiyor = j.durum == JobDurum.calisiyor; return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: calisiyor ? theme.colorScheme.primary.withValues(alpha: 0.1) : theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), border: Border.all( color: calisiyor ? theme.colorScheme.primary : theme.dividerColor, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ if (calisiyor) SizedBox( width: 12, height: 12, child: CircularProgressIndicator( strokeWidth: 2, color: theme.colorScheme.primary, ), ) else Icon(Icons.schedule, size: 14, color: theme.colorScheme.outline), const SizedBox(width: 8), Expanded( child: Text( calisiyor ? 'ÜRETİLİYOR' : j.durum == JobDurum.kuyruk ? 'KUYRUKTA' : j.durum.name.toUpperCase(), style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, color: calisiyor ? theme.colorScheme.primary : theme.colorScheme.outline, ), ), ), Text( '%${j.ilerleme.toStringAsFixed(0)}', style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, color: theme.colorScheme.primary, ), ), ], ), const SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(3), child: LinearProgressIndicator( value: j.ilerleme / 100, minHeight: 6, ), ), if (j.fazAciklama.isNotEmpty) ...[ const SizedBox(height: 4), Text( j.fazAciklama, style: theme.textTheme.labelSmall, ), ], ], ), ); }, orElse: () => const SizedBox.shrink(), ); } } class _FaceSwapKutusu extends StatelessWidget { final Segment seg; final Proje proje; final void Function(Segment Function(Segment)) onGuncelle; const _FaceSwapKutusu({ required this.seg, required this.proje, required this.onGuncelle, }); /// Aktif yüz listesi: segment override → segment legacy single → /// global list → global single → proje List _aktifYuzler() { if (seg.faceSwapKaynakYollari.isNotEmpty) return seg.faceSwapKaynakYollari; if (seg.faceSwapKaynakOverridePath != null && seg.faceSwapKaynakOverridePath!.isNotEmpty) { return [seg.faceSwapKaynakOverridePath!]; } if (proje.global.faceSwapKaynakYollari.isNotEmpty) { return proje.global.faceSwapKaynakYollari; } if (proje.global.faceSwapKaynakPath != null && proje.global.faceSwapKaynakPath!.isNotEmpty) { return [proje.global.faceSwapKaynakPath!]; } if (proje.kaynak.faceSwapPath != null && proje.kaynak.faceSwapPath!.isNotEmpty) { return [proje.kaynak.faceSwapPath!]; } return []; } bool get _segmentOzelMi => seg.faceSwapKaynakYollari.isNotEmpty || (seg.faceSwapKaynakOverridePath != null && seg.faceSwapKaynakOverridePath!.isNotEmpty); @override Widget build(BuildContext context) { final theme = Theme.of(context); final aktif = seg.faceSwapAcik ?? proje.global.faceSwapAcik; final yuzler = _aktifYuzler(); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Switch( value: aktif, onChanged: (v) => onGuncelle((s) => s.copyWith(faceSwapAcik: v)), ), const SizedBox(width: 8), Text( aktif ? 'Açık' : 'Kapalı', style: const TextStyle(fontSize: 12), ), ], ), if (aktif) ...[ const SizedBox(height: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(6), border: Border.all(color: theme.dividerColor), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (yuzler.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Text( 'Yüz seçilmemiş — ✚ ile ekle', style: theme.textTheme.labelSmall, ), ) else ..._yuzKartlari(context, yuzler), const SizedBox(height: 6), Wrap( spacing: 4, runSpacing: 4, children: [ OutlinedButton.icon( icon: const Icon(Icons.add, size: 14), label: Text( yuzler.isEmpty ? 'Yüz ekle' : 'Yeni yüz ekle', style: const TextStyle(fontSize: 11), ), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4), minimumSize: const Size(60, 28), ), onPressed: () => _yuzEkle(), ), if (_segmentOzelMi) TextButton.icon( icon: const Icon(Icons.refresh, size: 12), label: const Text('Global\'e dön', style: TextStyle(fontSize: 11)), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), minimumSize: const Size(60, 24), ), onPressed: () => onGuncelle( (s) => s.copyWith( faceSwapKaynakYollari: const [], faceSwapKaynakOverridePath: null, ), ), ), ], ), if (yuzler.length > 1) ...[ const SizedBox(height: 6), Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: theme.colorScheme.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), ), child: Text( 'Çoklu yüz: Yüz #1 → soldaki kişi, Yüz #2 → ondan sonraki, vs. (sıralama önemli — yukarı/aşağı butonları)', style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.primary, ), ), ), ], ], ), ), ], ], ); } List _yuzKartlari(BuildContext context, List yuzler) { final theme = Theme.of(context); final aktif = _segmentOzelMi; return List.generate(yuzler.length, (i) { final yol = yuzler[i]; return Container( margin: const EdgeInsets.only(bottom: 4), padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(4), border: Border.all(color: theme.dividerColor), ), child: Row( children: [ // Thumb Container( width: 40, height: 40, decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), clipBehavior: Clip.antiAlias, child: File(yol).existsSync() ? Image.file(File(yol), fit: BoxFit.cover) : Icon(Icons.broken_image, color: theme.colorScheme.outline, size: 18), ), const SizedBox(width: 6), // Etiket Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: theme.colorScheme.primary, borderRadius: BorderRadius.circular(3), ), child: Text( 'Yüz #${i + 1}', style: const TextStyle( color: Colors.black, fontSize: 10, fontWeight: FontWeight.bold, ), ), ), const SizedBox(width: 6), Expanded( child: Text( p.basename(yol), overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11), ), ), // Yukarı/aşağı taşı (sadece segmentin kendi listesi düzenleniyor) if (aktif) ...[ if (i > 0) IconButton( icon: const Icon(Icons.arrow_upward, size: 14), tooltip: 'Yukarı taşı', padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 24, minHeight: 24), onPressed: () => _yuzlariSiraDegistir(yuzler, i, i - 1), ), if (i < yuzler.length - 1) IconButton( icon: const Icon(Icons.arrow_downward, size: 14), tooltip: 'Aşağı taşı', padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 24, minHeight: 24), onPressed: () => _yuzlariSiraDegistir(yuzler, i, i + 1), ), IconButton( icon: const Icon(Icons.close, size: 14), tooltip: 'Kaldır', padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 24, minHeight: 24), onPressed: () => _yuzKaldir(yuzler, i), ), ] else Padding( padding: const EdgeInsets.only(right: 4), child: Tooltip( message: 'Global ayardan miras', child: Icon(Icons.lock_outline, size: 12, color: theme.colorScheme.outline), ), ), ], ), ); }); } Future _yuzEkle() async { final r = await FilePicker.platform.pickFiles( type: FileType.image, allowMultiple: true, ); if (r == null) return; final yeniler = r.files .map((f) => f.path) .whereType() .toList(); if (yeniler.isEmpty) return; onGuncelle((s) { // Mevcut listeye ekle (segment kendi listesi varsa onu büyüt; yoksa yeniden başlat) final mevcut = s.faceSwapKaynakYollari.isNotEmpty ? s.faceSwapKaynakYollari : (s.faceSwapKaynakOverridePath != null ? [s.faceSwapKaynakOverridePath!] : []); return s.copyWith( faceSwapKaynakYollari: [...mevcut, ...yeniler], faceSwapKaynakOverridePath: null, // legacy alanı temizle ); }); } void _yuzKaldir(List mevcut, int idx) { final yeni = [...mevcut]..removeAt(idx); onGuncelle( (s) => s.copyWith( faceSwapKaynakYollari: yeni, faceSwapKaynakOverridePath: null, ), ); } void _yuzlariSiraDegistir(List mevcut, int eski, int yeni) { final liste = [...mevcut]; final ele = liste.removeAt(eski); liste.insert(yeni, ele); onGuncelle( (s) => s.copyWith( faceSwapKaynakYollari: liste, faceSwapKaynakOverridePath: null, ), ); } } class _SesOverrideKutusu extends StatelessWidget { final Segment seg; final Proje proje; final void Function(Segment Function(Segment)) onGuncelle; const _SesOverrideKutusu({ required this.seg, required this.proje, required this.onGuncelle, }); /// Aktif ses dosyası: önce override, yoksa proje sesi. String _aktifSesYolu() { if (seg.sesOverridePath != null && seg.sesOverridePath!.isNotEmpty) { return seg.sesOverridePath!; } return proje.kaynak.sesPath; } bool get _overrideAktif => seg.sesOverridePath != null && seg.sesOverridePath!.isNotEmpty; @override Widget build(BuildContext context) { final theme = Theme.of(context); final aktif = _aktifSesYolu(); final dosyaIsmi = aktif.isEmpty ? '— ses yok —' : p.basename(aktif); final aktifVarMi = aktif.isNotEmpty && File(aktif).existsSync(); return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(6), border: Border.all(color: theme.dividerColor), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Üst: dosya başlığı + butonlar Row( children: [ Icon( _overrideAktif ? Icons.audiotrack : Icons.queue_music_outlined, size: 14, color: _overrideAktif ? theme.colorScheme.primary : theme.colorScheme.outline, ), const SizedBox(width: 6), Expanded( child: Tooltip( message: _overrideAktif ? 'Override: $aktif' : 'Proje şarkısı (range ile parça seçilir)', child: Text( dosyaIsmi, style: TextStyle( fontSize: 11, color: _overrideAktif ? theme.colorScheme.primary : null, fontWeight: _overrideAktif ? FontWeight.bold : FontWeight.normal, ), overflow: TextOverflow.ellipsis, ), ), ), if (_overrideAktif) IconButton( icon: const Icon(Icons.clear, size: 14), tooltip: 'Override kaldır (proje şarkısına dön)', padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 24, minHeight: 24), onPressed: () => onGuncelle((s) => s.copyWith(sesOverridePath: null)), ), IconButton( icon: const Icon(Icons.edit, size: 14), tooltip: _overrideAktif ? 'Override değiştir' : 'Override ekle', padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 24, minHeight: 24), onPressed: () async { final r = await FilePicker.platform.pickFiles( type: FileType.audio, ); if (r != null && r.files.single.path != null) { onGuncelle( (s) => s.copyWith(sesOverridePath: r.files.single.path)); } }, ), ], ), // Range slider'lı oynatıcı (aktif ses varsa) if (aktifVarMi) ...[ const SizedBox(height: 6), SesAralikOynatici( key: ValueKey('aralik-${seg.id}-$aktif'), dosyaYolu: aktif, basla: seg.basla, bit: seg.bit, onAralikDegisti: (b, e) => onGuncelle((s) => s.copyWith(basla: b, bit: e)), ), const SizedBox(height: 4), Wrap( spacing: 4, runSpacing: 4, children: [ for (final sure in const [5, 6, 8, 10, 20]) _hizliBtn(context, sure), ], ), ] else Padding( padding: const EdgeInsets.only(top: 4), child: Text( 'Ses yok — text-only üretim. Override eklemek için ✏ butonuna bas.', style: theme.textTheme.labelSmall, ), ), ], ), ); } Widget _hizliBtn(BuildContext context, int sure) { final mevcut = (seg.bit - seg.basla).abs(); final aktif = (mevcut - sure).abs() < 0.05; return InkWell( onTap: () => onGuncelle((s) => s.copyWith(bit: s.basla + sure)), child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: aktif ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), border: Border.all(color: Theme.of(context).dividerColor), ), child: Text( '${sure}s', style: TextStyle( fontSize: 11, color: aktif ? Colors.black : null, ), ), ), ); } } class _GirisGorseliBolumu extends ConsumerWidget { final Segment seg; final Proje proje; final void Function(Segment Function(Segment)) onGuncelle; const _GirisGorseliBolumu({ required this.seg, required this.proje, required this.onGuncelle, }); /// Bu segmentin gerçekte kullanacağı görsel yolu (öncelik: override → önceki son kare → kaynak görsel). String? _aktifGorselYolu() { if (seg.girisGorseliOverridePath != null && seg.girisGorseliOverridePath!.isNotEmpty) { return seg.girisGorseliOverridePath; } if (seg.sira > 0) { final onceki = proje.segmentler.firstWhere((s) => s.sira == seg.sira - 1); if (onceki.sonKareYolu != null && File(onceki.sonKareYolu!).existsSync()) { return onceki.sonKareYolu; } } if (proje.kaynak.girisGorseliPath.isNotEmpty && File(proje.kaynak.girisGorseliPath).existsSync()) { return proje.kaynak.girisGorseliPath; } return null; } String _kaynakAciklamasi() { if (seg.girisGorseliOverridePath != null) return 'Manuel override'; if (seg.sira > 0) { final onceki = proje.segmentler.firstWhere((s) => s.sira == seg.sira - 1); if (onceki.sonKareYolu != null && File(onceki.sonKareYolu!).existsSync()) { return 'S${onceki.sira + 1} son karesi'; } } if (proje.kaynak.girisGorseliPath.isNotEmpty) { return 'Proje başlangıç görseli'; } return 'Yok (text-only üretim)'; } /// Önceki segment varsa onun aktif varyantı (yoksa null). Segment? _oncekiSegment() { if (seg.sira <= 0) return null; try { return proje.segmentler.firstWhere((s) => s.sira == seg.sira - 1); } catch (_) { return null; } } Future _oncekidenKareSec(BuildContext context, WidgetRef ref) async { final onc = _oncekiSegment(); if (onc == null) return; final video = onc.videoYolu; if (video == null || video.isEmpty || !File(video).existsSync()) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Önceki segmentin aktif varyantı bulunamadı')), ); return; } final sonuc = await showDialog<_KareSecimSonuc>( context: context, barrierDismissible: false, useRootNavigator: true, builder: (_) => _KareSeciciDialog( videoYolu: video, baslik: 'S${onc.sira + 1} aktif varyantından kare seç', hedefKlasor: p.join(proje.klasorYolu, 'kare_seciler'), kayitOnEki: 's${onc.sira + 1}_v${onc.varyantYollari.indexOf(video) + 1}', ), ); if (sonuc == null) return; onGuncelle((s) => s.copyWith(girisGorseliOverridePath: sonuc.karePath)); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Kare seçildi → giriş görseli güncellendi'), duration: Duration(seconds: 2), ), ); } } @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final yol = _aktifGorselYolu(); final onc = _oncekiSegment(); final oncekiVaryantVar = onc != null && onc.videoYolu != null && onc.videoYolu!.isNotEmpty && File(onc.videoYolu!).existsSync(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MouseRegion( cursor: yol != null && File(yol).existsSync() ? SystemMouseCursors.click : SystemMouseCursors.basic, child: GestureDetector( onTap: yol != null && File(yol).existsSync() ? () => showDialog( context: context, useRootNavigator: true, barrierColor: Colors.black87, builder: (_) => _GorselLightbox( yol: yol, baslik: _kaynakAciklamasi(), ), ) : null, child: Container( height: 100, decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(6), border: Border.all(color: theme.dividerColor), ), child: yol != null && File(yol).existsSync() ? Stack( fit: StackFit.expand, children: [ ClipRRect( borderRadius: BorderRadius.circular(5), child: Image.file(File(yol), fit: BoxFit.contain, width: double.infinity), ), Positioned( right: 4, top: 4, child: Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(4), ), child: const Icon(Icons.zoom_out_map, size: 12, color: Colors.white), ), ), ], ) : const Center( child: Icon(Icons.image_not_supported_outlined, size: 32, color: Colors.grey), ), ), ), ), const SizedBox(height: 4), Row( children: [ Expanded( child: Text( _kaynakAciklamasi(), style: theme.textTheme.labelSmall?.copyWith( color: seg.girisGorseliOverridePath != null ? theme.colorScheme.primary : theme.colorScheme.outline, ), ), ), if (seg.girisGorseliOverridePath != null) TextButton.icon( icon: const Icon(Icons.clear, size: 12), label: const Text('Otomatik', style: TextStyle(fontSize: 11)), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), minimumSize: const Size(60, 24), ), onPressed: () => onGuncelle( (s) => s.copyWith(girisGorseliOverridePath: null)), ), TextButton.icon( icon: const Icon(Icons.edit, size: 12), label: const Text('Değiştir', style: TextStyle(fontSize: 11)), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), minimumSize: const Size(60, 24), ), onPressed: () async { final r = await FilePicker.platform.pickFiles( type: FileType.image, ); if (r != null && r.files.single.path != null) { onGuncelle((s) => s.copyWith( girisGorseliOverridePath: r.files.single.path)); } }, ), ], ), if (oncekiVaryantVar) ...[ const SizedBox(height: 4), SizedBox( width: double.infinity, child: OutlinedButton.icon( icon: const Icon(Icons.movie_filter_outlined, size: 14), label: Text( 'Önceki segmentten kare seç (S${onc.sira + 1})', style: const TextStyle(fontSize: 11), ), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), minimumSize: const Size(0, 28), foregroundColor: theme.colorScheme.primary, ), onPressed: () => _oncekidenKareSec(context, ref), ), ), ], ], ); } } /// Kare seçici dialog'un sonucu — hangi modda hangi kare seçildi. /// modu='giris' → sadece bir sonraki segmentin giriş görseli yapılır /// modu='kes_kaydir' → önceki segmentin videosu burada kesilir, zincir kaydırılır class _KareSecimSonuc { final String karePath; final double saniye; final String modu; _KareSecimSonuc({ required this.karePath, required this.saniye, required this.modu, }); } /// Video üzerinden zaman seçerek kare çıkaran dialog. /// Slider ile saniye seç → ffmpeg ile o anın karesi çıkarılıp önizlenir. /// İki aksiyon: "Giriş yap" veya "Burada bitir + kaydır" — sonuç [_KareSecimSonuc] /// olarak Navigator.pop ile döner. class _KareSeciciDialog extends StatefulWidget { final String videoYolu; final String baslik; final String hedefKlasor; final String kayitOnEki; const _KareSeciciDialog({ required this.videoYolu, required this.baslik, required this.hedefKlasor, required this.kayitOnEki, }); @override State<_KareSeciciDialog> createState() => _KareSeciciDialogState(); } class _KareSeciciDialogState extends State<_KareSeciciDialog> { double _sure = 0; double _saniye = 0; String? _onizlemePath; bool _yukleniyor = true; bool _kareCikariliyor = false; int _kareToken = 0; Directory? _tmpDir; @override void initState() { super.initState(); _hazirla(); } Future _hazirla() async { try { _tmpDir = await Directory.systemTemp.createTemp('kare_secici_'); final s = await FfmpegServis.videoSuresi(widget.videoYolu); if (!mounted) return; setState(() { _sure = s > 0 ? s : 1; // Varsayılan: son kare (1 fps gibi) _saniye = (_sure - 0.05).clamp(0, _sure).toDouble(); _yukleniyor = false; }); await _kareGuncelle(); } catch (e) { if (mounted) { setState(() => _yukleniyor = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Video açılamadı: $e')), ); } } } Future _kareGuncelle() async { if (_tmpDir == null) return; final myToken = ++_kareToken; setState(() => _kareCikariliyor = true); final cikis = p.join( _tmpDir!.path, 'onizleme_${myToken.toString().padLeft(4, '0')}.jpg', ); try { await KareCikarici.belirliZamandaKare( widget.videoYolu, cikis, _saniye); // Eğer bu sırada kullanıcı yeni bir saniye seçtiyse, atla if (myToken != _kareToken || !mounted) return; if (await File(cikis).exists()) { setState(() { _onizlemePath = cikis; _kareCikariliyor = false; }); } else { setState(() => _kareCikariliyor = false); } } catch (_) { if (mounted) setState(() => _kareCikariliyor = false); } } Future _kalicilastirVeDon(String modu) async { if (_onizlemePath == null) return; setState(() => _kareCikariliyor = true); try { final klasor = Directory(widget.hedefKlasor); if (!await klasor.exists()) await klasor.create(recursive: true); final dosyaIsmi = '${widget.kayitOnEki}_${DateTime.now().millisecondsSinceEpoch}.jpg'; final hedef = p.join(klasor.path, dosyaIsmi); await File(_onizlemePath!).copy(hedef); if (mounted) { Navigator.of(context).pop(_KareSecimSonuc( karePath: hedef, saniye: _saniye, modu: modu, )); } } catch (e) { if (mounted) { setState(() => _kareCikariliyor = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Kare kaydedilemedi: $e')), ); } } } @override void dispose() { _tmpDir?.delete(recursive: true).catchError((_) => _tmpDir!); super.dispose(); } String _zamanFmt(double s) { final t = s.clamp(0, double.infinity).toDouble(); final dk = (t ~/ 60).toInt(); final sn = t - dk * 60; return '${dk.toString().padLeft(2, '0')}:${sn.toStringAsFixed(2).padLeft(5, '0')}'; } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Dialog( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 720, maxHeight: 640), child: Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.movie_filter_outlined, size: 18, color: theme.colorScheme.primary), const SizedBox(width: 8), Expanded( child: Text(widget.baslik, style: theme.textTheme.titleMedium), ), IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop(), ), ], ), const Divider(), if (_yukleniyor) const Padding( padding: EdgeInsets.all(40), child: Center(child: CircularProgressIndicator()), ) else ...[ AspectRatio( aspectRatio: 16 / 9, child: Container( decoration: BoxDecoration( color: Colors.black, borderRadius: BorderRadius.circular(6), border: Border.all(color: theme.dividerColor), ), clipBehavior: Clip.antiAlias, child: Stack( fit: StackFit.expand, children: [ if (_onizlemePath != null && File(_onizlemePath!).existsSync()) Image.file( File(_onizlemePath!), fit: BoxFit.contain, gaplessPlayback: true, ), if (_kareCikariliyor) Container( color: Colors.black54, child: const Center( child: SizedBox( width: 36, height: 36, child: CircularProgressIndicator( strokeWidth: 3, color: Colors.white), ), ), ), ], ), ), ), const SizedBox(height: 12), Row( children: [ Text(_zamanFmt(_saniye), style: theme.textTheme.labelLarge), Expanded( child: Slider( value: _saniye.clamp(0, _sure).toDouble(), min: 0, max: _sure, divisions: (_sure * 30).clamp(20, 600).toInt(), label: _zamanFmt(_saniye), onChanged: (v) => setState(() => _saniye = v), onChangeEnd: (_) => _kareGuncelle(), ), ), Text(_zamanFmt(_sure), style: theme.textTheme.labelSmall), ], ), const SizedBox(height: 4), Wrap( spacing: 6, runSpacing: 4, children: [ _hizliButon('İlk', 0), _hizliButon('25%', _sure * 0.25), _hizliButon('Orta', _sure * 0.5), _hizliButon('75%', _sure * 0.75), _hizliButon('Son', (_sure - 0.05).clamp(0, _sure).toDouble()), OutlinedButton.icon( icon: const Icon(Icons.skip_previous, size: 14), label: const Text('-1 kare', style: TextStyle(fontSize: 11)), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), minimumSize: const Size(0, 28), ), onPressed: () { setState(() { _saniye = (_saniye - 1 / 30).clamp(0, _sure).toDouble(); }); _kareGuncelle(); }, ), OutlinedButton.icon( icon: const Icon(Icons.skip_next, size: 14), label: const Text('+1 kare', style: TextStyle(fontSize: 11)), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), minimumSize: const Size(0, 28), ), onPressed: () { setState(() { _saniye = (_saniye + 1 / 30).clamp(0, _sure).toDouble(); }); _kareGuncelle(); }, ), ], ), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('İptal'), ), const SizedBox(width: 8), FilledButton.icon( icon: const Icon(Icons.check, size: 16), label: const Text('Bu kareyi seç'), onPressed: _onizlemePath == null || _kareCikariliyor ? null : () => _kalicilastirVeDon('giris'), ), ], ), ], ], ), ), ), ); } Widget _hizliButon(String etiket, double s) { return OutlinedButton( style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), minimumSize: const Size(0, 28), ), onPressed: () { setState(() => _saniye = s.clamp(0, _sure).toDouble()); _kareGuncelle(); }, child: Text(etiket, style: const TextStyle(fontSize: 11)), ); } } /// Görsel lightbox: tam ekran, zoom + pan (InteractiveViewer), ESC ile kapanır. class _GorselLightbox extends StatelessWidget { final String yol; final String baslik; const _GorselLightbox({required this.yol, required this.baslik}); @override Widget build(BuildContext context) { return Dialog( insetPadding: const EdgeInsets.all(20), backgroundColor: Colors.transparent, child: Stack( children: [ // Tıklanırsa kapansın (resim alanı dışında) Positioned.fill( child: GestureDetector( onTap: () => Navigator.of(context).pop(), child: Container(color: Colors.transparent), ), ), Center( child: ClipRRect( borderRadius: BorderRadius.circular(8), child: InteractiveViewer( minScale: 0.5, maxScale: 8, child: Image.file(File(yol), fit: BoxFit.contain), ), ), ), // Üst bar Positioned( top: 8, left: 8, right: 8, child: Row( children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6), decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(6), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.image_outlined, size: 14, color: Colors.white), const SizedBox(width: 6), Text(baslik, style: const TextStyle( color: Colors.white, fontSize: 12)), ], ), ), const Spacer(), Material( color: Colors.black54, shape: const CircleBorder(), child: IconButton( icon: const Icon(Icons.close, color: Colors.white), tooltip: 'Kapat (Esc)', onPressed: () => Navigator.of(context).pop(), ), ), ], ), ), // Alt bilgi (dosya adı) Positioned( bottom: 12, left: 12, right: 12, child: Center( child: Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6), decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(6), ), child: Text( p.basename(yol), style: const TextStyle( color: Colors.white70, fontSize: 11), ), ), ), ), ], ), ); } } class _Alan extends StatelessWidget { final String etiket; final Widget child; const _Alan({required this.etiket, required this.child}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(etiket, style: Theme.of(context).textTheme.labelSmall), const SizedBox(height: 4), child, ], ), ); } }