Compare commits

...

12 Commits

Author SHA1 Message Date
Ponshu Developer f8ceb0bf02 fix: ソムリエ画面UIを統一 — カードスタイル・padding・重複ヘッダー削除
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 12:11:21 +09:00
Ponshu Developer ba5660c1cb fix: release_to_gitea.ps1にAPKバージョン一致チェックを追加
pubspec.yamlのversionとAPKの埋め込みversionNameが異なる場合は
エラーで中断する。「バージョン上げ前ビルド→リリース」ミスを防ぐ。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 09:45:16 +09:00
Ponshu Developer 402c6b6448 chore: update download page to v1.0.28
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 09:19:31 +09:00
Ponshu Developer 675e67e3c1 refactor: デッドコード削除 — analyzeSakeHybrid/analyzeSakeText
- gemini_service.dart: 未使用の analyzeSakeHybrid / analyzeSakeText を削除
  (どこからも呼ばれておらず、画像直接解析 analyzeSakeLabel のみ使用中)
- main.dart: isProVersion を「現在未使用」コメントに更新
- version: 1.0.28+35

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 09:19:03 +09:00
Ponshu Developer c7168e831c chore: update download page to v1.0.27
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 08:58:42 +09:00
Ponshu Developer d23ee8ed77 fix: AI解析プロンプトの銘柄名補完バグを修正
- 銘柄名(name)と蔵元名(brand)はラベルの文字をそのまま使うよう明示
  「東魁」→「東魁盛」のような知識補完を禁止する指示を追加
- 全3プロンプト(画像解析・ハイブリッド・テキストのみ)に適用
- version: 1.0.27+34

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 08:58:02 +09:00
Ponshu Developer f3d6a92799 chore: v1.0.26 リリース完了 — APKアップロード・ダウンロードページ更新
- releases.json: v1.0.26 のダウンロードURLに更新
- release_to_gitea.ps1: consumer命名に統一 (lite/pro分割廃止)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 08:23:10 +09:00
Ponshu Developer f229ff6b4b fix: 外部コードレビュー指摘の修正4件
device_service.dart:
- iOS device_idをSharedPreferencesにUUID永続化(identifierForVendor廃止)
  → 全ベンダーアプリ削除後の再インストール後もライセンスが継続する
- fallback device_idもSharedPreferencesに永続化
  → アプリ再起動のたびにIDが変わるバグを修正

license_service.dart:
- revokedキャッシュが24h後にfreeに降格するバグを修正
  → proとrevokedはキャッシュ有効期限の対象外にする

sake_mbti_stamp_section.dart / sake_detail_screen.dart:
- isProVersion(コンパイル時) → isProProvider(実行時ライセンス)に移行
  → ライセンス購入後にアプリ再起動なしでMBTIスタンプが有効になる

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 08:14:37 +09:00
Ponshu Developer 7b3249791e fix: セルフコードレビュー修正 — ライセンス管理バグ4件
- license_service.dart: オフライン時にProキャッシュを上書きしないよう修正
  (offline statusを受け取ったらキャッシュフォールバックを使う)
- license_service.dart: activate()でoffline状態を明示的にハンドル
  (「キーが見つかりません」ではなく「接続できません」と表示)
- license_service.dart: ライセンスキーのバイト数コメントを修正 (16→6バイト)
- license_screen.dart: Pro画面の誤ったテキスト修正
  (「AI解析無制限」→「すべてのPro機能」)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 06:42:35 +09:00
Ponshu Developer 659e81628a chore: v1.0.26 — Pro版ライセンス管理リリース
- pubspec.yaml: 1.0.21+32 -> 1.0.26+33
- home_screen.dart: 不正な ';' を削除(構文エラー修正)
- releases.json: v1.0.26 に更新

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 00:23:36 +09:00
Ponshu Developer 5311241fe5 feat: ライセンス検証をVPSエンドポイントへ移行
- secrets.dart: posimaiBaseUrl を追加 (https://api.soar-enrich.com)
- license_service.dart: 検証URLをproxyから /api/ponshu/license/validate へ変更
  + LicenseStatus.trialExpired 参照バグを修正
  + _cachedTrialInfo 残骸変数参照を削除
- proxy/server.js: /license/validate と /admin/license/revoke を削除
  (ライセンス管理はPostgreSQL + VPS server.js が担当)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 00:16:52 +09:00
Ponshu Developer d47bb201ac feat: ライセンス管理システムを追加(Pro機能解放型)
- LicenseService / LicenseProvider / LicenseScreen を追加
- main_screen.dart: isProVersion(ビルド時)→ isProProvider(ランタイム)に変更
  ライセンスキー購入後にARスキャン・Analytics・Instagram支援が解放される
- home_screen.dart: 不要なTrialStatusBannerを削除(AI解析は常時無制限)
- proxy/server.js: トライアル回数制限・Stripe/Resendコードを削除(整理)
- proxy/package.json: stripe/resend依存を削除
- .github/workflows/ios_build.yml: iOS CI追加
- scripts/deploy_android.ps1: Android配布スクリプト追加

AI解析(1日50回)はLite版でも制限なし
Proライセンス購入でARスキャン・Analytics・Instagram支援を解放

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 00:05:53 +09:00
20 changed files with 946 additions and 324 deletions

43
.github/workflows/ios_build.yml vendored Normal file
View File

@ -0,0 +1,43 @@
name: iOS Build & TestFlight
on:
push:
tags:
- 'v*' # 例: git tag v1.0.0 をプッシュした時に実行
jobs:
build:
name: Build & Deploy to TestFlight
runs-on: macos-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.29.x'
channel: 'stable'
- name: Install dependencies
run: flutter pub get
# iOSビルド (証明書不要の No Code Sign オプション)
# 実際の署名はAppStore Connectへのアップロード時に行われる
- name: Build iOS
run: |
flutter build ios --release --no-codesign \
--dart-define=GEMINI_API_KEY=dist-build-key \
--dart-define=AI_PROXY_URL=${{ secrets.VPS_PROXY_URL }} \
--dart-define=USE_PROXY=true
# TestFlight へのアップロード
# App Store Connect API Key を GitHub Secrets に設定する必要あり
- name: Upload to TestFlight
uses: apple-actions/upload-testflight-build@v1
with:
app-path: 'build/ios/iphoneos/Runner.app'
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }}
api-private-key: ${{ secrets.APPSTORE_API_PRIVATE_KEY }}

View File

