| 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'; |
|
|
| |
|
|
| class VisionResult { |
| VisionResult({ |
| required this.caption, |
| required this.objects, |
| required this.sentences, |
| required this.imgW, |
| required this.imgH, |
| }); |
| final String caption; |
| final List<VisionObject> objects; |
| final List<VisionSentence> 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<FuriSegment>? labelTargetSegments; |
| final String? romanized; |
| final List<double> box; |
| } |
|
|
| class VisionSentence { |
| VisionSentence({ |
| required this.target, |
| this.targetSegments, |
| required this.gloss, |
| this.romanized, |
| }); |
| final String target; |
| final List<FuriSegment>? targetSegments; |
| final String gloss; |
| final String? romanized; |
| } |
|
|
| |
|
|
| class CameraScreen extends ConsumerStatefulWidget { |
| const CameraScreen({super.key}); |
| @override |
| ConsumerState<CameraScreen> createState() => _CameraScreenState(); |
| } |
|
|
| class _CameraScreenState extends ConsumerState<CameraScreen> { |
| File? _image; |
| VisionResult? _result; |
| bool _loading = false; |
| String? _error; |
| String? _level; |
|
|
| Future<void> _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<void> _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<Map<String, dynamic>>('/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<String, dynamic>; |
| 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<String, dynamic>; |
| 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), |
| ], |
| ); |
| } |
| } |
|
|