import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:image_picker/image_picker.dart'; import '../../auth/auth_provider.dart'; import '../../core/api_client.dart'; import '../../theme/app_theme.dart'; import '../../widgets/furi_text.dart'; import '../../widgets/level_picker.dart'; import '../../widgets/tts_button.dart'; // ── Data types ────────────────────────────────────────────────────────────── class VisionResult { VisionResult({ required this.caption, required this.objects, required this.sentences, required this.imgW, required this.imgH, }); final String caption; final List objects; final List sentences; final int imgW; final int imgH; } class VisionObject { VisionObject({ required this.labelNative, required this.labelTarget, this.labelTargetSegments, this.romanized, required this.box, }); final String labelNative; final String labelTarget; final List? labelTargetSegments; final String? romanized; final List box; } class VisionSentence { VisionSentence({ required this.target, this.targetSegments, required this.gloss, this.romanized, }); final String target; final List? targetSegments; final String gloss; final String? romanized; } // ── Screen ────────────────────────────────────────────────────────────────── class CameraScreen extends ConsumerStatefulWidget { const CameraScreen({super.key}); @override ConsumerState createState() => _CameraScreenState(); } class _CameraScreenState extends ConsumerState { File? _image; VisionResult? _result; bool _loading = false; String? _error; String? _level; Future _pick(ImageSource source) async { final picker = ImagePicker(); final x = await picker.pickImage(source: source, maxWidth: 1600, imageQuality: 88); if (x == null) return; setState(() { _image = File(x.path); _result = null; _error = null; }); await _analyze(File(x.path)); } Future _analyze(File file) async { setState(() => _loading = true); try { final api = ref.read(apiClientProvider); final profile = ref.read(authControllerProvider).profile; final level = _level ?? profile?.level; final form = FormData.fromMap({ 'image': await MultipartFile.fromFile(file.path), if (level != null) 'level': level, }); final res = await api.dio.post>('/api/vision/analyze', data: form); if (res.statusCode == 200 && res.data != null) { final data = res.data!; final objs = ((data['objects'] as List?) ?? []).map((e) { final o = e as Map; final rawBox = (o['box'] as List).map((x) => (x as num).toDouble()).toList(); return VisionObject( labelNative: (o['label'] ?? '') as String, labelTarget: (o['translation'] ?? '') as String, labelTargetSegments: parseFuriSegments(o['translationSegments']), romanized: o['romanized'] as String?, box: rawBox, ); }).toList(); final sents = ((data['sentences'] as List?) ?? []).map((e) { final o = e as Map; return VisionSentence( target: (o['target'] ?? '') as String, targetSegments: parseFuriSegments(o['targetSegments']), gloss: (o['gloss'] ?? '') as String, romanized: o['romanized'] as String?, ); }).toList(); final dims = await _readDimensions(file); setState(() => _result = VisionResult( caption: (data['caption'] ?? '').toString(), objects: objs, sentences: sents, imgW: dims.width, imgH: dims.height, )); } else { final errMsg = (res.data?['message'] ?? res.data?['error'] ?? 'Analysis failed').toString(); setState(() => _error = errMsg); } } catch (e) { setState(() => _error = e.toString()); } finally { if (mounted) setState(() => _loading = false); } } Future<({int width, int height})> _readDimensions(File file) async { final decoded = await decodeImageFromList(await file.readAsBytes()); return (width: decoded.width, height: decoded.height); } @override Widget build(BuildContext context) { final profile = ref.watch(authControllerProvider).profile; return Scaffold( body: SafeArea( child: ListView( padding: const EdgeInsets.fromLTRB(16, 18, 16, 28), children: [ _Banner(profile: profile), const SizedBox(height: 18), Row(children: [ Expanded( child: FilledButton.icon( onPressed: _loading ? null : () => _pick(ImageSource.camera), icon: const Icon(Icons.camera_alt, size: 18), label: const Text('CAMERA'), ), ), const SizedBox(width: 10), Expanded( child: OutlinedButton.icon( onPressed: _loading ? null : () => _pick(ImageSource.gallery), icon: const Icon(Icons.photo_library_outlined, size: 18), label: const Text('GALLERY'), ), ), ]), const SizedBox(height: 12), Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: brutalCard(), child: Row( children: [ Expanded( child: Text( 'SENTENCES MATCH LEVEL', style: GoogleFonts.almarai( color: kBrutalSlate, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 1.0, ), ), ), LevelPicker( value: _level ?? profile?.level ?? 'A1', onChanged: (l) => setState(() => _level = l), ), ], ), ), if (_error != null) ...[ const SizedBox(height: 12), _ErrorBanner(message: _error!), ], const SizedBox(height: 16), if (_image != null) _loading && _result == null ? _LoadingOverlay() : _result != null ? _AnnotatedImage(image: _image!, result: _result!) : ClipRRect( borderRadius: BorderRadius.circular(kBrutalRadius), child: Image.file(_image!, fit: BoxFit.cover)), if (_result != null && _result!.caption.isNotEmpty) ...[ const SizedBox(height: 12), Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: brutalCard(color: kBrutalCream), child: Row(children: [ const Icon(Icons.auto_awesome, size: 14, color: kBrutalBlack), const SizedBox(width: 8), Expanded( child: Text( _result!.caption, style: GoogleFonts.almarai( color: kBrutalSlate, fontSize: 13, fontWeight: FontWeight.w800, fontStyle: FontStyle.italic, ), ), ), ]), ), ], if (_result != null) ...[ const SizedBox(height: 20), _ResultsSection(result: _result!, lang: profile?.targetLang ?? 'en'), ], ], ), ), ); } } class _Banner extends StatelessWidget { const _Banner({required this.profile}); final Profile? profile; @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: kBrutalBlue, borderRadius: BorderRadius.circular(kBrutalRadius), border: Border.all(color: kBrutalBlack, width: 3), boxShadow: const [ BoxShadow(color: kBrutalBlack, offset: Offset(5, 5), blurRadius: 0), ], ), padding: const EdgeInsets.all(20), child: Row( children: [ Container( width: 56, height: 56, decoration: BoxDecoration( color: kBrutalWhite, borderRadius: BorderRadius.circular(kBrutalRadius), border: Border.all(color: kBrutalBlack, width: 2), boxShadow: const [ BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0), ], ), child: const Icon(Icons.camera_alt, color: kBrutalBlack), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'IMAGE PRACTICE', style: GoogleFonts.almarai( color: kBrutalWhite, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 1.4, ), ), Text( 'SNAP & LEARN', style: GoogleFonts.almarai( color: kBrutalWhite, fontSize: 22, fontWeight: FontWeight.w900, height: 1.05, ), ), if (profile != null) Padding( padding: const EdgeInsets.only(top: 4), child: Text( '${profile!.targetLang.toUpperCase()} · ${profile!.level}', style: GoogleFonts.almarai( color: kBrutalWhite, fontSize: 12, fontWeight: FontWeight.w800, ), ), ), ], ), ), ], ), ); } } class _LoadingOverlay extends StatelessWidget { @override Widget build(BuildContext context) { return Container( height: 240, decoration: brutalCard(), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const CircularProgressIndicator(color: kBrutalTeal, strokeWidth: 3), const SizedBox(height: 16), Text( 'GEMMA IS ANALYSING…', style: GoogleFonts.almarai( color: kBrutalSlate, fontSize: 13, fontWeight: FontWeight.w900, letterSpacing: 1.0, ), ), const SizedBox(height: 4), Text( 'This may take a moment', style: GoogleFonts.almarai(color: kBrutalMuted, fontSize: 11, fontWeight: FontWeight.w800), ), ], ), ); } } class _ErrorBanner extends StatelessWidget { const _ErrorBanner({required this.message}); final String message; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: kBrutalRed, borderRadius: BorderRadius.circular(kBrutalRadius), border: Border.all(color: kBrutalBlack, width: 2), boxShadow: const [ BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0), ], ), child: Row(children: [ const Icon(Icons.error_outline, color: kBrutalWhite, size: 18), const SizedBox(width: 8), Expanded( child: Text( message, style: GoogleFonts.almarai(color: kBrutalWhite, fontSize: 13, fontWeight: FontWeight.w900), ), ), ]), ); } } class _AnnotatedImage extends StatelessWidget { const _AnnotatedImage({required this.image, required this.result}); final File image; final VisionResult result; @override Widget build(BuildContext context) { return LayoutBuilder(builder: (ctx, constraints) { final aspect = result.imgW / result.imgH; final dw = constraints.maxWidth; final dh = dw / aspect; return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(kBrutalRadius), border: Border.all(color: kBrutalBlack, width: 3), boxShadow: const [ BoxShadow(color: kBrutalBlack, offset: Offset(5, 5), blurRadius: 0), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(kBrutalRadius - 1), child: SizedBox( width: dw, height: dh, child: Stack(children: [ Positioned.fill(child: Image.file(image, fit: BoxFit.cover)), ...result.objects.map((o) { final isNorm = o.box.every((v) => v <= 1.0); final x1 = isNorm ? o.box[0] * dw : (o.box[0] / result.imgW) * dw; final y1 = isNorm ? o.box[1] * dh : (o.box[1] / result.imgH) * dh; final x2 = isNorm ? o.box[2] * dw : (o.box[2] / result.imgW) * dw; final y2 = isNorm ? o.box[3] * dh : (o.box[3] / result.imgH) * dh; final cx = (x1 + x2) / 2; final cy = (y1 + y2) / 2; return Positioned( left: cx - 60, top: cy - 12, child: _BrutalLabel(object: o), ); }), ]), ), ), ); }); } } class _BrutalLabel extends StatelessWidget { const _BrutalLabel({required this.object}); final VisionObject object; @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Container( width: 12, height: 12, decoration: BoxDecoration( color: kBrutalYellow, border: Border.all(color: kBrutalBlack, width: 2), shape: BoxShape.circle, ), ), const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: kBrutalBlack, borderRadius: BorderRadius.circular(kBrutalRadius), border: Border.all(color: kBrutalYellow, width: 2), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( object.labelTarget, style: GoogleFonts.almarai( color: kBrutalWhite, fontSize: 11, fontWeight: FontWeight.w900, ), ), if (object.romanized != null && object.romanized!.isNotEmpty) Text( object.romanized!, style: GoogleFonts.almarai( color: kBrutalYellow, fontSize: 9, fontWeight: FontWeight.w800, ), ), ], ), ), ], ); } } class _ResultsSection extends StatelessWidget { const _ResultsSection({required this.result, required this.lang}); final VisionResult result; final String lang; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'OBJECTS DETECTED', style: GoogleFonts.almarai( color: kBrutalSlate, fontSize: 12, fontWeight: FontWeight.w900, letterSpacing: 1.2, ), ), const SizedBox(height: 10), if (result.objects.isEmpty) Text('No objects detected.', style: GoogleFonts.almarai(color: kBrutalMuted, fontWeight: FontWeight.w800)) else ...result.objects.map( (o) => Padding( padding: const EdgeInsets.only(bottom: 10), child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: brutalCard(), child: Row(children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ FuriText( text: o.labelTarget, segments: o.labelTargetSegments, fontSize: 18, fontWeight: FontWeight.w900, ), if (o.romanized != null && o.romanized!.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 4), child: Text( o.romanized!, style: GoogleFonts.almarai(color: kBrutalTeal, fontSize: 12, fontWeight: FontWeight.w800), ), ), const SizedBox(height: 4), Text( o.labelNative, style: GoogleFonts.almarai(color: kBrutalMuted, fontSize: 12, fontWeight: FontWeight.w800), ), ], ), ), const SizedBox(width: 10), TtsButton(text: o.labelTarget, lang: lang, size: 34), ]), ), ), ), const SizedBox(height: 20), Text( 'PRACTICE SENTENCES', style: GoogleFonts.almarai( color: kBrutalSlate, fontSize: 12, fontWeight: FontWeight.w900, letterSpacing: 1.2, ), ), const SizedBox(height: 10), if (result.sentences.isEmpty) Text('No sentences generated.', style: GoogleFonts.almarai(color: kBrutalMuted, fontWeight: FontWeight.w800)) else ...result.sentences.map( (s) => Padding( padding: const EdgeInsets.only(bottom: 10), child: Container( padding: const EdgeInsets.all(14), decoration: brutalCard(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: FuriText( text: s.target, segments: s.targetSegments, fontSize: 17, fontWeight: FontWeight.w900, ), ), const SizedBox(width: 8), TtsButton(text: s.target, lang: lang, size: 32), ], ), if (s.romanized != null && s.romanized!.isNotEmpty) ...[ const SizedBox(height: 4), Text( s.romanized!, style: GoogleFonts.almarai( color: kBrutalTeal, fontSize: 12, fontWeight: FontWeight.w800, fontStyle: FontStyle.italic, ), ), ], const SizedBox(height: 8), Text( s.gloss, style: GoogleFonts.almarai(color: kBrutalMuted, fontSize: 12, fontWeight: FontWeight.w800), ), ], ), ), ), ), const SizedBox(height: 20), ], ); } }