@ -7,15 +7,11 @@ import 'models/user_profile.dart';
import 'models/menu_settings.dart';
import 'providers/theme_provider.dart';
import 'screens/main_screen.dart';
import 'screens/license_screen.dart';
import 'services/migration_service.dart';
/// Pro版かLite版かを判定するビルド時フラグ
///
/// :
/// - Pro版: flutter build apk --release --dart-define=IS_PRO_VERSION=true
/// - Lite版: flutter build apk --release --dart-define=IS_PRO_VERSION=false
///
/// falseLite版 ponshu_room_liteディレクトリのため
/// Pro解放フラグ使 isProProviderで管理
/// isProProvider (license_provider.dart) 使
const bool isProVersion = bool.fromEnvironment('IS_PRO_VERSION', defaultValue: false);
///
@ -111,6 +107,9 @@ class MyApp extends ConsumerWidget {
navigatorObservers: [routeObserver],
home: const MainScreen(),
routes: {
'/upgrade': (context) => const LicenseScreen(),
},
);
}
}

View File

@ -0,0 +1,19 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/license_service.dart';
///
///
/// VPSに問い合わせ
/// [licenseStatusProvider].invalidate()
final licenseStatusProvider = FutureProvider<LicenseStatus>((ref) async {
return LicenseService.checkStatus();
});
/// Pro版かどうか使
final isProProvider = Provider<bool>((ref) {
final statusAsync = ref.watch(licenseStatusProvider);
return statusAsync.maybeWhen(
data: (status) => status == LicenseStatus.pro,
orElse: () => false,
);
});

View File

@ -135,9 +135,7 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
_buildMBTIDiagnosisSection(context, userProfile, sakeList),
const SizedBox(height: 32),
//
_buildSectionHeader(context, 'さけのわ おすすめ', LucideIcons.trendingUp),
const SizedBox(height: 8),
//
const SakenowaNewRecommendationSection(displayCount: 5),
const SizedBox(height: 16),
const SakenowaRankingSection(displayCount: 10),
@ -224,13 +222,13 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
color: appColors.brandAccent.withValues(alpha: 0.05),
),
),
// Subtle Sake Emoji
Positioned(
left: 20,
top: 20,
child: Opacity(
opacity: 0.3, // Subtle
opacity: 0.3,
child: const Text(
'🍶',
style: TextStyle(fontSize: 40),
@ -239,7 +237,7 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 12.0), // Optimized for effect
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
// 1. Header (Name & Rank) with unified help icon
@ -353,11 +351,32 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
// --- MBTI Diagnosis Logic ---
Widget _buildMBTIDiagnosisSection(BuildContext context, UserProfile userProfile, List<SakeItem> sakeList) {
final appColors = Theme.of(context).extension<AppColors>()!;
return Card(
elevation: 2,
color: appColors.surfaceSubtle,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
border: isDark
? null
: Border.all(color: appColors.divider.withValues(alpha: 0.5), width: 1),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDark
? [const Color(0xFF2C2C2E), const Color(0xFF1C1C1E)]
: [appColors.surfaceSubtle, appColors.surfaceElevated],
),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.5)
: Colors.black.withValues(alpha: 0.08),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(

View File

@ -0,0 +1,240 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/license_provider.dart';
import '../services/license_service.dart';
import '../theme/app_colors.dart';
import 'package:url_launcher/url_launcher.dart';
class LicenseScreen extends ConsumerStatefulWidget {
const LicenseScreen({super.key});
@override
ConsumerState<LicenseScreen> createState() => _LicenseScreenState();
}
class _LicenseScreenState extends ConsumerState<LicenseScreen> {
final _keyController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
@override
void dispose() {
_keyController.dispose();
super.dispose();
}
Future<void> _activate() async {
final key = _keyController.text.trim();
if (key.isEmpty) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
final result = await LicenseService.activate(key);
if (mounted) {
if (result.success) {
ref.invalidate(licenseStatusProvider);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Pro版ライセンスを有効化しました'),
backgroundColor: Theme.of(context).extension<AppColors>()!.success,
),
);
Navigator.of(context).pop();
} else {
setState(() {
_errorMessage = result.message;
_isLoading = false;
});
}
}
}
void _openStore() async {
const storeUrl = 'https://posimai-store.soar-enrich.com'; // TODO: URL
final uri = Uri.parse(storeUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).extension<AppColors>()!;
final isPro = ref.watch(isProProvider);
return Scaffold(
backgroundColor: colors.brandSurface,
appBar: AppBar(
title: Text(
'ライセンスの有効化',
style: TextStyle(color: colors.textPrimary, fontWeight: FontWeight.bold),
),
backgroundColor: colors.brandSurface,
elevation: 0,
iconTheme: IconThemeData(color: colors.textPrimary),
),
body: SafeArea(
child: isPro ? _buildProState(colors) : _buildActivationForm(colors),
),
);
}
Widget _buildProState(AppColors colors) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.verified, size: 64, color: colors.success),
const SizedBox(height: 24),
Text(
'Pro版ライセンス有効',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: colors.textPrimary,
),
),
const SizedBox(height: 16),
Text(
'すべてのPro機能をご利用いただけます。\nご購入ありがとうございました。',
textAlign: TextAlign.center,
style: TextStyle(color: colors.textSecondary, height: 1.5),
),
],
),
),
);
}
Widget _buildActivationForm(AppColors colors) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(Icons.key, size: 64, color: colors.brandPrimary),
const SizedBox(height: 24),
Text(
'ご購入ありがとうございます。',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: colors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
'メールで受け取った「PONSHU-」から始まる\nライセンスキーを入力してください。',
textAlign: TextAlign.center,
style: TextStyle(color: colors.textSecondary, height: 1.5),
),
const SizedBox(height: 32),
TextField(
controller: _keyController,
decoration: InputDecoration(
hintText: 'PONSHU-XXXX-XXXX-XXXX',
filled: true,
fillColor: colors.surfaceSubtle,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colors.divider),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colors.divider),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colors.brandPrimary, width: 2),
),
prefixIcon: Icon(Icons.vpn_key_outlined, color: colors.iconSubtle),
),
textCapitalization: TextCapitalization.characters,
onSubmitted: (_) => _activate(),
),
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.error.withValues(alpha: 0.3)),
),
child: Row(
children: [
Icon(Icons.error_outline, color: colors.error, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: TextStyle(color: colors.error, fontSize: 13),
),
),
],
),
),
],
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _activate,
style: ElevatedButton.styleFrom(
backgroundColor: colors.brandPrimary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text(
'有効化する',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 48),
Divider(color: colors.divider),
const SizedBox(height: 24),
Text(
'ライセンスをお持ちでない方',
textAlign: TextAlign.center,
style: TextStyle(color: colors.textSecondary, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
OutlinedButton(
onPressed: _openStore,
style: OutlinedButton.styleFrom(
foregroundColor: colors.brandPrimary,
side: BorderSide(color: colors.brandPrimary),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.shopping_cart_outlined, size: 20),
SizedBox(width: 8),
Text('ストアで購入する', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
),
],
),
);
}
}

View File

