diff --git a/lib/features/home/presentation/widgets/settings/llm_download_tile.dart b/lib/features/home/presentation/widgets/settings/llm_download_tile.dart index 7031c18..4588613 100644 --- a/lib/features/home/presentation/widgets/settings/llm_download_tile.dart +++ b/lib/features/home/presentation/widgets/settings/llm_download_tile.dart @@ -8,8 +8,6 @@ import 'package:kudlit_ph/features/translator/presentation/providers/ai_inferenc import 'package:kudlit_ph/features/translator/presentation/providers/ai_inference_state.dart'; import 'package:kudlit_ph/features/translator/presentation/providers/translator_providers.dart'; -import 'profile_management_action_button.dart'; - class LlmDownloadTile extends ConsumerWidget { const LlmDownloadTile({super.key}); @@ -35,8 +33,8 @@ class LlmDownloadTile extends ConsumerWidget { children: [ const _TileHeader( icon: Icons.psychology_rounded, - label: 'Butty replies', - sublabel: 'Offline replies · large download', + label: 'Butty AI', + sublabel: 'Offline chat · large download', ), const SizedBox(height: 10), _LlmStatusRow( @@ -44,7 +42,8 @@ class LlmDownloadTile extends ConsumerWidget { prefsAsync: prefsAsync, readinessAsync: readinessAsync, onCancel: notifier.cancelDownload, - onTrigger: (GemmaModelInfo m) => notifier.triggerLocalDownload(m), + onTrigger: (GemmaModelInfo model) => + notifier.triggerLocalDownload(model), ), ], ), @@ -64,8 +63,8 @@ class _LlmStatusRow extends StatelessWidget { final AsyncValue stateAsync; final AsyncValue prefsAsync; final AsyncValue readinessAsync; - final void Function() onCancel; - final void Function(GemmaModelInfo) onTrigger; + final VoidCallback onCancel; + final void Function(GemmaModelInfo model) onTrigger; @override Widget build(BuildContext context) { @@ -98,7 +97,6 @@ class _LlmStatusRow extends StatelessWidget { ); } - final AppPreferences? prefs = prefsAsync.value; final LocalGemmaReadiness? readiness = readinessAsync.value; final GemmaModelInfo? activeModel = switch (state) { AiReady(:final GemmaModelInfo activeModel) => activeModel, @@ -106,106 +104,84 @@ class _LlmStatusRow extends StatelessWidget { _ => null, }; - if (prefs == null || readiness == null || activeModel == null) { + if (readiness == null || activeModel == null) { return const _CheckingRow(); } if (readiness.installed && readiness.usable) { - return _ReadyRow( - cloudMode: prefs.aiPreference == AiPreference.cloud, - note: prefs.aiPreference == AiPreference.cloud - ? 'Downloaded and ready whenever you switch to Offline mode.' - : 'Downloaded and ready to use without internet.', + return const _StatusRow(note: 'Downloaded'); + } + + if (readiness.installed) { + return _StatusRow( + note: 'Finishing setup…', + action: _CompactIconActionButton( + tooltip: 'Reload Butty AI', + icon: Icons.refresh_rounded, + onTap: () => onTrigger(activeModel), + ), ); } - return _ActionRow( - badge: _StatusBadge( - label: readiness.installed ? 'Almost ready' : 'Needs download', - ok: readiness.installed ? null : false, + return _StatusRow( + note: 'Download to use Butty offline.', + action: _CompactIconActionButton( + tooltip: 'Download Butty AI', + icon: Icons.download_rounded, + onTap: () => onTrigger(activeModel), + isPrimary: true, ), - primary: readiness.installed ? 'Reload' : 'Download', - onPrimary: () => onTrigger(activeModel), - note: readiness.installed - ? 'We are still getting this ready.' - : 'Download once to use Butty without internet.', ); } } -class _ReadyRow extends StatelessWidget { - const _ReadyRow({required this.cloudMode, this.note}); +class _StatusRow extends StatelessWidget { + const _StatusRow({this.note, this.action}); - final bool cloudMode; - final String? note; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - _StatusBadge( - label: cloudMode ? 'Downloaded' : 'Ready offline', - ok: true, - ), - ], - ), - if (note != null) ...[ - const SizedBox(height: 8), - Text( - note!, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurface.withAlpha(150), - ), - ), - ], - ], - ); - } -} - -class _ActionRow extends StatelessWidget { - const _ActionRow({ - required this.badge, - required this.primary, - required this.onPrimary, - this.note, - }); - - final Widget badge; - final String primary; - final VoidCallback onPrimary; final String? note; + final Widget? action; @override Widget build(BuildContext context) { final ColorScheme cs = Theme.of(context).colorScheme; + final Widget statusCopy = Text( + note ?? '', + style: TextStyle( + fontSize: 11, + height: 1.25, + color: cs.onSurface.withAlpha(150), + ), + ); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + if (constraints.maxWidth < 300) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + statusCopy, + if (action != null) ...[ + const SizedBox(height: 10), + Align(alignment: Alignment.centerLeft, child: action), + ], + ], + ); + } + + return Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 12, + runSpacing: 8, children: [ - badge, - const Spacer(), - ProfileManagementActionButton( - label: primary, - isPrimary: true, - onTap: onPrimary, + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 180, maxWidth: 260), + child: statusCopy, ), + if (action case final Widget compactAction) compactAction, ], - ), - if (note != null) ...[ - const SizedBox(height: 8), - Text( - note!, - style: TextStyle(fontSize: 11, color: cs.onSurface.withAlpha(150)), - ), - ], - ], + ); + }, ); } } @@ -228,20 +204,24 @@ class _ProgressRow extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 12, + runSpacing: 8, children: [ - Text( - 'Downloading… $progress%', - style: TextStyle(color: cs.primary, fontSize: 13), - ), - GestureDetector( - onTap: onCancel, + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 180, maxWidth: 260), child: Text( - 'Cancel', - style: TextStyle(color: cs.error, fontSize: 12), + 'Downloading… $progress%', + style: TextStyle(color: cs.primary, fontSize: 13), ), ), + _CompactIconActionButton( + tooltip: 'Cancel Butty AI download', + icon: Icons.close_rounded, + onTap: onCancel, + ), ], ), if (statusMessage != null) ...[ @@ -308,46 +288,53 @@ class _TileHeader extends StatelessWidget { } } -class _StatusBadge extends StatelessWidget { - const _StatusBadge({required this.label, required this.ok}); - - final String label; - final bool? ok; +class _CheckingRow extends StatelessWidget { + const _CheckingRow(); @override Widget build(BuildContext context) { - final Color bg = ok == true - ? Colors.green.shade800.withAlpha(40) - : ok == false - ? Colors.red.shade800.withAlpha(40) - : Theme.of(context).colorScheme.surfaceContainerHigh; - final Color fg = ok == true - ? Colors.green.shade300 - : ok == false - ? Colors.red.shade300 - : Theme.of(context).colorScheme.onSurface.withAlpha(150); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(20), + return Text( + 'Checking…', + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface.withAlpha(128), ), - child: Text(label, style: TextStyle(fontSize: 11, color: fg)), ); } } -class _CheckingRow extends StatelessWidget { - const _CheckingRow(); +class _CompactIconActionButton extends StatelessWidget { + const _CompactIconActionButton({ + required this.tooltip, + required this.icon, + required this.onTap, + this.isPrimary = false, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onTap; + final bool isPrimary; @override Widget build(BuildContext context) { - return Text( - 'Checking…', - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurface.withAlpha(128), + final ColorScheme cs = Theme.of(context).colorScheme; + + return Tooltip( + message: tooltip, + child: SizedBox( + width: 44, + height: 44, + child: IconButton( + onPressed: onTap, + style: IconButton.styleFrom( + backgroundColor: isPrimary ? cs.primary : cs.surface, + foregroundColor: isPrimary ? cs.onPrimary : cs.onSurface, + side: BorderSide(color: isPrimary ? cs.primary : cs.outline), + shape: const CircleBorder(), + ), + icon: Icon(icon, size: 18), + ), ), ); } diff --git a/lib/features/home/presentation/widgets/settings/vision_download_tile.dart b/lib/features/home/presentation/widgets/settings/vision_download_tile.dart index f56b3dc..1c8dc11 100644 --- a/lib/features/home/presentation/widgets/settings/vision_download_tile.dart +++ b/lib/features/home/presentation/widgets/settings/vision_download_tile.dart @@ -8,8 +8,6 @@ import 'package:kudlit_ph/features/scanner/data/datasources/web_vision_model_pre import 'package:kudlit_ph/features/scanner/presentation/providers/yolo_model_selection_provider.dart'; import 'package:kudlit_ph/features/translator/domain/entities/ai_model_info.dart'; -import 'profile_management_action_button.dart'; - class VisionDownloadTile extends ConsumerStatefulWidget { const VisionDownloadTile({super.key}); @@ -152,24 +150,26 @@ class _VisionStatusRow extends ConsumerWidget { data: (VisionModelSetupStatus status) { if (status.ready) { return _VisionActionRow( - badge: const _StatusBadge(label: 'Ready to scan', ok: true), - supportingText: 'Downloaded and ready when you open the scanner.', - action: ProfileManagementActionButton( - label: 'Set up again', + supportingText: 'Downloaded', + action: _CompactIconActionButton( + tooltip: 'Refresh scanner model', + icon: Icons.refresh_rounded, onTap: onPrepare, ), ); } return _VisionActionRow( - badge: const _StatusBadge(label: 'Needs download', ok: false), supportingText: kIsWeb - ? 'Set this up once to use camera reading in this browser.' - : 'Download once before using camera reading.', - action: ProfileManagementActionButton( - label: 'Set up', - isPrimary: true, + ? status.message + : 'Download before using the scanner.', + action: _CompactIconActionButton( + tooltip: kIsWeb ? 'Load scanner model' : 'Download scanner model', + icon: kIsWeb + ? Icons.cloud_download_rounded + : Icons.download_rounded, onTap: onPrepare, + isPrimary: true, ), ); }, @@ -178,13 +178,8 @@ class _VisionStatusRow extends ConsumerWidget { } class _VisionActionRow extends StatelessWidget { - const _VisionActionRow({ - required this.badge, - required this.supportingText, - required this.action, - }); + const _VisionActionRow({required this.supportingText, required this.action}); - final Widget badge; final String supportingText; final Widget action; @@ -194,8 +189,6 @@ class _VisionActionRow extends StatelessWidget { final Widget statusCopy = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - badge, - const SizedBox(height: 6), Text( supportingText, style: TextStyle( @@ -334,44 +327,53 @@ class _VisionTileHeader extends StatelessWidget { } } -class _StatusBadge extends StatelessWidget { - const _StatusBadge({required this.label, required this.ok}); - - final String label; - final bool ok; +class _NoModelRow extends StatelessWidget { + const _NoModelRow(); @override Widget build(BuildContext context) { - final ColorScheme cs = Theme.of(context).colorScheme; - final Color bg = ok ? cs.primaryContainer : cs.errorContainer; - final Color fg = ok ? cs.onPrimaryContainer : cs.onErrorContainer; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 11, color: fg), + return Text( + 'Scanner model setup is unavailable in this build.', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurface.withAlpha(128), ), ); } } -class _NoModelRow extends StatelessWidget { - const _NoModelRow(); +class _CompactIconActionButton extends StatelessWidget { + const _CompactIconActionButton({ + required this.tooltip, + required this.icon, + required this.onTap, + this.isPrimary = false, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onTap; + final bool isPrimary; @override Widget build(BuildContext context) { - return Text( - 'Scanner model setup is unavailable in this build.', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurface.withAlpha(128), + final ColorScheme cs = Theme.of(context).colorScheme; + + return Tooltip( + message: tooltip, + child: SizedBox( + width: 44, + height: 44, + child: IconButton( + onPressed: onTap, + style: IconButton.styleFrom( + backgroundColor: isPrimary ? cs.primary : cs.surface, + foregroundColor: isPrimary ? cs.onPrimary : cs.onSurface, + side: BorderSide(color: isPrimary ? cs.primary : cs.outline), + shape: const CircleBorder(), + ), + icon: Icon(icon, size: 18), + ), ), ); } diff --git a/lib/features/scanner/presentation/widgets/scanner_camera.dart b/lib/features/scanner/presentation/widgets/scanner_camera.dart index 74e452a..f9010df 100644 --- a/lib/features/scanner/presentation/widgets/scanner_camera.dart +++ b/lib/features/scanner/presentation/widgets/scanner_camera.dart @@ -118,6 +118,9 @@ extension WebScannerStatusMeta on WebScannerStatus { }; } +@visibleForTesting +Alignment webStatusAlignment(WebScannerStatus status) => Alignment.center; + /// On web shows a real browser webcam preview and capture-based detection. class ScannerCamera extends ConsumerStatefulWidget { const ScannerCamera({ @@ -574,15 +577,10 @@ class _WebCameraPreviewState extends ConsumerState<_WebCameraPreview> { : constraints.maxWidth < 380 ? 14 : 28; - final bool centerUnavailable = _status == WebScannerStatus.error; return Align( - alignment: centerUnavailable - ? Alignment.center - : Alignment.centerLeft, + alignment: webStatusAlignment(_status), child: Padding( - padding: EdgeInsets.symmetric( - horizontal: centerUnavailable ? 24 : horizontalPadding, - ), + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), child: WebStatusMessage( cs: cs, status: _status, @@ -629,10 +627,11 @@ class WebStatusMessage extends StatelessWidget { : 360; final double maxWidth = availableWidth.clamp(200.0, 240.0); final bool narrow = maxWidth < 300; + final String? effectiveMessage = message ?? _defaultMessage(); - final String semanticLabel = message == null + final String semanticLabel = effectiveMessage == null ? status.label - : '${status.label}. $message'; + : '${status.label}. $effectiveMessage'; return Semantics( label: semanticLabel, @@ -661,14 +660,11 @@ class WebStatusMessage extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon( - status.icon, - size: showCompact - ? 24 - : narrow - ? 28 - : 32, - color: _statusIconColor(cs), + _StatusVisual( + cs: cs, + status: status, + showCompact: showCompact, + narrow: narrow, ), SizedBox(height: showCompact || narrow ? 8 : 10), Text( @@ -686,10 +682,10 @@ class WebStatusMessage extends StatelessWidget { color: cs.onSurface, ), ), - if (message != null) const SizedBox(height: 6), - if (message != null) + if (effectiveMessage != null) const SizedBox(height: 6), + if (effectiveMessage != null) Text( - message!, + effectiveMessage, textAlign: TextAlign.center, softWrap: true, maxLines: 3, @@ -700,6 +696,17 @@ class WebStatusMessage extends StatelessWidget { color: cs.onSurface.withAlpha(190), ), ), + if (status == WebScannerStatus.detecting) ...[ + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + minHeight: 6, + backgroundColor: cs.tertiary.withAlpha(36), + valueColor: AlwaysStoppedAnimation(cs.tertiary), + ), + ), + ], ], ), ), @@ -731,7 +738,88 @@ class WebStatusMessage extends StatelessWidget { }; } - Color _statusIconColor(ColorScheme cs) { + String? _defaultMessage() { + return switch (status) { + WebScannerStatus.detecting => 'Hold still while Kudlit reads the frame.', + _ => null, + }; + } +} + +class _StatusVisual extends StatelessWidget { + const _StatusVisual({ + required this.cs, + required this.status, + required this.showCompact, + required this.narrow, + }); + + final ColorScheme cs; + final WebScannerStatus status; + final bool showCompact; + final bool narrow; + + @override + Widget build(BuildContext context) { + final double iconSize = showCompact + ? 24 + : narrow + ? 28 + : 32; + final double frameSize = showCompact + ? 44 + : narrow + ? 52 + : 58; + final Color iconColor = _iconColor(); + final Color fillColor = _fillColor(); + + final Widget iconFrame = Container( + width: frameSize, + height: frameSize, + decoration: BoxDecoration( + color: fillColor, + shape: BoxShape.circle, + border: Border.all(color: iconColor.withAlpha(60)), + boxShadow: [ + BoxShadow( + color: iconColor.withAlpha( + status == WebScannerStatus.detecting ? 46 : 22, + ), + blurRadius: status == WebScannerStatus.detecting ? 18 : 10, + spreadRadius: status == WebScannerStatus.detecting ? 1 : 0, + ), + ], + ), + child: Icon(status.icon, size: iconSize, color: iconColor), + ); + + if (status != WebScannerStatus.detecting) { + return iconFrame; + } + + return SizedBox( + width: frameSize + 10, + height: frameSize + 10, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: frameSize + 10, + height: frameSize + 10, + child: CircularProgressIndicator( + strokeWidth: 2.4, + backgroundColor: cs.tertiary.withAlpha(28), + valueColor: AlwaysStoppedAnimation(cs.tertiary), + ), + ), + iconFrame, + ], + ), + ); + } + + Color _iconColor() { return switch (status) { WebScannerStatus.ready => cs.primary, WebScannerStatus.detecting => cs.tertiary, @@ -740,6 +828,17 @@ class WebStatusMessage extends StatelessWidget { WebScannerStatus.permissionNeeded => cs.onSurface.withAlpha(190), }; } + + Color _fillColor() { + return switch (status) { + WebScannerStatus.ready => cs.primaryContainer.withAlpha(210), + WebScannerStatus.detecting => cs.tertiaryContainer.withAlpha(230), + WebScannerStatus.modelUnavailable || + WebScannerStatus.error => cs.errorContainer.withAlpha(220), + WebScannerStatus.initializing || WebScannerStatus.permissionNeeded => + cs.surfaceContainerHighest.withAlpha(220), + }; + } } class _CameraCover extends StatelessWidget { diff --git a/test/features/home/presentation/widgets/ai_models_section_test.dart b/test/features/home/presentation/widgets/ai_models_section_test.dart index 4bdea55..103c475 100644 --- a/test/features/home/presentation/widgets/ai_models_section_test.dart +++ b/test/features/home/presentation/widgets/ai_models_section_test.dart @@ -1,26 +1,27 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/misc.dart' show Override; +import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:kudlit_ph/features/home/presentation/providers/app_preferences_provider.dart'; import 'package:kudlit_ph/features/home/presentation/widgets/settings/ai_models_section.dart'; -import 'package:kudlit_ph/features/home/presentation/widgets/settings/profile_management_action_button.dart'; import 'package:kudlit_ph/features/home/presentation/widgets/settings/vision_download_tile.dart'; import 'package:kudlit_ph/features/scanner/data/datasources/yolo_model_cache.dart'; import 'package:kudlit_ph/features/scanner/presentation/providers/yolo_model_selection_provider.dart'; +import 'package:kudlit_ph/features/translator/data/datasources/local_gemma_datasource.dart'; import 'package:kudlit_ph/features/translator/domain/entities/ai_model_info.dart'; import 'package:kudlit_ph/features/translator/domain/entities/gemma_model_info.dart'; import 'package:kudlit_ph/features/translator/presentation/providers/ai_inference_provider.dart'; import 'package:kudlit_ph/features/translator/presentation/providers/ai_inference_state.dart'; +import 'package:kudlit_ph/features/translator/presentation/providers/translator_providers.dart'; void main() { setUp(() { SharedPreferences.setMockInitialValues({}); }); - testWidgets('offline downloads section frames setup in plain language', ( + testWidgets('offline downloads section keeps setup cards minimal', ( tester, ) async { await tester.binding.setSurfaceSize(const Size(360, 740)); @@ -45,15 +46,14 @@ void main() { ), findsOneWidget, ); - expect(find.text('Butty replies'), findsOneWidget); - expect(find.text('Offline replies · large download'), findsOneWidget); - expect(find.text('Needs download'), findsWidgets); + expect(find.text('Butty AI'), findsOneWidget); + expect(find.text('Offline chat · large download'), findsOneWidget); + expect(find.text('Downloaded'), findsWidgets); expect(find.text('KudVis-1-Turbo'), findsOneWidget); expect(find.text('Reads Baybayin with your camera'), findsOneWidget); - expect( - find.text('Download once before using camera reading.'), - findsOneWidget, - ); + expect(find.text('Download before using the scanner.'), findsOneWidget); + expect(find.text('Needs download'), findsNothing); + expect(find.text('Ready to scan'), findsNothing); expect(tester.takeException(), isNull); }); @@ -75,7 +75,7 @@ void main() { await tester.pump(); await tester.pump(); - await tester.tap(find.text('Set up')); + await tester.tap(find.byTooltip('Download scanner model')); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); @@ -128,9 +128,9 @@ void main() { await tester.pump(); expect(find.text('KudVis-Pro'), findsOneWidget); - expect(find.text('Ready to scan'), findsOneWidget); + expect(find.text('Downloaded'), findsWidgets); - await tester.tap(find.text('Set up again')); + await tester.tap(find.byTooltip('Refresh scanner model')); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); @@ -164,10 +164,10 @@ void main() { await tester.pump(); final Rect supportText = tester.getRect( - find.text('Download once before using camera reading.'), + find.text('Download before using the scanner.'), ); final Rect downloadButton = tester.getRect( - find.widgetWithText(ProfileManagementActionButton, 'Set up'), + find.byTooltip('Download scanner model'), ); expect(downloadButton.top, greaterThan(supportText.bottom)); @@ -204,8 +204,8 @@ void main() { await tester.pump(); expect(find.text('Use Kudlit offline'), findsOneWidget); - expect(find.text('Needs download'), findsWidgets); - expect(find.byType(ProfileManagementActionButton), findsWidgets); + expect(find.text('Needs download'), findsNothing); + expect(find.byType(IconButton), findsWidgets); expect(tester.takeException(), isNull); }); } @@ -231,6 +231,13 @@ List _modelOverrides( ]; }), aiInferenceNotifierProvider.overrideWith(_ReadyInferenceNotifier.new), + localModelReadinessProvider.overrideWith((Ref ref) async { + return const LocalGemmaReadiness( + installed: true, + usable: true, + detail: 'Downloaded', + ); + }), ]; } @@ -277,9 +284,8 @@ class _FakeYoloModelCache implements YoloModelCacheStore { void Function(int received, int total)? onProgress, }) async { downloadedIds.add(modelId); - onProgress?.call(1, 2); installed = true; - onProgress?.call(2, 2); + onProgress?.call(1, 1); return '/tmp/$modelId.tflite'; } diff --git a/test/features/scanner/presentation/widgets/scanner_camera_status_test.dart b/test/features/scanner/presentation/widgets/scanner_camera_status_test.dart index 9200d3a..10a0428 100644 --- a/test/features/scanner/presentation/widgets/scanner_camera_status_test.dart +++ b/test/features/scanner/presentation/widgets/scanner_camera_status_test.dart @@ -49,6 +49,17 @@ void main() { ); }); + test( + 'web status alignment stays centered for camera prompts and detection', + () { + expect( + webStatusAlignment(WebScannerStatus.permissionNeeded), + Alignment.center, + ); + expect(webStatusAlignment(WebScannerStatus.detecting), Alignment.center); + }, + ); + testWidgets('web camera status card fits narrow scanner viewport', ( WidgetTester tester, ) async { @@ -138,6 +149,36 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('detecting status shows centered progress treatment', ( + WidgetTester tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320, 480)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: WebStatusMessage( + cs: ThemeData.dark().colorScheme, + status: WebScannerStatus.detecting, + showCompact: false, + ), + ), + ), + ), + ); + + expect(find.text('Detecting'), findsOneWidget); + expect( + find.text('Hold still while Kudlit reads the frame.'), + findsOneWidget, + ); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('model not ready screen shows download progress', ( WidgetTester tester, ) async {