Compare commits

..

7 Commits

Author SHA1 Message Date
Ponshu Developer d72587ac19 security: .env.exampleの実APIキーをダミー値に差し替え
MAITA_API_KEY・EIJI_API_KEYに本物のGemini APIキーが入っていた。
ダミー値(AIzaSy_YOUR_GEMINI_KEY_HERE)に置換。
実キーはGoogle Cloud Consoleでローテーション要。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 00:07:38 +09:00
Ponshu Developer 1a84163654 fix: use_build_context_synchronously 解消
_reanalyze で nav / messenger を async gap 前にキャプチャするよう移動。
showDialog の context 引数を ignore 対象行に統合。
dart analyze: No issues found

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 08:20:42 +09:00
Ponshu Developer 2e770ff98d refactor: ハードコード色をAppColorsセマンティックカラーに置換
- pending_analysis_banner: Colors.orange.* → appColors.warning(グラデーション・影・バッジ)
- activity_stats: Colors.orange → appColors.warning(残回数少ない警告色・テキスト)
- scan_screen: Colors.grey → appColors.textTertiary(蔵元/産地テキスト)
- sake_no_match_state: Colors.grey[400/600] → appColors.textTertiary(空状態アイコン・テキスト)
- camera_screen: Colors.greenAccent → appColors.success(解析開始ボタン)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 08:18:48 +09:00
Ponshu Developer e7bb4e494c chore: update releases.json for v1.0.43
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 22:40:49 +09:00
Ponshu Developer 5bcacfffa3 chore: bump version to v1.0.43+50
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:26:18 +09:00
Ponshu Developer 9fba57621a fix: quotaLockout Provider化・色トークン整備・依存バージョン固定
- quotaLockoutProvider (NotifierProvider) を新規作成し、カメラ・詳細画面で共有
  - camera_analysis_mixin: quotaLockoutTime フィールドを削除、429時にProviderへ設定
  - camera_screen: ref.watch(quotaLockoutProvider) でシャッターボタンUI更新
  - sake_detail_screen: _quotaLockoutTime フィールドを削除、Providerに移行
  - 画面遷移後もロックアウト状態が保持されるP1バグを解消
- camera_screen: Colors.red/grey → appColors.error/textTertiary に置換
- camera_screen: ギャラリー保存SnackBarから例外文字列 $e を除去
- camera_screen: SnackBarAction textColor Colors.yellow → appColors.brandAccent
- pubspec.yaml: flutter_riverpod ^3.1.0, riverpod_annotation 3.0.0-dev.3,
  riverpod_generator 3.0.0-dev.11 を固定(バージョン未固定による意図しないアップグレードを防止)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:18:27 +09:00
Ponshu Developer dd9b814174 refactor: TextEditingControllerリーク解消・エラー正規化・デザイントークン整備
- sake_detail_screen: _showTagEditDialog/TextEditDialog/BreweryEditDialog に
  try/finally + controller.dispose() を追加(メモリリーク修正)
- sake_detail_screen: State フィールドを build() より前に移動
- 生例外の SnackBar 露出を人間可読メッセージに正規化(6ファイル・10箇所)
- camera_analysis_mixin: Colors.orange を appColors.warning に置換、
  ガミフィケーション色を brandAccent/success/textTertiary に統一
- sake_detail_screen: ハードコード hex 色グラデーションをトークン化
- scan_screen / pdf_preview_screen / add_set_item_dialog: 絵文字 debugPrint を除去
- sake_basic_info_section: unnecessary_non_null_assertion (warning) を解消
- license_service: revoked 永続キャッシュの意図をコメントで明確化
- dart analyze: warning 0 / error 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:48:32 +09:00
18 changed files with 246 additions and 211 deletions

View File

@ -28,5 +28,5 @@ GITEA_REPO=ponshu_room_lite
# VERCEL_PROJECT_ID=prj_xxxxxxxxxx # VERCEL_PROJECT_ID=prj_xxxxxxxxxx
# APKビルド設定 # APKビルド設定
MAITA_API_KEY=AIzaSyDjPZGOHy-xAstpLks081SIbUdTyb_iJpU MAITA_API_KEY=AIzaSy_YOUR_GEMINI_KEY_HERE
EIJI_API_KEY=AIzaSyBEwmTa9_2aiRrwr1mXE7Qriw8mIg1xr0U EIJI_API_KEY=AIzaSy_YOUR_GEMINI_KEY_HERE

View File

@ -0,0 +1,13 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
class QuotaLockoutNotifier extends Notifier<DateTime?> {
@override
DateTime? build() => null;
void set(DateTime? value) => state = value;
}
/// Gemini API 429
///
///
/// null = DateTime =
final quotaLockoutProvider = NotifierProvider<QuotaLockoutNotifier, DateTime?>(QuotaLockoutNotifier.new);

View File

