PraxaLing / mobile /lib /features /camera /camera_screen.dart
Reubencf's picture
Deploy PraxaLing: component refactor, bug fixes, unified neo-brutal design, Qwen3.5 everywhere
2992997
Raw
History Blame Contribute Delete
20.9 kB
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<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;
}
// ── Screen ──────────────────────────────────────────────────────────────────
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),
],
);
}
}