refactor: P2/P3-Step1 — camera_screen Mixin分離 + license storeURL修正

P2:
- license_screen.dart: storeUrl を store.posimai.soar-enrich.com に統一(TODO解消)

P3 Step1:
- camera_analysis_mixin.dart 新規作成: analyzeImages() + _performSakenowaMatching()
  を CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T> に切り出し
- camera_screen.dart: 1031行 → 718行(-313行)
  不要なimport 10個削除、Mixin適用、フィールド/メソッド移動

Note: Dart ライブラリプライベート制約のため Mixin の公開 API は
capturedImages / quotaLockoutTime / analyzeImages(アンダースコアなし)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ponshu Developer 2026-04-16 13:20:53 +09:00
parent ef2d940b6a
commit aa933cf1e3
3 changed files with 348 additions and 334 deletions

View File

@ -0,0 +1,327 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:uuid/uuid.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../models/sake_item.dart';
import '../providers/gemini_provider.dart';
import '../providers/sakenowa_providers.dart';
import '../providers/theme_provider.dart';
import '../services/draft_service.dart';
import '../services/gamification_service.dart';
import '../services/gemini_exceptions.dart';
import '../services/network_service.dart';
import '../theme/app_colors.dart';
import '../widgets/analyzing_dialog.dart';
/// CameraScreen State Mixin
///
/// :
/// - capturedImages / quotaLockoutTime _ : State
/// - analyzeImages() : /Gemini呼び出しHive保存Gamification
/// - _performSakenowaMatching() :
mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T> {
final List<String> capturedImages = [];
DateTime? quotaLockoutTime;
Future<void> analyzeImages() async {
if (capturedImages.isEmpty) return;
// async gap context
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
final isOnline = await NetworkService.isOnline();
if (!isOnline) {
// : Draft
debugPrint('Offline detected: Saving as draft...');
try {
await DraftService.saveDraft(capturedImages);
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.wifiOff, color: Colors.orange, size: 16),
SizedBox(width: 8),
Text('オフライン検知', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
SizedBox(height: 4),
Text('写真を「解析待ち」として保存しました。'),
Text('オンライン復帰後、ホーム画面から解析できます。'),
],
),
duration: Duration(seconds: 5),
backgroundColor: Colors.orange,
),
);
navigator.pop();
return;
} catch (e) {
debugPrint('Draft save error: $e');
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Draft保存エラー: $e')),
);
return;
}
}
// :
if (!mounted) return;
// ignore: use_build_context_synchronously
// mounted BuildContext
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AnalyzingDialog(),
);
try {
debugPrint('Starting Gemini Vision Direct Analysis for ${capturedImages.length} images');
final geminiService = ref.read(geminiServiceProvider);
final result = await geminiService.analyzeSakeLabel(capturedImages);
// Create SakeItem (Schema v2.0)
final sakeItem = SakeItem(
id: const Uuid().v4(),
displayData: DisplayData(
name: result.name ?? '不明な日本酒',
brewery: result.brand ?? '不明',
prefecture: result.prefecture ?? '不明',
catchCopy: result.catchCopy,
imagePaths: List.from(capturedImages),
rating: null,
),
hiddenSpecs: HiddenSpecs(
description: result.description,
tasteStats: result.tasteStats,
flavorTags: result.flavorTags,
type: result.type,
alcoholContent: result.alcoholContent,
polishingRatio: result.polishingRatio,
sakeMeterValue: result.sakeMeterValue,
riceVariety: result.riceVariety,
yeast: result.yeast,
manufacturingYearMonth: result.manufacturingYearMonth,
),
metadata: Metadata(
createdAt: DateTime.now(),
aiConfidence: result.confidenceScore,
),
);
// Save to Hive
final box = Hive.box<SakeItem>('sake_items');
await box.add(sakeItem);
//
//
_performSakenowaMatching(sakeItem).catchError((error) {
debugPrint('Sakenowa auto-matching failed (non-critical): $error');
});
// Prepend new item to sort order so it appears at the top
final settingsBox = Hive.box('settings');
final List<String> currentOrder = (settingsBox.get('sake_sort_order') as List<dynamic>?)
?.cast<String>() ?? [];
currentOrder.insert(0, sakeItem.id);
await settingsBox.put('sake_sort_order', currentOrder);
// --- Gamification Hook ---
// EXP付与しない
int expGained = 0;
int newLevel = 0;
bool isLevelUp = false;
List<dynamic> newBadges = [];
if (!result.isFromCache) {
final userProfileState = ref.read(userProfileProvider);
final prevLevel = userProfileState.level;
expGained = 10;
await ref.read(userProfileProvider.notifier).updateTotalExp(userProfileState.totalExp + expGained);
newBadges = await GamificationService.checkAndUnlockBadges(ref);
final updatedProfile = ref.read(userProfileProvider);
newLevel = updatedProfile.level;
isLevelUp = newLevel > prevLevel;
debugPrint('Gamification: EXP +$expGained (Total: ${updatedProfile.totalExp}) Level: $prevLevel -> $newLevel');
} else {
debugPrint('Cache hit: EXP not awarded (same image re-scanned)');
}
debugPrint('Saved to Hive: ${sakeItem.displayData.displayName} (ID: ${sakeItem.id})');
debugPrint('Total items in box: ${box.length}');
//
setState(() => capturedImages.clear());
if (!mounted) return;
navigator.pop(); // Close AnalyzingDialog
navigator.pop(); // Close Camera Screen (Return to Home)
// Success Message
final isDark = Theme.of(context).brightness == Brightness.dark;
final List<Widget> messageWidgets = [
Text('${sakeItem.displayData.displayName} を登録しました!'),
];
if (result.isFromCache) {
messageWidgets.add(const SizedBox(height: 4));
messageWidgets.add(const Text(
'※ 解析済みの結果を使用(経験値なし)',
style: TextStyle(fontSize: 12, color: Colors.grey),
));
} else {
messageWidgets.add(const SizedBox(height: 4));
messageWidgets.add(Row(
children: [
Icon(LucideIcons.sparkles,
color: isDark ? Colors.yellow.shade300 : Colors.yellow,
size: 16),
const SizedBox(width: 8),
Text(
'経験値 +$expGained GET!${isLevelUp ? " (Level UP!)" : ""}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isDark ? Colors.yellow.shade200 : Colors.yellowAccent,
),
),
],
));
}
if (newBadges.isNotEmpty) {
messageWidgets.add(const SizedBox(height: 8));
for (var badge in newBadges) {
messageWidgets.add(
Row(
children: [
Text(badge.icon, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 8),
Text(
'バッジ獲得: ${badge.name}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isDark ? Colors.green.shade300 : Colors.greenAccent,
),
),
],
),
);
}
}
messenger.showSnackBar(
SnackBar(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: messageWidgets,
),
duration: Duration(seconds: newBadges.isNotEmpty ? 6 : 4),
),
);
} catch (e) {
if (mounted) {
navigator.pop(); // Close AnalyzingDialog
// AIサーバー混雑503
if (e is GeminiCongestionException) {
try {
await DraftService.saveDraft(capturedImages);
if (!mounted) return;
navigator.pop(); // Close camera screen
messenger.showSnackBar(
const SnackBar(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.cloudOff, color: Colors.white, size: 16),
SizedBox(width: 8),
Text('AIサーバー混雑', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
SizedBox(height: 4),
Text('写真を「解析待ち」として保存しました。'),
Text('時間をおいてホーム画面から解析できます。'),
],
),
duration: Duration(seconds: 5),
backgroundColor: Colors.orange,
),
);
} catch (draftError) {
debugPrint('Draft save also failed: $draftError');
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(content: Text('解析もドラフト保存も失敗しました。再試行してください。')),
);
}
return;
}
// Quota 429
final errStr = e.toString();
if (errStr.contains('Quota') || errStr.contains('429')) {
setState(() {
quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
});
}
final appColors = Theme.of(context).extension<AppColors>()!;
messenger.showSnackBar(
SnackBar(
content: Text('解析エラー: $e'),
duration: const Duration(seconds: 5),
backgroundColor: appColors.error,
),
);
}
}
}
///
///
///
///
Future<void> _performSakenowaMatching(SakeItem sakeItem) async {
try {
debugPrint('Starting sakenowa auto-matching for: ${sakeItem.displayData.displayName}');
final matchingService = ref.read(sakenowaAutoMatchingServiceProvider);
final result = await matchingService.matchSake(
sakeItem: sakeItem,
minScore: 0.7,
autoApply: true,
);
if (result.hasMatch) {
debugPrint('Sakenowa matching successful: ${result.brand?.name} / score=${result.score.toStringAsFixed(2)} confident=${result.isConfident}');
} else {
debugPrint('No sakenowa match found (score: ${result.score.toStringAsFixed(2)})');
}
} catch (e, stackTrace) {
debugPrint('Sakenowa auto-matching error: $e');
debugPrint('Stack trace: $stackTrace');
}
}
}