@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../main.dart'; // Import isProVersion flag
import '../main.dart'; // Import isBusinessApp flag
import '../providers/license_provider.dart';
import '../providers/theme_provider.dart'; // Access userProfileProvider
import '../providers/navigation_provider.dart'; // Track current tab index
import '../utils/translations.dart'; // Translation helper
@ -100,6 +101,13 @@ class _MainScreenState extends ConsumerState<MainScreen> {
onPressed: () => Navigator.of(context).pop(),
child: const Text('閉じる'),
),
FilledButton(
onPressed: () {
Navigator.of(context).pop();
Navigator.of(context).pushNamed('/upgrade');
},
child: const Text('ライセンスを有効化'),
),
],
),
);
@ -127,53 +135,53 @@ class _MainScreenState extends ConsumerState<MainScreen> {
});
final userProfile = ref.watch(userProfileProvider);
final isPro = ref.watch(isProProvider);
// isBusinessApp=false
final isBusiness = isBusinessApp && userProfile.isBusinessMode;
final t = Translations(userProfile.locale); // Translation helper
// Define Screens for each mode
// Lite版のPro限定タブは表示されないようにダミー画面を配置
//
// Pro版でないタブはダミー画面を配置
final List<Widget> screens = isBusiness
? [
const HomeScreen(), // Inventory Management
isProVersion ? const InstaSupportScreen() : const HomeScreen(), // Instagram Support (Pro only)
isProVersion ? const AnalyticsScreen() : const HomeScreen(), // Analytics (Pro only)
isPro ? const InstaSupportScreen() : const HomeScreen(), // Instagram Support (Pro only)
isPro ? const AnalyticsScreen() : const HomeScreen(), // Analytics (Pro only)
const ShopSettingsScreen(), // Shop Settings
]
: [
const HomeScreen(), // My Sake List
isProVersion ? const ScanARScreen() : const HomeScreen(), // QR Scan (Pro only)
isPro ? const ScanARScreen() : const HomeScreen(), // QR Scan (Pro only)
const SommelierScreen(), // Sommelier
const BreweryMapScreen(), // Map
const SoulScreen(), // MyPage/Settings
];
// Define Navigation Items (with translation)
// Lite版では王冠バッジを表示
// Pro版でない場合は王冠バッジを表示
final List<NavigationDestination> destinations = isBusiness
? [
NavigationDestination(
icon: const Padding(padding: EdgeInsets.only(bottom: 2), child: Text('🍶', style: TextStyle(fontSize: 22))),
icon: const Padding(padding: EdgeInsets.only(bottom: 2), child: Icon(LucideIcons.home)),
label: t['home'],
),
NavigationDestination(
icon: isProVersion ? const Icon(LucideIcons.instagram) : const _IconWithCrownBadge(icon: LucideIcons.instagram),
icon: isPro ? const Icon(LucideIcons.instagram) : const _IconWithCrownBadge(icon: LucideIcons.instagram),
label: t['promo'],
),
NavigationDestination(
icon: isProVersion ? const Icon(LucideIcons.barChart) : const _IconWithCrownBadge(icon: LucideIcons.barChart),
icon: isPro ? const Icon(LucideIcons.barChart) : const _IconWithCrownBadge(icon: LucideIcons.barChart),
label: t['analytics'],
),
NavigationDestination(icon: const Icon(LucideIcons.store), label: t['shop']),
]
: [
NavigationDestination(
icon: const Padding(padding: EdgeInsets.only(bottom: 2), child: Text('🍶', style: TextStyle(fontSize: 22))),
icon: const Padding(padding: EdgeInsets.only(bottom: 2), child: Icon(LucideIcons.home)),
label: t['home'],
),
NavigationDestination(
icon: isProVersion ? const Icon(LucideIcons.scanLine) : const _IconWithCrownBadge(icon: LucideIcons.scanLine),
icon: isPro ? const Icon(LucideIcons.scanLine) : const _IconWithCrownBadge(icon: LucideIcons.scanLine),
label: t['scan'],
),
NavigationDestination(icon: const Icon(LucideIcons.sparkles), label: t['sommelier']),
@ -194,8 +202,8 @@ class _MainScreenState extends ConsumerState<MainScreen> {
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (index) {
// Lite版でPro限定タブをタップした場合はダイアログを表示
if (!isProVersion) {
// Pro版でない場合にPro限定タブをタップしたらダイアログを表示
if (!isPro) {
if (isBusiness) {
// : Instagram (index 1) Analytics (index 2) Pro限定
if (index == 1) {

View File

@ -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<AppColors>()!;
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),
);

View File

@ -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<SakeDetailScreen> {
@override
Widget build(BuildContext context) {
final appColors = Theme.of(context).extension<AppColors>()!;
final isPro = ref.watch(isProProvider);
//
final allSakeAsync = ref.watch(allSakeItemsProvider);
@ -254,7 +255,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
],
// MBTI Diagnostic Stamp Section (Pro only)
if (isProVersion) ...[
if (isPro) ...[
SakeMbtiStampSection(sake: _sake),
const SizedBox(height: 24),
],

View File

@ -72,5 +72,11 @@ class Secrets {
return local.SecretsLocal.geminiApiKey;
}
/// Posimai URL使
static const String posimaiBaseUrl = String.fromEnvironment(
'POSIMAI_BASE_URL',
defaultValue: 'https://api.soar-enrich.com',
);
// static const String driveClientId = String.fromEnvironment('DRIVE_CLIENT_ID', defaultValue: '');
}

View File

@ -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<String> 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<String> _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<void> reset() async {
_cachedDeviceId = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefKey);
}
}

View File

@ -44,7 +44,10 @@ class GeminiService {
"manufacturingYearMonth": "2023.10"
}
null tasteStatsは必ず1-5
:
- "name" "brand"****使
- tasteStatsは必ず1-5
- null
''';
return _callProxyApi(
@ -54,82 +57,6 @@ class GeminiService {
);
}
/// OCRテキストと画像のハイブリッド解析
Future<SakeAnalysisResult> analyzeSakeHybrid(String extractedText, List<String> imagePaths) async {
final prompt = '''
OCR抽出テキストは参考情報です
OCRテキストはあくまで補助的なヒントとして扱い
OCRテキスト:
"""
$extractedText
"""
JSON形式で情報を抽出してください
{
"name": "銘柄名",
"brand": "蔵元名",
"prefecture": "都道府県名",
"type": "特定名称(純米大吟醸など)",
"description": "味や特徴の魅力的な説明文(100文字程度)",
"catchCopy": "短いキャッチコピー(20文字以内)",
"confidenceScore": 80,
"flavorTags": ["フルーティー", "辛口", "華やか"],
"tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3},
"alcoholContent": 15.0,
"polishingRatio": 50,
"sakeMeterValue": 3.0,
"riceVariety": "山田錦",
"yeast": "きょうかい9号",
"manufacturingYearMonth": "2023.10"
}
:
- tasteStats1-53
- alcoholContent, polishingRatio, sakeMeterValue
- null
''';
return _callProxyApi(imagePaths: imagePaths, customPrompt: prompt);
}
/// ()
Future<SakeAnalysisResult> analyzeSakeText(String extractedText) async {
final prompt = '''
OCRで抽出された日本酒ラベルのテキスト情報を分析してください
:
"""
$extractedText
"""
JSON形式で返してください
{
"name": "銘柄名",
"brand": "蔵元名",
"prefecture": "都道府県名",
"type": "特定名称",
"description": "特徴(100文字)",
"catchCopy": "キャッチコピー(20文字)",
"confidenceScore": 0-100,
"flavorTags": ["タグ"],
"tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3},
"alcoholContent": 15.5,
"polishingRatio": 50,
"sakeMeterValue": 3.0,
"riceVariety": "山田錦",
"yeast": "きょうかい9号",
"manufacturingYearMonth": "2023.10"
}
''';
return _callProxyApi(imagePaths: [], customPrompt: prompt);
}
/// : ProxyへのAPIコール
Future<SakeAnalysisResult> _callProxyApi({
required List<String> imagePaths,

View File

@ -0,0 +1,167 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'device_service.dart';
import '../secrets.dart';
///
enum LicenseStatus {
/// AI解析は1日50回まで利用可能
free,
/// Pro版ライセンス有効Pro機能すべて解放
pro,
///
revoked,
/// /
offline,
}
///
///
/// ##
/// 1. : VPSに問い合わせた結果を使用 + SharedPreferencesにキャッシュ
/// 2. : SharedPreferencesのキャッシュを使用 (Pro状態を維持)
///
/// ##
/// PONSHU-XXXX-XXXX-XXXX (6 = 12hex文字, )
class LicenseService {
static const _prefLicenseKey = 'ponshu_license_key';
static const _prefCachedStatus = 'ponshu_license_status_cache';
static const _prefCachedAt = 'ponshu_license_cached_at';
static const _cacheValidSeconds = 24 * 60 * 60; // 24
// ========== Public API ==========
/// :
static Future<LicenseStatus> checkStatus() async {
final prefs = await SharedPreferences.getInstance();
final savedKey = prefs.getString(_prefLicenseKey) ?? '';
//
if (savedKey.isNotEmpty) {
try {
final status = await _validateKeyWithServer(savedKey);
if (status == LicenseStatus.offline) {
// :
debugPrint('[License] Server unreachable, using cache');
return _getCachedStatus(prefs);
}
await _cacheStatus(prefs, status);
return status;
} catch (e) {
debugPrint('[License] Server unreachable, using cache: $e');
return _getCachedStatus(prefs);
}
}
//
return LicenseStatus.free;
}
///
///
/// : true +
/// : false +
static Future<({bool success, String message})> activate(String rawKey) async {
final key = rawKey.trim().toUpperCase();
if (!_isValidKeyFormat(key)) {
return (success: false, message: 'キーの形式が正しくありません\n(例: PONSHU-XXXX-XXXX-XXXX)');
}
final status = await _validateKeyWithServer(key);
if (status == LicenseStatus.pro) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefLicenseKey, key);
await _cacheStatus(prefs, LicenseStatus.pro);
debugPrint('[License] Activated successfully.');
return (success: true, message: '');
}
if (status == LicenseStatus.revoked) {
return (success: false, message: 'このライセンスは無効化されています。\nサポートにお問い合わせください。');
}
if (status == LicenseStatus.offline) {
return (success: false, message: 'サーバーに接続できませんでした。\nネットワーク接続を確認してください。');
}
return (success: false, message: 'ライセンスキーが見つかりません。\nご購入時のメールをご確認ください。');
}
///
static Future<bool> hasLicenseKey() async {
final prefs = await SharedPreferences.getInstance();
return (prefs.getString(_prefLicenseKey) ?? '').isNotEmpty;
}
///
static Future<void> reset() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefLicenseKey);
await prefs.remove(_prefCachedStatus);
await prefs.remove(_prefCachedAt);
debugPrint('[License] Reset complete.');
}
// ========== Private Helpers ==========
static bool _isValidKeyFormat(String key) {
final regex = RegExp(r'^PONSHU-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$');
return regex.hasMatch(key);
}
static Future<LicenseStatus> _validateKeyWithServer(String key) async {
try {
final deviceId = await DeviceService.getDeviceId();
final response = await http.post(
Uri.parse('${Secrets.posimaiBaseUrl}/api/ponshu/license/validate'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'license_key': key, 'device_id': deviceId}),
).timeout(const Duration(seconds: 15));
final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
if (data['valid'] == true) return LicenseStatus.pro;
if ((data['error'] as String? ?? '').contains('無効化')) return LicenseStatus.revoked;
return LicenseStatus.free;
} catch (e) {
debugPrint('[License] Validation network error: $e');
return LicenseStatus.offline;
}
}
static Future<void> _cacheStatus(SharedPreferences prefs, LicenseStatus status) async {
await prefs.setString(_prefCachedStatus, status.name);
await prefs.setString(_prefCachedAt, DateTime.now().toIso8601String());
}
static LicenseStatus _getCachedStatus(SharedPreferences prefs) {
final cached = prefs.getString(_prefCachedStatus);
final cachedAt = prefs.getString(_prefCachedAt);
if (cached == null) return LicenseStatus.free;
// freeにフォールバック
// pro revoked proは購入者を締め出さないrevokedは誤って復活させない
if (cachedAt != null) {
final age = DateTime.now().difference(DateTime.parse(cachedAt));
final isPermanentStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.name;
if (age.inSeconds > _cacheValidSeconds && !isPermanentStatus) {
return LicenseStatus.free;
}
}
// Pro
return LicenseStatus.values.firstWhere(
(s) => s.name == cached,
orElse: () => LicenseStatus.free,
);
}
}

View File

@ -54,6 +54,15 @@ class _OtherSettingsSectionState extends ConsumerState<OtherSettingsSection> {
color: appColors.surfaceSubtle,
child: Column(
children: [
ListTile(
leading: Icon(LucideIcons.key, color: appColors.brandPrimary),
title: Text('ライセンスの有効化', style: TextStyle(color: appColors.textPrimary)),
subtitle: Text('Pro版の認証・フリートライアル状態', style: TextStyle(color: appColors.textSecondary)),
trailing: Icon(LucideIcons.chevronRight, color: appColors.iconSubtle),
onTap: () => Navigator.of(context).pushNamed('/upgrade'),
),
Divider(height: 1, color: appColors.divider),
if (widget.showBusinessMode) ...[
ListTile(
leading: Icon(LucideIcons.store, color: appColors.warning),

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
# 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.
version: 1.0.21+32
version: 1.0.28+35
environment:
sdk: ^3.10.1

210
release_to_gitea.ps1 Normal file
View File

@ -0,0 +1,210 @@
param([string]$ApkDir = "")
# Load .env.local
$envFile = Join-Path $PSScriptRoot ".env.local"
if (-not (Test-Path $envFile)) { Write-Error ".env.local not found"; exit 1 }
Get-Content $envFile | ForEach-Object {
$line = $_.Trim()
if ($line -and -not $line.StartsWith('#') -and $line.Contains('=')) {
$idx = $line.IndexOf('=')
$key = $line.Substring(0, $idx).Trim()
$val = $line.Substring($idx + 1).Trim()
[System.Environment]::SetEnvironmentVariable($key, $val)
}
}
$GITEA_TOKEN = $env:GITEA_TOKEN
$GITEA_BASE_URL = $env:GITEA_BASE_URL
$GITEA_OWNER = $env:GITEA_OWNER
$GITEA_REPO = $env:GITEA_REPO
if (-not $GITEA_TOKEN) { Write-Error "GITEA_TOKEN not set in .env.local"; exit 1 }
# Find latest APK build folder
if (-not $ApkDir) {
$root = Join-Path $PSScriptRoot "build\apk_releases"
if (-not (Test-Path $root)) { Write-Error "build\apk_releases not found"; exit 1 }
$ApkDir = Get-ChildItem $root -Directory |
Sort-Object Name -Descending |
Select-Object -First 1 -ExpandProperty FullName
}
if (-not (Test-Path $ApkDir)) { Write-Error "APK folder not found: $ApkDir"; exit 1 }
$apkFiles = Get-ChildItem $ApkDir -Filter "*.apk"
if ($apkFiles.Count -eq 0) { Write-Error "No APK files in: $ApkDir"; exit 1 }
# Read version from pubspec.yaml
$publine = Get-Content (Join-Path $PSScriptRoot "pubspec.yaml") | Where-Object { $_ -match "^version:" } | Select-Object -First 1
$version = if ($publine -match "version:\s*(\S+)") { $Matches[1].Split("+")[0] } else { "1.0.0" }
$buildNum = if ($publine -match "version:\s*[^+]+\+(\S+)") { $Matches[1] } else { "0" }
$dateStr = Get-Date -Format "yyyy-MM-dd"
$tagName = "v$version"
$relName = "Ponshu Room $version ($dateStr)"
# APKの埋め込みバージョンと pubspec.yaml が一致するか確認
Write-Host " Checking APK version matches pubspec ($version+$buildNum)..." -ForegroundColor Gray
$firstApk = $apkFiles | Select-Object -First 1
$apkInfo = & flutter.bat --version 2>$null
# aapt2 でバージョン確認(利用可能な場合)
$aapt2 = Get-ChildItem "$env:LOCALAPPDATA\Android\Sdk\build-tools" -Recurse -Filter "aapt2.exe" -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName
if ($aapt2) {
$apkDump = & $aapt2 dump badging $firstApk.FullName 2>$null | Select-String "versionName|versionCode"
$apkVersion = if ($apkDump -match "versionName='([^']+)'") { $Matches[1] } else { $null }
$apkBuild = if ($apkDump -match "versionCode='([^']+)'") { $Matches[1] } else { $null }
if ($apkVersion -and $apkVersion -ne $version) {
Write-Host ""
Write-Host " [ERROR] APK version mismatch!" -ForegroundColor Red
Write-Host " APK contains : $apkVersion (build $apkBuild)" -ForegroundColor Red
Write-Host " pubspec.yaml : $version+$buildNum" -ForegroundColor Red
Write-Host " -> Rebuild APKs after version bump, then retry." -ForegroundColor Yellow
exit 1
}
Write-Host " OK: APK version = $apkVersion (build $apkBuild)" -ForegroundColor Green
} else {
Write-Host " (aapt2 not found, skipping version check)" -ForegroundColor DarkGray
}
Write-Host "================================================" -ForegroundColor Cyan
Write-Host " Ponshu Room - Gitea Auto Release" -ForegroundColor Cyan
Write-Host "================================================" -ForegroundColor Cyan
Write-Host " Server : $GITEA_BASE_URL" -ForegroundColor Gray
Write-Host " Repo : $GITEA_OWNER/$GITEA_REPO" -ForegroundColor Gray
Write-Host " Tag : $tagName" -ForegroundColor Gray
Write-Host " APKs : $($apkFiles.Count) files" -ForegroundColor Gray
Write-Host ""
$authHeader = @{ "Authorization" = "token $GITEA_TOKEN" }
# Step 1: Delete existing release if found
Write-Host "[1/3] Checking existing release..." -ForegroundColor Yellow
try {
$existing = Invoke-RestMethod `
-Uri "$GITEA_BASE_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases/tags/$tagName" `
-Headers $authHeader -Method Get -ErrorAction Stop
Write-Host " Found existing (ID: $($existing.id)). Deleting..." -ForegroundColor Yellow
Invoke-RestMethod `
-Uri "$GITEA_BASE_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases/$($existing.id)" `
-Headers $authHeader -Method Delete | Out-Null
} catch {
Write-Host " No existing release. Creating new." -ForegroundColor Gray
}
# Step 2: Create release
Write-Host "[2/3] Creating release..." -ForegroundColor Yellow
$releaseNotes = @(
"## Ponshu Room $version ($dateStr)",
"",
"### APK Files",
"- ponshu_room_consumer_maita.apk : Maita",
"- ponshu_room_consumer_eiji.apk : Eiji",
"",
"### Install",
"1. Download the APK for your name",
"2. On Android: Settings > Security > Allow unknown sources",
"3. Tap the downloaded APK to install",
"",
"Requires Android 8.0+ (API 26+)"
) -join "`n"
$body = @{
tag_name = $tagName
name = $relName
body = $releaseNotes
draft = $false
prerelease = $false
} | ConvertTo-Json -Depth 3
$release = Invoke-RestMethod `
-Uri "$GITEA_BASE_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" `
-Headers $authHeader `
-Method Post `
-ContentType "application/json" `
-Body $body
Write-Host " OK - Release ID: $($release.id)" -ForegroundColor Green
# Step 3: Upload APKs with multipart/form-data
Write-Host "[3/3] Uploading APK files..." -ForegroundColor Yellow
$downloadUrls = @{}
foreach ($apk in $apkFiles) {
$mb = [math]::Round($apk.Length / 1MB, 1)
Write-Host " Uploading: $($apk.Name) ($mb MB)..." -ForegroundColor Gray
$boundary = [System.Guid]::NewGuid().ToString()
$uploadUrl = "$GITEA_BASE_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases/$($release.id)/assets?name=$($apk.Name)"
$fileBytes = [System.IO.File]::ReadAllBytes($apk.FullName)
$enc = [System.Text.Encoding]::UTF8
$bodyParts = [System.Collections.Generic.List[byte]]::new()
$header = "--$boundary`r`nContent-Disposition: form-data; name=`"attachment`"; filename=`"$($apk.Name)`"`r`nContent-Type: application/octet-stream`r`n`r`n"
$bodyParts.AddRange($enc.GetBytes($header))
$bodyParts.AddRange($fileBytes)
$footer = "`r`n--$boundary--`r`n"
$bodyParts.AddRange($enc.GetBytes($footer))
$bodyArray = $bodyParts.ToArray()
$wc = New-Object System.Net.WebClient
$wc.Headers.Add("Authorization", "token $GITEA_TOKEN")
$wc.Headers.Add("Content-Type", "multipart/form-data; boundary=$boundary")
$result = $wc.UploadData($uploadUrl, "POST", $bodyArray)
$wc.Dispose()
$json = [System.Text.Encoding]::UTF8.GetString($result) | ConvertFrom-Json
$downloadUrls[$apk.BaseName] = $json.browser_download_url
Write-Host " OK: $($json.browser_download_url)" -ForegroundColor Green
}
# Step 4: Update releases.json for Vercel
Write-Host ""
Write-Host "[4/4] Updating releases.json..." -ForegroundColor Yellow
$tailscaleBase = "$GITEA_BASE_URL".Replace("http://100.76.7.3:3000", "https://posimai-lab.tail72e846.ts.net")
$relJson = @{
version = $tagName
name = $relName
date = $dateStr
apks = @{
maita = @{
lite = @{ filename = "ponshu_room_consumer_maita.apk"; url = "$tailscaleBase/mai/ponshu-room-lite/releases/download/$tagName/ponshu_room_consumer_maita.apk"; size_mb = [math]::Round(($apkFiles | Where-Object { $_.Name -eq "ponshu_room_consumer_maita.apk" }).Length / 1MB) }
}
eiji = @{
lite = @{ filename = "ponshu_room_consumer_eiji.apk"; url = "$tailscaleBase/mai/ponshu-room-lite/releases/download/$tagName/ponshu_room_consumer_eiji.apk"; size_mb = [math]::Round(($apkFiles | Where-Object { $_.Name -eq "ponshu_room_consumer_eiji.apk" }).Length / 1MB) }
}
}
} | ConvertTo-Json -Depth 5
$relJsonPath = Join-Path $PSScriptRoot "web\download\releases.json"
[System.IO.File]::WriteAllText($relJsonPath, $relJson, [System.Text.Encoding]::UTF8)
Write-Host " OK: releases.json updated" -ForegroundColor Green
# Step 5: Vercel redeploy
Write-Host ""
Write-Host "[5/5] Deploying to Vercel..." -ForegroundColor Yellow
$vercelDir = Join-Path $PSScriptRoot "web\download"
Push-Location $vercelDir
try {
$deployOutput = vercel --prod --yes 2>&1 | Out-String
$deployOutput -split "`n" | Where-Object { $_ -match "Production:|Error" } | Write-Host
# Extract production URL from output
$prodUrl = ($deployOutput -split "`n" | Where-Object { $_ -match "Production: https://" } | Select-Object -First 1) -replace ".*Production: (https://[^\s]+).*", '$1'
if ($prodUrl) {
Write-Host " Setting alias ponshu-room.vercel.app..." -ForegroundColor Gray
vercel alias set $prodUrl ponshu-room.vercel.app 2>&1 | Out-Null
Write-Host " OK: Alias set" -ForegroundColor Green
}
} catch {
Write-Host " Warning: Vercel deploy or alias failed" -ForegroundColor Yellow
} finally {
Pop-Location
}
Write-Host ""
Write-Host "================================================" -ForegroundColor Green
Write-Host " All done!" -ForegroundColor Green
Write-Host "================================================" -ForegroundColor Green
Write-Host " Gitea : $GITEA_BASE_URL/$GITEA_OWNER/$GITEA_REPO/releases/tag/$tagName"
Write-Host " Download : https://ponshu-room.vercel.app"
Write-Host ""

View File

@ -0,0 +1,45 @@
# deploy_android.ps1
# Firebase App Distribution 用のビルド・配布スクリプト (Ponshu Room Lite)
#
# 前提:
# npm install -g firebase-tools
# firebase login
#
# 引数:
# -ReleaseNotes "アップデート内容" (省略時は "Minor update")
param (
[string]$ReleaseNotes = "Minor update"
)
$ErrorActionPreference = "Stop"
Write-Host "🍶 Ponshu Room Lite: Android 商用ビルド・配布を開始します" -ForegroundColor Cyan
# 1. ビルド実行 (APIキーはダミー、VPSへ接続)
Write-Host " -> ビルディング APK..." -ForegroundColor Yellow
flutter build apk --release `
--dart-define=GEMINI_API_KEY=dist-build-key `
--dart-define=AI_PROXY_URL=https://(ここにVPSのドメインを入力) `
--dart-define=USE_PROXY=true `
--obfuscate `
--split-debug-info=build\debug-info
$ApkPath = "build\app\outputs\flutter-apk\app-release.apk"
if (-Not (Test-Path $ApkPath)) {
Write-Host "❌ エラー: APKが生成されていません ($ApkPath)" -ForegroundColor Red
exit 1
}
# 2. Firebase App Distributionへアップロード
# 注意: 初回実行前に Firebase Console で App Distribution を有効にし、アプリIDを調べて以下に入れる
$FirebaseAppId = "1:XXXXXXXXXXXX:android:XXXXXXXXXXXX" # <--- TODO: 書き換える
Write-Host " -> Firebaseへアップロード中..." -ForegroundColor Yellow
firebase appdistribution:distribute $ApkPath `
--app $FirebaseAppId `
--groups "beta-testers" `
--release-notes $ReleaseNotes
Write-Host "✅ 配布完了!テスターに通知メールが送信されました。" -ForegroundColor Green

View File

@ -1,13 +1,25 @@
# Gemini API Key
# ============================================================
# Ponshu Room Proxy Server — 環境変数設定例
# ============================================================
# [必須] Gemini API Key
GEMINI_API_KEY=your_gemini_api_key_here
# Proxy Authentication Token (recommended for security)
# Generate a random string: openssl rand -hex 32
# [必須] Proxy認証トークン (生成: openssl rand -hex 32)
PROXY_AUTH_TOKEN=your_secure_random_token_here
# Daily request limit per device
# [任意] 1デバイスあたりの日次リクエスト上限
DAILY_LIMIT=50
# Redis connection settings (default values for Docker Compose)
# Redis接続設定 (Docker Compose デフォルト値)
REDIS_HOST=redis
REDIS_PORT=6379
# サポート連絡先 (ライセンス有効化画面で表示)
APP_SUPPORT_EMAIL=support@posimai.soar-enrich.com
# ============================================================
# 注意: Stripe/Resendの処理はメインサーバー (server.js) で行います
# Stripe Webhook → server.js → ライセンスキー生成 → Resendでメール送信
# このプロキシサーバーはライセンス検証 (/license/validate) のみ担当
# ============================================================

View File

@ -2,6 +2,7 @@ const express = require('express');
const bodyParser = require('body-parser');
const { GoogleGenerativeAI } = require('@google/generative-ai');
const { createClient } = require('redis');
const crypto = require('crypto');
require('dotenv').config();
const app = express();
@ -14,7 +15,9 @@ const DAILY_LIMIT = parseInt(process.env.DAILY_LIMIT || '50', 10);
const REDIS_HOST = process.env.REDIS_HOST || 'localhost';
const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10);
// Redis Client Setup
const APP_SUPPORT_EMAIL = process.env.APP_SUPPORT_EMAIL || 'support@posimai.soar-enrich.com';
// ========== Redis Client Setup ==========
const redisClient = createClient({
socket: {
host: REDIS_HOST,
@ -30,22 +33,30 @@ redisClient.on('connect', () => {
console.log(`[Redis] Connected to ${REDIS_HOST}:${REDIS_PORT}`);
});
// Initialize Redis connection
(async () => {
try {
await redisClient.connect();
} catch (err) {
console.error('[Redis] Failed to connect:', err);
process.exit(1); // Exit if Redis is unavailable
process.exit(1);
}
})();
// Authentication Middleware (skip for /health)
// ========== Gemini Client ==========
const genAI = new GoogleGenerativeAI(API_KEY);
const model = genAI.getGenerativeModel({
model: "gemini-2.5-flash",
generationConfig: {
responseMimeType: "application/json",
temperature: 0.2,
}
});
// ========== Authentication Middleware ==========
function authMiddleware(req, res, next) {
if (!AUTH_TOKEN) {
// If no token configured, skip auth (backward compatibility)
console.warn('[Auth] WARNING: PROXY_AUTH_TOKEN is not set. Authentication disabled.');
return next();
console.error('[Auth] FATAL: PROXY_AUTH_TOKEN is not set.');
return res.status(503).json({ success: false, error: 'Server misconfigured: authentication token not set' });
}
const authHeader = req.headers['authorization'];
@ -54,7 +65,7 @@ function authMiddleware(req, res, next) {
return res.status(401).json({ success: false, error: 'Authentication required' });
}
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
const token = authHeader.substring(7);
if (token !== AUTH_TOKEN) {
console.log(`[Auth] Rejected: Invalid token`);
return res.status(403).json({ success: false, error: 'Invalid authentication token' });
@ -63,84 +74,55 @@ function authMiddleware(req, res, next) {
next();
}
// Global middleware: Body parser first, then auth (skip /health)
// ========== Global Middleware (JSON body parser + auth) ==========
app.use(bodyParser.json({ limit: '10mb' }));
app.use((req, res, next) => {
if (req.path === '/health') return next();
const publicPaths = ['/health'];
if (publicPaths.includes(req.path)) return next();
authMiddleware(req, res, next);
});
// Gemini Client with JSON response configuration
const genAI = new GoogleGenerativeAI(API_KEY);
const model = genAI.getGenerativeModel({
model: "gemini-2.5-flash", // Flutter側(gemini_service.dart)と統一
generationConfig: {
responseMimeType: "application/json", // Force JSON-only output
temperature: 0.2, // チャート一貫性向上のためFlutter側と統一
}
});
// ========== Helper Functions ==========
// Helper: Get Today's Date String (YYYY-MM-DD)
function getTodayString() {
return new Date().toISOString().split('T')[0];
}
// Helper: Check & Update Rate Limit (Redis-based)
async function checkRateLimit(deviceId) {
const today = getTodayString();
const today = getTodayString();
const redisKey = `usage:${deviceId}:${today}`;
try {
// Get current usage count
const currentCount = await redisClient.get(redisKey);
const count = currentCount ? parseInt(currentCount, 10) : 0;
const remaining = DAILY_LIMIT - count;
const count = currentCount ? parseInt(currentCount, 10) : 0;
const remaining = DAILY_LIMIT - count;
return {
allowed: remaining > 0,
current: count,
limit: DAILY_LIMIT,
remaining: remaining,
redisKey: redisKey
};
return { allowed: remaining > 0, current: count, limit: DAILY_LIMIT, remaining, redisKey };
} catch (err) {
console.error('[Redis] Error checking rate limit:', err);
// Fallback: deny request if Redis is down
return {
allowed: false,
current: 0,
limit: DAILY_LIMIT,
remaining: 0,
error: 'Rate limit check failed'
};
return { allowed: false, current: 0, limit: DAILY_LIMIT, remaining: 0, error: 'Rate limit check failed' };
}
}
// Helper: Increment Usage Count (Redis-based)
async function incrementUsage(deviceId) {
const today = getTodayString();
const today = getTodayString();
const redisKey = `usage:${deviceId}:${today}`;
try {
// Increment count
const newCount = await redisClient.incr(redisKey);
const newCount = await redisClient.incr(redisKey);
// Set expiration to end of day (86400 seconds = 24 hours)
const now = new Date();
const midnight = new Date(now);
midnight.setHours(24, 0, 0, 0);
const secondsUntilMidnight = Math.floor((midnight - now) / 1000);
const now = new Date();
const midnight = new Date(now);
midnight.setHours(24, 0, 0, 0);
const secondsUntilMidnight = Math.floor((midnight - now) / 1000);
await redisClient.expire(redisKey, secondsUntilMidnight);
await redisClient.expire(redisKey, secondsUntilMidnight);
return newCount;
} catch (err) {
console.error('[Redis] Error incrementing usage:', err);
throw err;
}
return newCount;
}
// API Endpoint (authentication enforced by global middleware)
// ========== API Endpoints ==========
// 既存: AI解析 (認証必須)
app.post('/analyze', async (req, res) => {
const { device_id, images, prompt } = req.body;
@ -149,7 +131,6 @@ app.post('/analyze', async (req, res) => {
}
try {
// 1. Check Rate Limit (Redis-based)
const limitStatus = await checkRateLimit(device_id);
if (!limitStatus.allowed) {
console.log(`[Limit Reached] Device: ${device_id} (${limitStatus.current}/${limitStatus.limit})`);
@ -160,33 +141,24 @@ app.post('/analyze', async (req, res) => {
});
}
console.log(`[Request] Device: ${device_id} | Images: ${images ? images.length : 0} | Current: ${limitStatus.current}/${limitStatus.limit}`);
console.log(`[Request] Device: ${device_id} | Images: ${images ? images.length : 0} | Count: ${limitStatus.current}/${limitStatus.limit}`);
// 2. Prepare Gemini Request
// Base64 images to GenerativeContentBlob
const imageParts = (images || []).map(base64 => ({
inlineData: {
data: base64,
mimeType: "image/jpeg"
}
inlineData: { data: base64, mimeType: "image/jpeg" }
}));
const result = await model.generateContent([prompt, ...imageParts]);
const result = await model.generateContent([prompt, ...imageParts]);
const response = await result.response;
const text = response.text();
const text = response.text();
// 3. Parse JSON from Markdown (e.g. ```json ... ```)
console.log(`[Debug] Gemini raw response (first 200 chars): ${text.substring(0, 200)}`);
const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/) || text.match(/```([\s\S]*?)```/);
let jsonData;
if (jsonMatch) {
console.log('[Debug] Found JSON in code block');
jsonData = JSON.parse(jsonMatch[1]);
} else {
// Try parsing raw text if no code blocks
console.log('[Debug] Attempting to parse raw text as JSON');
try {
jsonData = JSON.parse(text);
} catch (parseError) {
@ -195,38 +167,39 @@ app.post('/analyze', async (req, res) => {
}
}
// 4. Increment Usage (Redis-based)
const newCount = await incrementUsage(device_id);
console.log(`[Success] Device: ${device_id} | New Count: ${newCount}/${DAILY_LIMIT}`);
// 5. Send Response
res.json({
success: true,
data: jsonData,
usage: {
today: newCount,
limit: DAILY_LIMIT
}
usage: { today: newCount, limit: DAILY_LIMIT }
});
} catch (error) {
console.error('[Error] Gemini API or Redis Error:', error);
res.status(500).json({
success: false,
error: error.message || 'Internal Server Error'
});
res.status(500).json({ success: false, error: error.message || 'Internal Server Error' });
}
});
// Health Check
// ヘルスチェック
app.get('/health', (req, res) => {
res.send('OK');
});
// Start Server
// ========== Server Start ==========
if (!AUTH_TOKEN) {
console.error('[FATAL] PROXY_AUTH_TOKEN is not set. Refusing to start.');
process.exit(1);
}
if (!API_KEY) {
console.error('[FATAL] GEMINI_API_KEY is not set. Refusing to start.');
process.exit(1);
}
app.listen(PORT, '0.0.0.0', () => {
console.log(`Proxy Server running on port ${PORT}`);
if (!API_KEY) console.error('WARNING: GEMINI_API_KEY is not set!');
if (!AUTH_TOKEN) console.error('WARNING: PROXY_AUTH_TOKEN is not set! Authentication is disabled.');
else console.log('Authentication: Bearer Token enabled');
console.log(`[Server] Ponshu Room Proxy running on port ${PORT}`);
console.log(`[Server] Auth: Bearer Token enabled`);
console.log(`[Server] Daily Limit: ${DAILY_LIMIT} requests per device`);
console.log(`[Server] License validation: enabled`);
});

View File

@ -1,21 +1,21 @@
{
"date": "2026-04-06",
"name": "Ponshu Room 1.0.25 (2026-04-10)",
"version": "v1.0.25",
{
"date": "2026-04-11",
"name": "Ponshu Room 1.0.28 (2026-04-11)",
"version": "v1.0.28",
"apks": {
"eiji": {
"lite": {
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.25/ponshu_room_consumer_eiji.apk",
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.28/ponshu_room_consumer_eiji.apk",
"size_mb": 89,
"filename": "ponshu_room_consumer_eiji.apk"
}
},
"maita": {
"lite": {
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.25/ponshu_room_consumer_maita.apk",
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.28/ponshu_room_consumer_maita.apk",
"size_mb": 89,
"filename": "ponshu_room_consumer_maita.apk"
}
}
}
}
}

View File

@ -57,17 +57,17 @@ a {
.container {
max-width: 480px;
margin: 0 auto;
padding: var(--space-lg) var(--space-md);
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-xl);
gap: var(--space-md);
min-height: 100vh;
}
/* ===== Hero ===== */
.hero {
text-align: center;
padding-top: var(--space-xl);
padding-top: var(--space-sm);
}
.logo {
@ -78,9 +78,9 @@ a {
}
.sake-icon {
width: 48px;
height: 48px;
color: var(--kohaku-gold);
font-size: 48px;
line-height: 1;
display: block;
}
.logo h1 {
@ -97,55 +97,6 @@ a {
letter-spacing: 0.1em;
}
/* ===== Preview ===== */
.preview {
display: flex;
justify-content: center;
}
.phone-frame {
width: 200px;
height: 400px;
background: var(--sumi-black);
border-radius: 32px;
padding: 12px;
box-shadow:
0 25px 50px -12px rgba(74, 59, 50, 0.25),
inset 0 0 0 2px rgba(255, 255, 255, 0.1);
}
.screen-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--gray-100) 0%, var(--washi-white) 100%);
border-radius: 24px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.grid-preview {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
padding: 16px;
width: 100%;
}
.grid-item {
aspect-ratio: 1;
background: linear-gradient(145deg, var(--kohaku-gold), var(--kohaku-deep));
border-radius: 8px;
opacity: 0.6;
}
.grid-item:nth-child(2) { opacity: 0.4; }
.grid-item:nth-child(3) { opacity: 0.8; }
.grid-item:nth-child(4) { opacity: 0.5; }
.grid-item:nth-child(5) { opacity: 0.7; }
.grid-item:nth-child(6) { opacity: 0.3; }
/* ===== Download ===== */
.download {
display: flex;
@ -258,18 +209,6 @@ a {
letter-spacing: 0.1em;
}
/* ===== Footer ===== */
footer {
margin-top: auto;
text-align: center;
padding: var(--space-lg) 0;
}
footer p {
font-size: 0.75rem;
color: var(--gray-400);
}
/* ===== Dark Mode ===== */
@media (prefers-color-scheme: dark) {
:root {
@ -281,14 +220,6 @@ footer p {
--gray-600: #9E9A94;
}
.phone-frame {
background: #2A2A2A;
}
.screen-placeholder {
background: linear-gradient(135deg, #1E1E1E 0%, #2A2A2A 100%);
}
.version-card {
background: #1E1E1E;
border-color: #2A2A2A;
@ -312,11 +243,6 @@ footer p {
/* ===== Responsive ===== */
@media (min-width: 768px) {
.container {
padding: var(--space-xl);
}
.phone-frame {
width: 240px;
height: 480px;
padding: var(--space-lg);
}
}