@ -6,6 +6,7 @@ import 'package:lucide_icons/lucide_icons.dart';
import '../models/sake_item.dart'; import '../models/sake_item.dart';
import '../providers/gemini_provider.dart'; import '../providers/gemini_provider.dart';
import '../providers/quota_lockout_provider.dart';
import '../providers/sakenowa_providers.dart'; import '../providers/sakenowa_providers.dart';
import '../providers/theme_provider.dart'; import '../providers/theme_provider.dart';
import '../services/api_usage_service.dart'; import '../services/api_usage_service.dart';
@ -24,7 +25,6 @@ import '../widgets/analyzing_dialog.dart';
/// - _performSakenowaMatching() : /// - _performSakenowaMatching() :
mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T> { mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T> {
final List<String> capturedImages = []; final List<String> capturedImages = [];
DateTime? quotaLockoutTime;
Future<void> analyzeImages() async { Future<void> analyzeImages() async {
if (capturedImages.isEmpty) return; if (capturedImages.isEmpty) return;
@ -32,6 +32,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
// async gap context // async gap context
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
final appColors = Theme.of(context).extension<AppColors>()!;
final isOnline = await NetworkService.isOnline(); final isOnline = await NetworkService.isOnline();
if (!isOnline) { if (!isOnline) {
@ -44,25 +45,25 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
if (!mounted) return; if (!mounted) return;
messenger.showSnackBar( messenger.showSnackBar(
const SnackBar( SnackBar(
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
Icon(LucideIcons.wifiOff, color: Colors.orange, size: 16), Icon(LucideIcons.wifiOff, color: Colors.white, size: 16),
SizedBox(width: 8), const SizedBox(width: 8),
Text('オフライン検知', style: TextStyle(fontWeight: FontWeight.bold)), const Text('オフライン検知', style: TextStyle(fontWeight: FontWeight.bold)),
], ],
), ),
SizedBox(height: 4), const SizedBox(height: 4),
Text('写真を「解析待ち」として保存しました。'), const Text('写真を「解析待ち」として保存しました。'),
Text('オンライン復帰後、ホーム画面から解析できます。'), const Text('オンライン復帰後、ホーム画面から解析できます。'),
], ],
), ),
duration: Duration(seconds: 5), duration: const Duration(seconds: 5),
backgroundColor: Colors.orange, backgroundColor: appColors.warning,
), ),
); );
@ -72,7 +73,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
debugPrint('Draft save error: $e'); debugPrint('Draft save error: $e');
if (!mounted) return; if (!mounted) return;
messenger.showSnackBar( messenger.showSnackBar(
SnackBar(content: Text('Draft保存エラー: $e')), const SnackBar(content: Text('写真の一時保存に失敗しました。再度お試しください。')),
); );
return; return;
} }
@ -93,11 +94,11 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Row( Row(
children: [ children: [
Icon(LucideIcons.zap, color: Colors.orange, size: 16), const Icon(LucideIcons.zap, color: Colors.white, size: 16),
SizedBox(width: 8), const SizedBox(width: 8),
Text('本日のAI解析上限20回に達しました', const Text('本日のAI解析上限20回に達しました',
style: TextStyle(fontWeight: FontWeight.bold)), style: TextStyle(fontWeight: FontWeight.bold)),
], ],
), ),
@ -107,7 +108,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
], ],
), ),
duration: const Duration(seconds: 6), duration: const Duration(seconds: 6),
backgroundColor: Colors.orange, backgroundColor: appColors.warning,
), ),
); );
navigator.pop(); // navigator.pop(); //
@ -115,7 +116,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
debugPrint('Draft save error (quota): $e'); debugPrint('Draft save error (quota): $e');
if (!mounted) return; if (!mounted) return;
messenger.showSnackBar( messenger.showSnackBar(
SnackBar(content: Text('保存エラー: $e')), const SnackBar(content: Text('写真の一時保存に失敗しました。再度お試しください。')),
); );
} }
return; return;
@ -224,30 +225,27 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
navigator.pop(); // Close Camera Screen (Return to Home) navigator.pop(); // Close Camera Screen (Return to Home)
// Success Message // Success Message
final isDark = Theme.of(context).brightness == Brightness.dark;
final List<Widget> messageWidgets = [ final List<Widget> messageWidgets = [
Text('${sakeItem.displayData.displayName} を登録しました!'), Text('${sakeItem.displayData.displayName} を登録しました!'),
]; ];
if (result.isFromCache) { if (result.isFromCache) {
messageWidgets.add(const SizedBox(height: 4)); messageWidgets.add(const SizedBox(height: 4));
messageWidgets.add(const Text( messageWidgets.add(Text(
'※ 解析済みの結果を使用(経験値なし)', '※ 解析済みの結果を使用(経験値なし)',
style: TextStyle(fontSize: 12, color: Colors.grey), style: TextStyle(fontSize: 12, color: appColors.textTertiary),
)); ));
} else { } else {
messageWidgets.add(const SizedBox(height: 4)); messageWidgets.add(const SizedBox(height: 4));
messageWidgets.add(Row( messageWidgets.add(Row(
children: [ children: [
Icon(LucideIcons.sparkles, Icon(LucideIcons.sparkles, color: appColors.brandAccent, size: 16),
color: isDark ? Colors.yellow.shade300 : Colors.yellow,
size: 16),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'経験値 +$expGained GET!${isLevelUp ? " (Level UP!)" : ""}', '経験値 +$expGained GET!${isLevelUp ? " (Level UP!)" : ""}',
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isDark ? Colors.yellow.shade200 : Colors.yellowAccent, color: appColors.brandAccent,
), ),
), ),
], ],
@ -266,7 +264,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
'バッジ獲得: ${badge.name}', 'バッジ獲得: ${badge.name}',
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isDark ? Colors.green.shade300 : Colors.greenAccent, color: appColors.success,
), ),
), ),
], ],
@ -297,25 +295,25 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
if (!mounted) return; if (!mounted) return;
navigator.pop(); // Close camera screen navigator.pop(); // Close camera screen
messenger.showSnackBar( messenger.showSnackBar(
const SnackBar( SnackBar(
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( const Row(
children: [ children: [
Icon(LucideIcons.cloudOff, color: Colors.white, size: 16), Icon(LucideIcons.cloudOff, color: Colors.white, size: 16),
SizedBox(width: 8), SizedBox(width: 8),
Text('AIサーバー混雑', style: TextStyle(fontWeight: FontWeight.bold)), Text('AIサーバー混雑', style: TextStyle(fontWeight: FontWeight.bold)),
], ],
), ),
SizedBox(height: 4), const SizedBox(height: 4),
Text('写真を「解析待ち」として保存しました。'), const Text('写真を「解析待ち」として保存しました。'),
Text('時間をおいてホーム画面から解析できます。'), const Text('時間をおいてホーム画面から解析できます。'),
], ],
), ),
duration: Duration(seconds: 5), duration: const Duration(seconds: 5),
backgroundColor: Colors.orange, backgroundColor: appColors.warning,
), ),
); );
} catch (draftError) { } catch (draftError) {
@ -333,6 +331,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
if (errStr.contains('Quota') || errStr.contains('429')) { if (errStr.contains('Quota') || errStr.contains('429')) {
try { try {
await DraftService.saveDraft(capturedImages, reason: DraftReason.quotaLimit); await DraftService.saveDraft(capturedImages, reason: DraftReason.quotaLimit);
ref.read(quotaLockoutProvider.notifier).set(DateTime.now().add(const Duration(minutes: 1)));
if (!mounted) return; if (!mounted) return;
navigator.pop(); // navigator.pop(); //
final resetTime = ApiUsageService.getNextResetTime(); final resetTime = ApiUsageService.getNextResetTime();
@ -345,7 +344,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
children: [ children: [
const Row( const Row(
children: [ children: [
Icon(LucideIcons.zap, color: Colors.orange, size: 16), Icon(LucideIcons.zap, color: Colors.white, size: 16),
SizedBox(width: 8), SizedBox(width: 8),
Text('本日のAI解析上限20回に達しました', Text('本日のAI解析上限20回に達しました',
style: TextStyle(fontWeight: FontWeight.bold)), style: TextStyle(fontWeight: FontWeight.bold)),
@ -357,7 +356,7 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
], ],
), ),
duration: const Duration(seconds: 6), duration: const Duration(seconds: 6),
backgroundColor: Colors.orange, backgroundColor: appColors.warning,
), ),
); );
} catch (draftError) { } catch (draftError) {
@ -370,10 +369,10 @@ mixin CameraAnalysisMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
return; return;
} }
final appColors = Theme.of(context).extension<AppColors>()!; debugPrint('Analysis error: $e');
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
content: Text('解析エラー: $e'), content: const Text('解析に失敗しました。時間をおいて再試行してください。'),
duration: const Duration(seconds: 5), duration: const Duration(seconds: 5),
backgroundColor: appColors.error, backgroundColor: appColors.error,
), ),

