diff --git a/lib/screens/sake_detail/sections/sake_mbti_stamp_section.dart b/lib/screens/sake_detail/sections/sake_mbti_stamp_section.dart index 0e06715..2c9a4ea 100644 --- a/lib/screens/sake_detail/sections/sake_mbti_stamp_section.dart +++ b/lib/screens/sake_detail/sections/sake_mbti_stamp_section.dart @@ -5,7 +5,7 @@ import '../../../models/sake_item.dart'; import '../../../providers/theme_provider.dart'; import '../../../services/mbti_compatibility_service.dart'; import '../../../theme/app_colors.dart'; -import '../../../main.dart'; // For isProVersion +import '../../../providers/license_provider.dart'; /// MBTI酒向スタンプセクション /// @@ -22,13 +22,14 @@ class SakeMbtiStampSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final appColors = Theme.of(context).extension()!; + final isPro = ref.watch(isProProvider); return Container( width: double.infinity, padding: const EdgeInsets.all(24), decoration: BoxDecoration( border: Border.all( - color: isProVersion + color: isPro ? appColors.brandPrimary.withValues(alpha: 0.3) : appColors.divider, style: BorderStyle.solid, @@ -37,7 +38,7 @@ class SakeMbtiStampSection extends ConsumerWidget { borderRadius: BorderRadius.circular(16), color: Theme.of(context).cardColor.withValues(alpha: 0.5), ), - child: isProVersion + child: isPro ? _buildProContent(context, ref, appColors) : _buildLiteContent(context, appColors), ); diff --git a/lib/screens/sake_detail_screen.dart b/lib/screens/sake_detail_screen.dart index 6a9d2ba..d33e11a 100644 --- a/lib/screens/sake_detail_screen.dart +++ b/lib/screens/sake_detail_screen.dart @@ -18,7 +18,7 @@ import '../widgets/sake_detail/sake_detail_memo.dart'; import '../widgets/sake_detail/sake_detail_specs.dart'; import 'sake_detail/widgets/sake_photo_edit_modal.dart'; import 'sake_detail/sections/sake_mbti_stamp_section.dart'; -import '../main.dart' show isProVersion; +import '../providers/license_provider.dart'; import 'sake_detail/sections/sake_basic_info_section.dart'; import 'sake_detail/widgets/sake_detail_sliver_app_bar.dart'; import '../services/mbti_compatibility_service.dart'; @@ -83,6 +83,7 @@ class _SakeDetailScreenState extends ConsumerState { @override Widget build(BuildContext context) { final appColors = Theme.of(context).extension()!; + final isPro = ref.watch(isProProvider); // スマートレコメンド final allSakeAsync = ref.watch(allSakeItemsProvider); @@ -254,7 +255,7 @@ class _SakeDetailScreenState extends ConsumerState { ], // MBTI Diagnostic Stamp Section (Pro only) - if (isProVersion) ...[ + if (isPro) ...[ SakeMbtiStampSection(sake: _sake), const SizedBox(height: 24), ], diff --git a/lib/services/device_service.dart b/lib/services/device_service.dart index 856e3b9..a0fd32d 100644 --- a/lib/services/device_service.dart +++ b/lib/services/device_service.dart @@ -2,54 +2,71 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; /// デバイスID取得サービス -/// レート制限のためのデバイス識別に使用 +/// レート制限・ライセンス管理のためのデバイス識別に使用 +/// +/// ## 安定性の保証 +/// - Android: ANDROID_ID をハッシュ化して使用(Factory Reset まで不変) +/// - iOS: SharedPreferences に UUID を永続化(identifierForVendor は +/// 全ベンダーアプリ削除後の再インストールで変わるため不使用) +/// - その他/エラー時: SharedPreferences に UUID を永続化 class DeviceService { static final DeviceInfoPlugin _deviceInfo = DeviceInfoPlugin(); static String? _cachedDeviceId; + static const _prefKey = 'ponshu_device_id'; + /// デバイス固有のIDを取得(SHA256ハッシュ化) static Future getDeviceId() async { - // キャッシュがあれば返す - if (_cachedDeviceId != null) { + if (_cachedDeviceId != null) return _cachedDeviceId!; + + final prefs = await SharedPreferences.getInstance(); + + // SharedPreferencesに永続化済みならそれを使う(再インストール後も同じIDを返す) + final stored = prefs.getString(_prefKey); + if (stored != null && stored.isNotEmpty) { + _cachedDeviceId = stored; + debugPrint('[Device] Using persisted device ID: ${stored.substring(0, 8)}...'); return _cachedDeviceId!; } + // 初回: ハードウェアIDから生成してSharedPreferencesに保存 + final id = await _generateAndPersist(prefs); + _cachedDeviceId = id; + return id; + } + + static Future _generateAndPersist(SharedPreferences prefs) async { + String deviceIdentifier; + try { - String deviceIdentifier; - if (defaultTargetPlatform == TargetPlatform.android) { - final androidInfo = await _deviceInfo.androidInfo; - // Android IDを使用(アプリ再インストールでも同じIDを維持) - deviceIdentifier = androidInfo.id; - } else if (defaultTargetPlatform == TargetPlatform.iOS) { - final iosInfo = await _deviceInfo.iosInfo; - // identifierForVendor(アプリ再インストールで変わる可能性あり) - deviceIdentifier = iosInfo.identifierForVendor ?? 'unknown-ios'; + final info = await _deviceInfo.androidInfo; + // ANDROID_ID: アプリ再インストールでは変わらない。Factory Resetで変わる。 + deviceIdentifier = 'android-${info.id}'; } else { - // その他のプラットフォーム - deviceIdentifier = 'unknown-platform'; + // iOS / その他: UUIDを生成して永続化 + // identifierForVendor は全ベンダーアプリ削除後の再インストールで変わるため不使用 + deviceIdentifier = 'uuid-${const Uuid().v4()}'; } - - // SHA256ハッシュ化(64文字の固定長文字列) - final bytes = utf8.encode(deviceIdentifier); - final digest = sha256.convert(bytes); - _cachedDeviceId = digest.toString(); - - debugPrint('Device ID (hashed): ${_cachedDeviceId!.substring(0, 8)}...'); - - return _cachedDeviceId!; } catch (e) { - debugPrint('Error getting device ID: $e'); - // エラー時はランダムなIDを生成(セッション中は同じIDを使用) - _cachedDeviceId = sha256.convert(utf8.encode('fallback-${DateTime.now().millisecondsSinceEpoch}')).toString(); - return _cachedDeviceId!; + debugPrint('[Device] Failed to get hardware ID, using UUID fallback: $e'); + deviceIdentifier = 'fallback-${const Uuid().v4()}'; } + + final id = sha256.convert(utf8.encode(deviceIdentifier)).toString(); + await prefs.setString(_prefKey, id); + debugPrint('[Device] Generated and persisted device ID: ${id.substring(0, 8)}...'); + return id; } /// デバイス情報をリセット(テスト用) - static void reset() { + static Future reset() async { _cachedDeviceId = null; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_prefKey); } } diff --git a/lib/services/license_service.dart b/lib/services/license_service.dart index 9807b6e..79c1734 100644 --- a/lib/services/license_service.dart +++ b/lib/services/license_service.dart @@ -149,9 +149,11 @@ class LicenseService { if (cached == null) return LicenseStatus.free; // キャッシュが古すぎる場合はfreeにフォールバック + // pro と revoked は期限切れにしない(proは購入者を締め出さない、revokedは誤って復活させない) if (cachedAt != null) { final age = DateTime.now().difference(DateTime.parse(cachedAt)); - if (age.inSeconds > _cacheValidSeconds && cached != LicenseStatus.pro.name) { + final isPermanentStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.name; + if (age.inSeconds > _cacheValidSeconds && !isPermanentStatus) { return LicenseStatus.free; } }