View File

@ -3,25 +3,16 @@ import 'dart:io'; // For File class
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:path/path.dart' show join;
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
import 'package:gal/gal.dart';
import '../providers/gemini_provider.dart';
import '../services/gemini_exceptions.dart';
import '../services/image_compression_service.dart';
import '../services/gamification_service.dart'; // Badge check
import '../services/network_service.dart';
import '../services/draft_service.dart';
import '../widgets/analyzing_dialog.dart';
import '../models/sake_item.dart';
import '../theme/app_colors.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:image_picker/image_picker.dart'; // Gallery & Camera
import '../providers/theme_provider.dart'; // userProfileProvider
import '../providers/sakenowa_providers.dart'; // sakenowa auto-matching
import '../services/image_compression_service.dart';
import '../theme/app_colors.dart';
import 'camera_analysis_mixin.dart';
enum CameraMode {
@ -37,11 +28,10 @@ class CameraScreen extends ConsumerStatefulWidget {
ConsumerState<CameraScreen> createState() => _CameraScreenState();
}
class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerProviderStateMixin, WidgetsBindingObserver {
class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerProviderStateMixin, WidgetsBindingObserver, CameraAnalysisMixin<CameraScreen> {
CameraController? _controller;
Future<void>? _initializeControllerFuture;
bool _isTakingPicture = false;
DateTime? _quotaLockoutTime;
String? _cameraError;
double _minZoom = 1.0;
@ -172,14 +162,12 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
}
}
final List<String> _capturedImages = [];
Future<void> _takePicture() async {
// Check Quota Lockout
if (_quotaLockoutTime != null) {
final remaining = _quotaLockoutTime!.difference(DateTime.now());
if (quotaLockoutTime != null) {
final remaining = quotaLockoutTime!.difference(DateTime.now());
if (remaining.isNegative) {
setState(() => _quotaLockoutTime = null); // Reset
setState(() => quotaLockoutTime = null); // Reset
} else {
final appColors = Theme.of(context).extension<AppColors>()!;
ScaffoldMessenger.of(context).showSnackBar(
@ -296,7 +284,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
// 3. Add compressed permanent path to capture list
if (!mounted) return;
setState(() {
_capturedImages.add(compressedPath);
capturedImages.add(compressedPath);
});
debugPrint('Gallery image compressed & persisted: $compressedPath');
@ -305,7 +293,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
// Fallback: Use original path (legacy behavior)
if (!mounted) return;
setState(() {
_capturedImages.add(img.path);
capturedImages.add(img.path);
});
}
}
@ -318,7 +306,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
duration: const Duration(seconds: 3),
action: SnackBarAction(
label: '解析する',
onPressed: _analyzeImages,
onPressed: analyzeImages,
textColor: Colors.yellow,
),
),
@ -335,7 +323,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
}
setState(() {
_capturedImages.add(imagePath);
capturedImages.add(imagePath);
});
// Show Confirmation Dialog
@ -352,7 +340,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
onPressed: () {
// Start Analysis
Navigator.of(context).pop();
_analyzeImages();
analyzeImages();
},
style: OutlinedButton.styleFrom(
foregroundColor: appColors.brandPrimary,
@ -376,280 +364,6 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
);
}
Future<void> _analyzeImages() async {
if (_capturedImages.isEmpty) return;
// async gap context
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
final isOnline = await NetworkService.isOnline();
if (!isOnline) {
// : Draft
debugPrint('Offline detected: Saving as draft...');
try {
await DraftService.saveDraft(_capturedImages);
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.wifiOff, color: Colors.orange, size: 16),
SizedBox(width: 8),
Text('オフライン検知', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
SizedBox(height: 4),
Text('写真を「解析待ち」として保存しました。'),
Text('オンライン復帰後、ホーム画面から解析できます。'),
],
),
duration: Duration(seconds: 5),
backgroundColor: Colors.orange,
),
);
navigator.pop();
return;
} catch (e) {
debugPrint('Draft save error: $e');
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Draft保存エラー: $e')),
);
return;
}
}
// :
if (!mounted) return;
// ignore: use_build_context_synchronously
// mounted BuildContext
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AnalyzingDialog(),
);
try {
// Direct Gemini Vision Analysis (OCR removed for app size reduction)
debugPrint('Starting Gemini Vision Direct Analysis for ${_capturedImages.length} images');
final geminiService = ref.read(geminiServiceProvider);
final result = await geminiService.analyzeSakeLabel(_capturedImages);
// Create SakeItem (Schema v2.0)
final sakeItem = SakeItem(
id: const Uuid().v4(),
displayData: DisplayData(
name: result.name ?? '不明な日本酒',
brewery: result.brand ?? '不明',
prefecture: result.prefecture ?? '不明',
catchCopy: result.catchCopy,
imagePaths: List.from(_capturedImages),
rating: null,
),
hiddenSpecs: HiddenSpecs(
description: result.description,
tasteStats: result.tasteStats,
flavorTags: result.flavorTags,
type: result.type,
alcoholContent: result.alcoholContent,
polishingRatio: result.polishingRatio,
sakeMeterValue: result.sakeMeterValue,
riceVariety: result.riceVariety,
yeast: result.yeast,
manufacturingYearMonth: result.manufacturingYearMonth,
),
metadata: Metadata(
createdAt: DateTime.now(),
aiConfidence: result.confidenceScore,
),
);
// Save to Hive
final box = Hive.box<SakeItem>('sake_items');
await box.add(sakeItem);
//
//
_performSakenowaMatching(sakeItem).catchError((error) {
debugPrint('Sakenowa auto-matching failed (non-critical): $error');
});
// Prepend new item to sort order so it appears at the top
final settingsBox = Hive.box('settings');
final List<String> currentOrder = (settingsBox.get('sake_sort_order') as List<dynamic>?)
?.cast<String>() ?? [];
currentOrder.insert(0, sakeItem.id); // Insert at beginning
await settingsBox.put('sake_sort_order', currentOrder);
// --- v1.3 Gamification Hook ---
// EXP付与しない
int expGained = 0;
int newLevel = 0;
bool isLevelUp = false;
List<dynamic> newBadges = [];
if (!result.isFromCache) {
final userProfileState = ref.read(userProfileProvider);
final prevLevel = userProfileState.level;
expGained = 10;
await ref.read(userProfileProvider.notifier).updateTotalExp(userProfileState.totalExp + expGained);
newBadges = await GamificationService.checkAndUnlockBadges(ref);
final updatedProfile = ref.read(userProfileProvider);
newLevel = updatedProfile.level;
isLevelUp = newLevel > prevLevel;
debugPrint('Gamification: EXP +$expGained (Total: ${updatedProfile.totalExp}) Level: $prevLevel -> $newLevel');
} else {
debugPrint('Cache hit: EXP not awarded (same image re-scanned)');
}
debugPrint('Saved to Hive: ${sakeItem.displayData.displayName} (ID: ${sakeItem.id})');
debugPrint('Total items in box: ${box.length}');
//
setState(() => _capturedImages.clear());
if (!mounted) return;
navigator.pop(); // Close AnalyzingDialog
navigator.pop(); // Close Camera Screen (Return to Home)
// Success Message
final isDark = Theme.of(context).brightness == Brightness.dark;
final List<Widget> messageWidgets = [
Text('${sakeItem.displayData.displayName} を登録しました!'),
];
if (result.isFromCache) {
messageWidgets.add(const SizedBox(height: 4));
messageWidgets.add(const Text(
'※ 解析済みの結果を使用(経験値なし)',
style: TextStyle(fontSize: 12, color: Colors.grey),
));
} else {
messageWidgets.add(const SizedBox(height: 4));
messageWidgets.add(Row(
children: [
Icon(LucideIcons.sparkles,
color: isDark ? Colors.yellow.shade300 : Colors.yellow,
size: 16),
const SizedBox(width: 8),
Text(
'経験値 +$expGained GET!${isLevelUp ? " (Level UP!)" : ""}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isDark ? Colors.yellow.shade200 : Colors.yellowAccent,
),
),
],
));
}
if (newBadges.isNotEmpty) {
messageWidgets.add(const SizedBox(height: 8));
for (var badge in newBadges) {
messageWidgets.add(
Row(
children: [
Text(badge.icon, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 8),
Text(
'バッジ獲得: ${badge.name}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isDark ? Colors.green.shade300 : Colors.greenAccent,
),
),
],
),
);
}
}
messenger.showSnackBar(
SnackBar(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: messageWidgets,
),
duration: Duration(seconds: newBadges.isNotEmpty ? 6 : 4),
),
);
} catch (e) {
if (mounted) {
navigator.pop(); // Close AnalyzingDialog
// AIサーバー混雑503
if (e is GeminiCongestionException) {
try {
await DraftService.saveDraft(_capturedImages);
if (!mounted) return;
navigator.pop(); // Close camera screen
messenger.showSnackBar(
const SnackBar(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.cloudOff, color: Colors.white, size: 16),
SizedBox(width: 8),
Text('AIサーバー混雑', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
SizedBox(height: 4),
Text('写真を「解析待ち」として保存しました。'),
Text('時間をおいてホーム画面から解析できます。'),
],
),
duration: Duration(seconds: 5),
backgroundColor: Colors.orange,
),
);
} catch (e) {
debugPrint('Draft save also failed: $e');
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(content: Text('解析もドラフト保存も失敗しました。再試行してください。')),
);
}
return;
}
// Quota 429
final errStr = e.toString();
if (errStr.contains('Quota') || errStr.contains('429')) {
setState(() {
_quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
});
}
final appColors = Theme.of(context).extension<AppColors>()!;
messenger.showSnackBar(
SnackBar(
content: Text('解析エラー: $e'),
duration: const Duration(seconds: 5),
backgroundColor: appColors.error,
),
);
}
}
}
@override
Widget build(BuildContext context) {
if (_cameraError != null) {
@ -849,22 +563,22 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: _quotaLockoutTime != null ? Colors.red : Colors.white,
color: quotaLockoutTime != null ? Colors.red : Colors.white,
width: 4
),
color: _isTakingPicture
? Colors.white.withValues(alpha: 0.5)
: (_quotaLockoutTime != null ? Colors.red.withValues(alpha: 0.2) : Colors.transparent),
: (quotaLockoutTime != null ? Colors.red.withValues(alpha: 0.2) : Colors.transparent),
),
child: Center(
child: _quotaLockoutTime != null
child: quotaLockoutTime != null
? const Icon(LucideIcons.timer, color: Colors.white, size: 30)
: Container(
height: 60,
width: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _quotaLockoutTime != null ? Colors.grey : Colors.white,
color: quotaLockoutTime != null ? Colors.grey : Colors.white,
),
),
),
@ -872,13 +586,13 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
),
// Right Spacer -> Analyze Button if images exist
if (_capturedImages.isNotEmpty)
if (capturedImages.isNotEmpty)
IconButton(
icon: Badge(
label: Text('${_capturedImages.length}'),
label: Text('${capturedImages.length}'),
child: const Icon(LucideIcons.playCircle, color: Colors.greenAccent, size: 40),
),
onPressed: _analyzeImages,
onPressed: analyzeImages,
tooltip: '解析を開始',
)
else
@ -940,33 +654,6 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
);
}
///
///
///
///
Future<void> _performSakenowaMatching(SakeItem sakeItem) async {
try {
debugPrint('Starting sakenowa auto-matching for: ${sakeItem.displayData.displayName}');
final matchingService = ref.read(sakenowaAutoMatchingServiceProvider);
// : 0.7: true
final result = await matchingService.matchSake(
sakeItem: sakeItem,
minScore: 0.7,
autoApply: true, //
);
if (result.hasMatch) {
debugPrint('Sakenowa matching successful: ${result.brand?.name} / score=${result.score.toStringAsFixed(2)} confident=${result.isConfident}');
} else {
debugPrint('No sakenowa match found (score: ${result.score.toStringAsFixed(2)})');
}
} catch (e, stackTrace) {
debugPrint('Sakenowa auto-matching error: $e');
debugPrint('Stack trace: $stackTrace');
}
}
}
// Custom Painter for Exposure Slider

View File

@ -54,7 +54,7 @@ class _LicenseScreenState extends ConsumerState<LicenseScreen> {
}
void _openStore() async {
const storeUrl = 'https://posimai-store.soar-enrich.com'; // TODO: URL
const storeUrl = 'https://store.posimai.soar-enrich.com';
final uri = Uri.parse(storeUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);