View File

@ -10,6 +10,7 @@ import 'package:gal/gal.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:image_picker/image_picker.dart'; // Gallery & Camera import 'package:image_picker/image_picker.dart'; // Gallery & Camera
import '../providers/quota_lockout_provider.dart';
import '../services/image_compression_service.dart'; import '../services/image_compression_service.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import 'camera_analysis_mixin.dart'; import 'camera_analysis_mixin.dart';
@ -165,10 +166,11 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
Future<void> _takePicture() async { Future<void> _takePicture() async {
// Check Quota Lockout // Check Quota Lockout
if (quotaLockoutTime != null) { final quotaLockout = ref.read(quotaLockoutProvider);
final remaining = quotaLockoutTime!.difference(DateTime.now()); if (quotaLockout != null) {
final remaining = quotaLockout.difference(DateTime.now());
if (remaining.isNegative) { if (remaining.isNegative) {
setState(() => quotaLockoutTime = null); // Reset ref.read(quotaLockoutProvider.notifier).set(null);
} else { } else {
final appColors = Theme.of(context).extension<AppColors>()!; final appColors = Theme.of(context).extension<AppColors>()!;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -223,7 +225,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
final appColors = Theme.of(context).extension<AppColors>()!; final appColors = Theme.of(context).extension<AppColors>()!;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('ギャラリー保存に失敗しました: $e'), content: const Text('ギャラリーへの保存に失敗しました'),
duration: const Duration(seconds: 4), duration: const Duration(seconds: 4),
backgroundColor: appColors.warning, backgroundColor: appColors.warning,
), ),
@ -301,6 +303,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
// Batch handle - Notification only // Batch handle - Notification only
if (mounted) { if (mounted) {
final appColors = Theme.of(context).extension<AppColors>()!;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('${images.length}枚の画像を読み込みました。\n右下のボタンから解析を開始してください。'), content: Text('${images.length}枚の画像を読み込みました。\n右下のボタンから解析を開始してください。'),
@ -308,7 +311,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
action: SnackBarAction( action: SnackBarAction(
label: '解析する', label: '解析する',
onPressed: analyzeImages, onPressed: analyzeImages,
textColor: Colors.yellow, textColor: appColors.brandAccent,
), ),
), ),
); );
@ -367,6 +370,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final quotaLockout = ref.watch(quotaLockoutProvider);
final appColors = Theme.of(context).extension<AppColors>()!;
if (_cameraError != null) { if (_cameraError != null) {
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
@ -564,22 +569,22 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all( border: Border.all(
color: quotaLockoutTime != null ? Colors.red : Colors.white, color: quotaLockout != null ? appColors.error : Colors.white,
width: 4 width: 4
), ),
color: _isTakingPicture color: _isTakingPicture
? Colors.white.withValues(alpha: 0.5) ? Colors.white.withValues(alpha: 0.5)
: (quotaLockoutTime != null ? Colors.red.withValues(alpha: 0.2) : Colors.transparent), : (quotaLockout != null ? appColors.error.withValues(alpha: 0.2) : Colors.transparent),
), ),
child: Center( child: Center(
child: quotaLockoutTime != null child: quotaLockout != null
? const Icon(LucideIcons.timer, color: Colors.white, size: 30) ? const Icon(LucideIcons.timer, color: Colors.white, size: 30)
: Container( : Container(
height: 60, height: 60,
width: 60, width: 60,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: quotaLockoutTime != null ? Colors.grey : Colors.white, color: quotaLockout != null ? appColors.textTertiary : Colors.white,
), ),
), ),
), ),
@ -591,7 +596,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
IconButton( IconButton(
icon: Badge( icon: Badge(
label: Text('${capturedImages.length}'), label: Text('${capturedImages.length}'),
child: const Icon(LucideIcons.playCircle, color: Colors.greenAccent, size: 40), child: Icon(LucideIcons.playCircle, color: appColors.success, size: 40),
), ),
onPressed: analyzeImages, onPressed: analyzeImages,
tooltip: '解析を開始', tooltip: '解析を開始',

View File

@ -58,8 +58,9 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
); );
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
debugPrint('Share error: $e');
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('シェアに失敗しました: $e')), const SnackBar(content: Text('シェアに失敗しました。再度お試しください。')),
); );
} }
} finally { } finally {
@ -541,7 +542,7 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
debugPrint('Diagnosis Error: $e'); debugPrint('Diagnosis Error: $e');
if (!mounted) return; if (!mounted) return;
navigator.pop(); navigator.pop();
messenger.showSnackBar(SnackBar(content: Text('エラー: $e'))); messenger.showSnackBar(const SnackBar(content: Text('診断に失敗しました。時間をおいて再試行してください。')));
} }
} }

View File

@ -95,7 +95,7 @@ class PdfPreviewScreen extends ConsumerWidget {
], ],
), ),
loadingWidget: const Center(child: CircularProgressIndicator()), loadingWidget: const Center(child: CircularProgressIndicator()),
onError: (context, error) => Center(child: Text('エラーが発生しました: $error')), onError: (context, error) => const Center(child: Text('PDFの表示に失敗しました')),
), ),
bottomNavigationBar: SafeArea( bottomNavigationBar: SafeArea(
child: Column( child: Column(
@ -246,9 +246,10 @@ class PdfPreviewScreen extends ConsumerWidget {
filename: 'oshinagaki_${DateTime.now().toString().split(' ')[0]}.pdf', filename: 'oshinagaki_${DateTime.now().toString().split(' ')[0]}.pdf',
); );
} catch (e) { } catch (e) {
debugPrint('PDF share error: $e');
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('共有エラー: $e')), const SnackBar(content: Text('PDFの共有に失敗しました。再度お試しください。')),
); );
} }
} }
@ -284,7 +285,7 @@ class PdfPreviewScreen extends ConsumerWidget {
..name = fileName ..name = fileName
..mimeType = 'application/pdf'; ..mimeType = 'application/pdf';
debugPrint('[PDF_DRIVE] 📤 アップロード開始: $fileName (${bytes.length} bytes)'); debugPrint('[PDF_DRIVE] Upload start: $fileName (${bytes.length} bytes)');
final uploadedFile = await driveApi.files.create( final uploadedFile = await driveApi.files.create(
driveFile, driveFile,
uploadMedia: drive.Media( uploadMedia: drive.Media(
@ -294,11 +295,11 @@ class PdfPreviewScreen extends ConsumerWidget {
); );
if (uploadedFile.id == null) { if (uploadedFile.id == null) {
debugPrint('[PDF_DRIVE] ❌ アップロード失敗: ID取得不可'); debugPrint('[PDF_DRIVE] Upload failed: no file ID returned');
throw Exception('アップロードに失敗しましたIDなし'); throw Exception('アップロードに失敗しましたIDなし');
} }
debugPrint('[PDF_DRIVE] ✅ アップロード完了: ID=${uploadedFile.id}'); debugPrint('[PDF_DRIVE] Upload complete: ID=${uploadedFile.id}');
// 5. Success notification // 5. Success notification
if (context.mounted) { if (context.mounted) {
@ -314,11 +315,12 @@ class PdfPreviewScreen extends ConsumerWidget {
); );
} }
} catch (e) { } catch (e) {
debugPrint('Drive upload error: $e');
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: Text('Driveアップロードエラー: $e'), content: Text('Google Driveへの保存に失敗しました。再度お試しください。'),
duration: const Duration(seconds: 4), duration: Duration(seconds: 4),
), ),
); );
} }
@ -336,9 +338,10 @@ class PdfPreviewScreen extends ConsumerWidget {
format: _getPageFormat(pdfSize, ref.read(pdfIsPortraitProvider)), format: _getPageFormat(pdfSize, ref.read(pdfIsPortraitProvider)),
); );
} catch (e) { } catch (e) {
debugPrint('Print error: $e');
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('印刷エラー: $e')), const SnackBar(content: Text('印刷に失敗しました。再度お試しください。')),
); );
} }
} }

View File

@ -194,10 +194,11 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
} }
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
debugPrint('Draft delete error: $e');
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: Text('削除エラー: $e'), content: Text('削除に失敗しました。再度お試しください。'),
duration: const Duration(seconds: 5), duration: Duration(seconds: 5),
), ),
); );
} }

View File

@ -101,7 +101,7 @@ class SakeBasicInfoSection extends ConsumerWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: badgeColor!.withValues(alpha: 0.1), color: badgeColor!.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: badgeColor!.withValues(alpha: 0.4)), border: Border.all(color: badgeColor.withValues(alpha: 0.4)),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -109,7 +109,7 @@ class SakeBasicInfoSection extends ConsumerWidget {
Icon(LucideIcons.brainCircuit, size: 12, color: badgeColor), Icon(LucideIcons.brainCircuit, size: 12, color: badgeColor),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'$mbtiType ${mbtiResult!.starDisplay}', '$mbtiType ${mbtiResult.starDisplay}',
style: TextStyle( style: TextStyle(
color: badgeColor, color: badgeColor,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,

View File

@ -234,9 +234,10 @@ class _SakePhotoEditModalState extends State<SakePhotoEditModal> {
await _saveNewPhoto(savedPath); await _saveNewPhoto(savedPath);
} catch (e) { } catch (e) {
debugPrint('Photo pick error: $e');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('エラー: $e')), const SnackBar(content: Text('写真の追加に失敗しました。再度お試しください。')),
); );
} }
} }

View File

@ -19,6 +19,7 @@ import '../widgets/sake_detail/sake_detail_specs.dart';
import 'sake_detail/widgets/sake_photo_edit_modal.dart'; import 'sake_detail/widgets/sake_photo_edit_modal.dart';
import 'sake_detail/sections/sake_mbti_stamp_section.dart'; import 'sake_detail/sections/sake_mbti_stamp_section.dart';
import '../providers/license_provider.dart'; import '../providers/license_provider.dart';
import '../providers/quota_lockout_provider.dart';
import 'sake_detail/sections/sake_basic_info_section.dart'; import 'sake_detail/sections/sake_basic_info_section.dart';
import 'sake_detail/widgets/sake_detail_sliver_app_bar.dart'; import 'sake_detail/widgets/sake_detail_sliver_app_bar.dart';
import '../services/mbti_compatibility_service.dart'; import '../services/mbti_compatibility_service.dart';
@ -36,10 +37,9 @@ class SakeDetailScreen extends ConsumerStatefulWidget {
} }
class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> { class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
// To trigger rebuilds if we don't switch to a stream
late SakeItem _sake; late SakeItem _sake;
int _currentImageIndex = 0; int _currentImageIndex = 0;
// Memo logic moved to SakeDetailMemo bool _isAnalyzing = false;
@override @override
@ -119,15 +119,12 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: Theme.of(context).brightness == Brightness.dark colors: [
? [ Theme.of(context).scaffoldBackgroundColor,
const Color(0xFF121212), // Scaffold Background Theme.of(context).brightness == Brightness.dark
const Color(0xFF1E1E1E), // Slightly lighter surface ? appColors.brandSurface
] : Theme.of(context).primaryColor.withValues(alpha: 0.05),
: [ ],
Theme.of(context).scaffoldBackgroundColor,
Theme.of(context).primaryColor.withValues(alpha: 0.05),
],
), ),
), ),
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
@ -281,10 +278,6 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
); );
} }
bool _isAnalyzing = false;
DateTime? _quotaLockoutTime;
Future<void> _toggleFavorite() async { Future<void> _toggleFavorite() async {
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
final box = Hive.box<SakeItem>('sake_items'); final box = Hive.box<SakeItem>('sake_items');
@ -309,13 +302,18 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
// 1. Check Locks // 1. Check Locks
if (_isAnalyzing) return; if (_isAnalyzing) return;
// async gap context
final nav = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
// 2. Check Quota Lockout // 2. Check Quota Lockout
if (_quotaLockoutTime != null) { final quotaLockout = ref.read(quotaLockoutProvider);
final remaining = _quotaLockoutTime!.difference(DateTime.now()); if (quotaLockout != null) {
final remaining = quotaLockout.difference(DateTime.now());
if (remaining.isNegative) { if (remaining.isNegative) {
setState(() => _quotaLockoutTime = null); // Reset if time passed ref.read(quotaLockoutProvider.notifier).set(null);
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( messenger.showSnackBar(
SnackBar(content: Text('AI利用制限中です。あと${remaining.inSeconds}秒お待ちください。')), SnackBar(content: Text('AI利用制限中です。あと${remaining.inSeconds}秒お待ちください。')),
); );
return; return;
@ -332,8 +330,6 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
} }
} }
if (!mounted) return; if (!mounted) return;
final nav = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
if (existingPaths.isEmpty) { if (existingPaths.isEmpty) {
messenger.showSnackBar( messenger.showSnackBar(
@ -345,13 +341,8 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
setState(() => _isAnalyzing = true); setState(() => _isAnalyzing = true);
try { try {
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
// mounted 334 await showDialog(context: context, barrierDismissible: false, builder: (context) => const AnalyzingDialog());
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AnalyzingDialog(),
);
final geminiService = ref.read(geminiServiceProvider); final geminiService = ref.read(geminiServiceProvider);
final result = await geminiService.analyzeSakeLabel(existingPaths, forceRefresh: true); final result = await geminiService.analyzeSakeLabel(existingPaths, forceRefresh: true);
@ -396,13 +387,12 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
// Check for Quota Error to set Lockout // Check for Quota Error to set Lockout
if (e.toString().contains('Quota') || e.toString().contains('429')) { if (e.toString().contains('Quota') || e.toString().contains('429')) {
setState(() { ref.read(quotaLockoutProvider.notifier).set(DateTime.now().add(const Duration(minutes: 1)));
_quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
});
} }
debugPrint('Reanalyze error: $e');
messenger.showSnackBar( messenger.showSnackBar(
SnackBar(content: Text('エラー: $e')), const SnackBar(content: Text('再解析に失敗しました。時間をおいて再試行してください。')),
); );
} }
} finally { } finally {
@ -413,11 +403,11 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
} }
void _showTagEditDialog(BuildContext context) { Future<void> _showTagEditDialog(BuildContext context) async {
final TextEditingController tagController = TextEditingController(); final TextEditingController tagController = TextEditingController();
final allTags = _sake.hiddenSpecs.flavorTags.toSet(); final allTags = _sake.hiddenSpecs.flavorTags.toSet();
try {
showDialog( await showDialog(
context: context, context: context,
builder: (ctx) => StatefulBuilder( builder: (ctx) => StatefulBuilder(
builder: (context, setModalState) { builder: (context, setModalState) {
@ -498,6 +488,9 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
} }
), ),
); );
} finally {
tagController.dispose();
}
} }
Future<void> _updateTags(List<String> newTags) async { Future<void> _updateTags(List<String> newTags) async {
@ -581,33 +574,37 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
required Future<void> Function(String) onSave, required Future<void> Function(String) onSave,
}) async { }) async {
final controller = TextEditingController(text: initialValue); final controller = TextEditingController(text: initialValue);
await showDialog( try {
context: context, await showDialog(
builder: (context) => AlertDialog( context: context,
title: Text(title), builder: (context) => AlertDialog(
content: TextField( title: Text(title),
controller: controller, content: TextField(
decoration: const InputDecoration( controller: controller,
border: OutlineInputBorder(), decoration: const InputDecoration(
border: OutlineInputBorder(),
),
autofocus: true,
maxLines: null,
), ),
autofocus: true, actions: [
maxLines: null, TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('キャンセル'),
),
ElevatedButton(
onPressed: () async {
await onSave(controller.text);
if (context.mounted) Navigator.pop(context);
},
child: const Text('保存'),
),
],
), ),
actions: [ );
TextButton( } finally {
onPressed: () => Navigator.pop(context), controller.dispose();
child: const Text('キャンセル'), }
),
ElevatedButton(
onPressed: () async {
await onSave(controller.text);
if (context.mounted) Navigator.pop(context);
},
child: const Text('保存'),
),
],
),
);
} }
/// MBTI相性詳細ダイアログを表示 /// MBTI相性詳細ダイアログを表示
@ -732,52 +729,57 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
Future<void> _showBreweryEditDialog(BuildContext context) async { Future<void> _showBreweryEditDialog(BuildContext context) async {
final breweryController = TextEditingController(text: _sake.displayData.displayBrewery); final breweryController = TextEditingController(text: _sake.displayData.displayBrewery);
final prefectureController = TextEditingController(text: _sake.displayData.displayPrefecture); final prefectureController = TextEditingController(text: _sake.displayData.displayPrefecture);
await showDialog( try {
context: context, await showDialog(
builder: (context) => AlertDialog( context: context,
title: const Text('酒蔵・都道府県を編集'), builder: (context) => AlertDialog(
content: Column( title: const Text('酒蔵・都道府県を編集'),
mainAxisSize: MainAxisSize.min, content: Column(
children: [ mainAxisSize: MainAxisSize.min,
TextField( children: [
controller: breweryController, TextField(
decoration: const InputDecoration( controller: breweryController,
labelText: '酒蔵', decoration: const InputDecoration(
border: OutlineInputBorder(), labelText: '酒蔵',
border: OutlineInputBorder(),
),
), ),
const SizedBox(height: 16),
TextField(
controller: prefectureController,
decoration: const InputDecoration(
labelText: '都道府県',
border: OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('キャンセル'),
), ),
const SizedBox(height: 16), ElevatedButton(
TextField( onPressed: () async {
controller: prefectureController, final box = Hive.box<SakeItem>('sake_items');
decoration: const InputDecoration( final updated = _sake.copyWith(
labelText: '都道府県', brand: breweryController.text,
border: OutlineInputBorder(), prefecture: prefectureController.text,
), isUserEdited: true,
);
await box.put(_sake.key, updated);
setState(() => _sake = updated);
if (context.mounted) Navigator.pop(context);
},
child: const Text('保存'),
), ),
], ],
), ),
actions: [ );
TextButton( } finally {
onPressed: () => Navigator.pop(context), breweryController.dispose();
child: const Text('キャンセル'), prefectureController.dispose();
), }
ElevatedButton(
onPressed: () async {
final box = Hive.box<SakeItem>('sake_items');
final updated = _sake.copyWith(
brand: breweryController.text,
prefecture: prefectureController.text,
isUserEdited: true,
);
await box.put(_sake.key, updated);
setState(() => _sake = updated);
if (context.mounted) Navigator.pop(context);
},
child: const Text('保存'),
),
],
),
);
} }
/// ///

View File

@ -77,10 +77,10 @@ class _ScanARScreenState extends ConsumerState<ScanARScreen>
_isInitializing = false; _isInitializing = false;
}); });
} }
debugPrint('✅ Scanner: Controller created successfully'); debugPrint('[Scanner] Controller created successfully');
} catch (e) { } catch (e) {
debugPrint('❌ Scanner: Error during initialization: $e'); debugPrint('[Scanner] Error during initialization: $e');
if (mounted) { if (mounted) {
setState(() { setState(() {
_isInitializing = false; _isInitializing = false;
@ -395,7 +395,7 @@ class _DigitalSakeCardDialog extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'$brewery / $prefecture', '$brewery / $prefecture',
style: const TextStyle(fontSize: 14, color: Colors.grey), style: TextStyle(fontSize: 14, color: appColors.textTertiary),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),

View File

@ -148,17 +148,22 @@ class LicenseService {
if (cached == null) return LicenseStatus.free; if (cached == null) return LicenseStatus.free;
// freeにフォールバック // _validateKeyWithServer
// pro revoked proは購入者を締め出さないrevokedは誤って復活させない // _getCachedStatus
//
// TTL _cacheValidSeconds = 24h:
// - free / offline free
// - pro :
// - revoked:
// TTL isNoExpiryStatus
if (cachedAt != null) { if (cachedAt != null) {
final age = DateTime.now().difference(DateTime.parse(cachedAt)); final age = DateTime.now().difference(DateTime.parse(cachedAt));
final isPermanentStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.name; final isNoExpiryStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.name;
if (age.inSeconds > _cacheValidSeconds && !isPermanentStatus) { if (age.inSeconds > _cacheValidSeconds && !isNoExpiryStatus) {
return LicenseStatus.free; return LicenseStatus.free;
} }
} }
// Pro
return LicenseStatus.values.firstWhere( return LicenseStatus.values.firstWhere(
(s) => s.name == cached, (s) => s.name == cached,
orElse: () => LicenseStatus.free, orElse: () => LicenseStatus.free,

View File

@ -104,7 +104,7 @@ class _AddSetItemDialogState extends ConsumerState<AddSetItemDialog> {
final newlyUnlockedBadges = await GamificationService.checkAndUnlockBadges(ref); final newlyUnlockedBadges = await GamificationService.checkAndUnlockBadges(ref);
if (newlyUnlockedBadges.isNotEmpty) { if (newlyUnlockedBadges.isNotEmpty) {
debugPrint('🏅 Badges Unlocked: ${newlyUnlockedBadges.map((b) => b.name).join(", ")}'); debugPrint('[Gamification] Badges Unlocked: ${newlyUnlockedBadges.map((b) => b.name).join(", ")}');
} }
if (mounted) { if (mounted) {

View File

@ -37,7 +37,7 @@ class ActivityStats extends ConsumerWidget {
final apiColor = isExhausted final apiColor = isExhausted
? appColors.error ? appColors.error
: isLow : isLow
? Colors.orange ? appColors.warning
: appColors.brandPrimary; : appColors.brandPrimary;
// Bento Grid: 2 // Bento Grid: 2
@ -192,7 +192,7 @@ class ActivityStats extends ConsumerWidget {
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'残り$remaining回です', '残り$remaining回です',
style: const TextStyle(fontSize: 10, color: Colors.orange), style: TextStyle(fontSize: 10, color: appColors.warning),
), ),
], ],
], ],

View File

@ -2,19 +2,21 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/filter_providers.dart'; import '../../providers/filter_providers.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import '../../theme/app_colors.dart';
class SakeNoMatchState extends ConsumerWidget { class SakeNoMatchState extends ConsumerWidget {
const SakeNoMatchState({super.key}); const SakeNoMatchState({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final appColors = Theme.of(context).extension<AppColors>()!;
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(LucideIcons.filterX, size: 48, color: Colors.grey[400]), Icon(LucideIcons.filterX, size: 48, color: appColors.textTertiary),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('条件に一致するお酒が見つかりません', style: TextStyle(color: Colors.grey[600])), Text('条件に一致するお酒が見つかりません', style: TextStyle(color: appColors.textTertiary)),
TextButton( TextButton(
child: const Text('フィルタを解除'), child: const Text('フィルタを解除'),
onPressed: () { onPressed: () {

View File

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import '../services/draft_service.dart'; import '../services/draft_service.dart';
import '../screens/pending_analysis_screen.dart'; import '../screens/pending_analysis_screen.dart';
import '../theme/app_colors.dart';
/// Draft /// Draft
/// ///
@ -24,19 +25,21 @@ class PendingAnalysisBanner extends ConsumerWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final appColors = Theme.of(context).extension<AppColors>()!;
return Container( return Container(
margin: const EdgeInsets.all(12), margin: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: [
Colors.orange.shade600, appColors.warning,
Colors.orange.shade400, appColors.warning.withValues(alpha: 0.85),
], ],
), ),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.orange.withValues(alpha: 0.3), color: appColors.warning.withValues(alpha: 0.3),
blurRadius: 8, blurRadius: 8,
offset: const Offset(0, 2), offset: const Offset(0, 2),
), ),
@ -98,7 +101,7 @@ class PendingAnalysisBanner extends ConsumerWidget {
child: Text( child: Text(
'$pendingCount件', '$pendingCount件',
style: TextStyle( style: TextStyle(
color: Colors.orange.shade700, color: appColors.warning,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.42+49 version: 1.0.43+50
environment: environment:
sdk: ^3.10.1 sdk: ^3.10.1
@ -35,8 +35,8 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
google_fonts: ^6.3.3 google_fonts: ^6.3.3
flutter_riverpod: flutter_riverpod: ^3.1.0
riverpod_annotation: riverpod_annotation: 3.0.0-dev.3
hive: ^2.2.3 hive: ^2.2.3
hive_flutter: ^1.1.0 hive_flutter: ^1.1.0
google_generative_ai: ^0.4.7 google_generative_ai: ^0.4.7
@ -85,7 +85,7 @@ dev_dependencies:
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
build_runner: build_runner:
hive_generator: hive_generator:
riverpod_generator: riverpod_generator: 3.0.0-dev.11
flutter_launcher_icons: ^0.13.1 flutter_launcher_icons: ^0.13.1
flutter_native_splash: ^2.4.4 flutter_native_splash: ^2.4.4

View File

@ -1,21 +1,21 @@
{ {
"date": "2026-04-16", "version": "v1.0.43",
"name": "Ponshu Room 1.0.42 (2026-04-16)", "name": "Ponshu Room 1.0.43 (2026-04-18)",
"version": "v1.0.42", "date": "2026-04-18",
"apks": { "apks": {
"eiji": { "maita": {
"lite": { "lite": {
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.42/ponshu_room_consumer_eiji.apk", "filename": "ponshu_room_consumer_maita.apk",
"size_mb": 89.2, "url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.43/ponshu_room_consumer_maita.apk",
"filename": "ponshu_room_consumer_eiji.apk" "size_mb": 89
} }
}, },
"maita": { "eiji": {
"lite": { "lite": {
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.42/ponshu_room_consumer_maita.apk", "filename": "ponshu_room_consumer_eiji.apk",
"size_mb": 89.2, "url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.43/ponshu_room_consumer_eiji.apk",
"filename": "ponshu_room_consumer_maita.apk" "size_mb": 89
} }
} }
} }
} }