Compare commits
12 Commits
2b90756417
...
f8ceb0bf02
| Author | SHA1 | Date |
|---|---|---|
|
|
f8ceb0bf02 | |
|
|
ba5660c1cb | |
|
|
402c6b6448 | |
|
|
675e67e3c1 | |
|
|
c7168e831c | |
|
|
d23ee8ed77 | |
|
|
f3d6a92799 | |
|
|
f229ff6b4b | |
|
|
7b3249791e | |
|
|
659e81628a | |
|
|
5311241fe5 | |
|
|
d47bb201ac |
|
|
@ -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 }}
|
||||||
|
|
@ -7,15 +7,11 @@ import 'models/user_profile.dart';
|
||||||
import 'models/menu_settings.dart';
|
import 'models/menu_settings.dart';
|
||||||
import 'providers/theme_provider.dart';
|
import 'providers/theme_provider.dart';
|
||||||
import 'screens/main_screen.dart';
|
import 'screens/main_screen.dart';
|
||||||
|
import 'screens/license_screen.dart';
|
||||||
import 'services/migration_service.dart';
|
import 'services/migration_service.dart';
|
||||||
|
|
||||||
/// Pro版かLite版かを判定するビルド時フラグ
|
/// ビルド時Pro解放フラグ(現在未使用 — 実行時ライセンスはisProProviderで管理)
|
||||||
///
|
/// 将来的に削除予定。isProProvider (license_provider.dart) を使うこと。
|
||||||
/// ビルドコマンド:
|
|
||||||
/// - Pro版: flutter build apk --release --dart-define=IS_PRO_VERSION=true
|
|
||||||
/// - Lite版: flutter build apk --release --dart-define=IS_PRO_VERSION=false
|
|
||||||
///
|
|
||||||
/// デフォルトはfalse(Lite版) ※ponshu_room_liteディレクトリのため
|
|
||||||
const bool isProVersion = bool.fromEnvironment('IS_PRO_VERSION', defaultValue: false);
|
const bool isProVersion = bool.fromEnvironment('IS_PRO_VERSION', defaultValue: false);
|
||||||
|
|
||||||
/// 店舗向けビルドかどうかを判定するビルド時フラグ
|
/// 店舗向けビルドかどうかを判定するビルド時フラグ
|
||||||
|
|
@ -111,6 +107,9 @@ class MyApp extends ConsumerWidget {
|
||||||
|
|
||||||
navigatorObservers: [routeObserver],
|
navigatorObservers: [routeObserver],
|
||||||
home: const MainScreen(),
|
home: const MainScreen(),
|
||||||
|
routes: {
|
||||||
|
'/upgrade': (context) => const LicenseScreen(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -135,9 +135,7 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
|
||||||
_buildMBTIDiagnosisSection(context, userProfile, sakeList),
|
_buildMBTIDiagnosisSection(context, userProfile, sakeList),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// さけのわ おすすめ
|
// さけのわ おすすめ(各ウィジェットが独自ヘッダーを持つため親ヘッダーは不要)
|
||||||
_buildSectionHeader(context, 'さけのわ おすすめ', LucideIcons.trendingUp),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const SakenowaNewRecommendationSection(displayCount: 5),
|
const SakenowaNewRecommendationSection(displayCount: 5),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const SakenowaRankingSection(displayCount: 10),
|
const SakenowaRankingSection(displayCount: 10),
|
||||||
|
|
@ -230,7 +228,7 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
|
||||||
left: 20,
|
left: 20,
|
||||||
top: 20,
|
top: 20,
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
opacity: 0.3, // Subtle
|
opacity: 0.3,
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'🍶',
|
'🍶',
|
||||||
style: TextStyle(fontSize: 40),
|
style: TextStyle(fontSize: 40),
|
||||||
|
|
@ -239,7 +237,7 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
|
||||||
),
|
),
|
||||||
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 12.0), // Optimized for チラ見せ effect
|
padding: const EdgeInsets.all(24.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// 1. Header (Name & Rank) with unified help icon
|
// 1. Header (Name & Rank) with unified help icon
|
||||||
|
|
@ -353,11 +351,32 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
|
||||||
// --- MBTI Diagnosis Logic ---
|
// --- MBTI Diagnosis Logic ---
|
||||||
Widget _buildMBTIDiagnosisSection(BuildContext context, UserProfile userProfile, List<SakeItem> sakeList) {
|
Widget _buildMBTIDiagnosisSection(BuildContext context, UserProfile userProfile, List<SakeItem> sakeList) {
|
||||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
return Card(
|
return Container(
|
||||||
elevation: 2,
|
width: double.infinity,
|
||||||
color: appColors.surfaceSubtle,
|
decoration: BoxDecoration(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
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(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.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/theme_provider.dart'; // Access userProfileProvider
|
||||||
import '../providers/navigation_provider.dart'; // Track current tab index
|
import '../providers/navigation_provider.dart'; // Track current tab index
|
||||||
import '../utils/translations.dart'; // Translation helper
|
import '../utils/translations.dart'; // Translation helper
|
||||||
|
|
@ -100,6 +101,13 @@ class _MainScreenState extends ConsumerState<MainScreen> {
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
child: const Text('閉じる'),
|
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 userProfile = ref.watch(userProfileProvider);
|
||||||
|
final isPro = ref.watch(isProProvider);
|
||||||
// isBusinessApp=false(消費者向けビルド)では店舗モードを完全に無効化
|
// isBusinessApp=false(消費者向けビルド)では店舗モードを完全に無効化
|
||||||
final isBusiness = isBusinessApp && userProfile.isBusinessMode;
|
final isBusiness = isBusinessApp && userProfile.isBusinessMode;
|
||||||
final t = Translations(userProfile.locale); // Translation helper
|
final t = Translations(userProfile.locale); // Translation helper
|
||||||
|
|
||||||
// Define Screens for each mode
|
// Define Screens for each mode
|
||||||
// Lite版のPro限定タブは表示されないようにダミー画面を配置
|
// Pro版でないタブはダミー画面を配置(タップ時にダイアログで対応)
|
||||||
// (タップ時にダイアログで対応するため、画面遷移は発生しない)
|
|
||||||
final List<Widget> screens = isBusiness
|
final List<Widget> screens = isBusiness
|
||||||
? [
|
? [
|
||||||
const HomeScreen(), // Inventory Management
|
const HomeScreen(), // Inventory Management
|
||||||
isProVersion ? const InstaSupportScreen() : const HomeScreen(), // Instagram Support (Pro only)
|
isPro ? const InstaSupportScreen() : const HomeScreen(), // Instagram Support (Pro only)
|
||||||
isProVersion ? const AnalyticsScreen() : const HomeScreen(), // Analytics (Pro only)
|
isPro ? const AnalyticsScreen() : const HomeScreen(), // Analytics (Pro only)
|
||||||
const ShopSettingsScreen(), // Shop Settings
|
const ShopSettingsScreen(), // Shop Settings
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
const HomeScreen(), // My Sake List
|
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 SommelierScreen(), // Sommelier
|
||||||
const BreweryMapScreen(), // Map
|
const BreweryMapScreen(), // Map
|
||||||
const SoulScreen(), // MyPage/Settings
|
const SoulScreen(), // MyPage/Settings
|
||||||
];
|
];
|
||||||
|
|
||||||
// Define Navigation Items (with translation)
|
// Define Navigation Items (with translation)
|
||||||
// Lite版では王冠バッジを表示
|
// Pro版でない場合は王冠バッジを表示
|
||||||
final List<NavigationDestination> destinations = isBusiness
|
final List<NavigationDestination> destinations = isBusiness
|
||||||
? [
|
? [
|
||||||
NavigationDestination(
|
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'],
|
label: t['home'],
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
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'],
|
label: t['promo'],
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
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'],
|
label: t['analytics'],
|
||||||
),
|
),
|
||||||
NavigationDestination(icon: const Icon(LucideIcons.store), label: t['shop']),
|
NavigationDestination(icon: const Icon(LucideIcons.store), label: t['shop']),
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
NavigationDestination(
|
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'],
|
label: t['home'],
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
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'],
|
label: t['scan'],
|
||||||
),
|
),
|
||||||
NavigationDestination(icon: const Icon(LucideIcons.sparkles), label: t['sommelier']),
|
NavigationDestination(icon: const Icon(LucideIcons.sparkles), label: t['sommelier']),
|
||||||
|
|
@ -194,8 +202,8 @@ class _MainScreenState extends ConsumerState<MainScreen> {
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: _currentIndex,
|
selectedIndex: _currentIndex,
|
||||||
onDestinationSelected: (index) {
|
onDestinationSelected: (index) {
|
||||||
// Lite版でPro限定タブをタップした場合はダイアログを表示
|
// Pro版でない場合にPro限定タブをタップしたらダイアログを表示
|
||||||
if (!isProVersion) {
|
if (!isPro) {
|
||||||
if (isBusiness) {
|
if (isBusiness) {
|
||||||
// ビジネスモード: Instagram (index 1) と Analytics (index 2) がPro限定
|
// ビジネスモード: Instagram (index 1) と Analytics (index 2) がPro限定
|
||||||
if (index == 1) {
|
if (index == 1) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import '../../../models/sake_item.dart';
|
||||||
import '../../../providers/theme_provider.dart';
|
import '../../../providers/theme_provider.dart';
|
||||||
import '../../../services/mbti_compatibility_service.dart';
|
import '../../../services/mbti_compatibility_service.dart';
|
||||||
import '../../../theme/app_colors.dart';
|
import '../../../theme/app_colors.dart';
|
||||||
import '../../../main.dart'; // For isProVersion
|
import '../../../providers/license_provider.dart';
|
||||||
|
|
||||||
/// MBTI酒向スタンプセクション
|
/// MBTI酒向スタンプセクション
|
||||||
///
|
///
|
||||||
|
|
@ -22,13 +22,14 @@ class SakeMbtiStampSection extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||||
|
final isPro = ref.watch(isProProvider);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isProVersion
|
color: isPro
|
||||||
? appColors.brandPrimary.withValues(alpha: 0.3)
|
? appColors.brandPrimary.withValues(alpha: 0.3)
|
||||||
: appColors.divider,
|
: appColors.divider,
|
||||||
style: BorderStyle.solid,
|
style: BorderStyle.solid,
|
||||||
|
|
@ -37,7 +38,7 @@ class SakeMbtiStampSection extends ConsumerWidget {
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: Theme.of(context).cardColor.withValues(alpha: 0.5),
|
color: Theme.of(context).cardColor.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
child: isProVersion
|
child: isPro
|
||||||
? _buildProContent(context, ref, appColors)
|
? _buildProContent(context, ref, appColors)
|
||||||
: _buildLiteContent(context, appColors),
|
: _buildLiteContent(context, appColors),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import '../widgets/sake_detail/sake_detail_memo.dart';
|
||||||
import '../widgets/sake_detail/sake_detail_specs.dart';
|
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 '../main.dart' show isProVersion;
|
import '../providers/license_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';
|
||||||
|
|
@ -83,6 +83,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||||
|
final isPro = ref.watch(isProProvider);
|
||||||
|
|
||||||
// スマートレコメンド
|
// スマートレコメンド
|
||||||
final allSakeAsync = ref.watch(allSakeItemsProvider);
|
final allSakeAsync = ref.watch(allSakeItemsProvider);
|
||||||
|
|
@ -254,7 +255,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
||||||
],
|
],
|
||||||
|
|
||||||
// MBTI Diagnostic Stamp Section (Pro only)
|
// MBTI Diagnostic Stamp Section (Pro only)
|
||||||
if (isProVersion) ...[
|
if (isPro) ...[
|
||||||
SakeMbtiStampSection(sake: _sake),
|
SakeMbtiStampSection(sake: _sake),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -72,5 +72,11 @@ class Secrets {
|
||||||
return local.SecretsLocal.geminiApiKey;
|
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: '');
|
// static const String driveClientId = String.fromEnvironment('DRIVE_CLIENT_ID', defaultValue: '');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,54 +2,71 @@ import 'dart:convert';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
/// デバイスID取得サービス
|
/// デバイスID取得サービス
|
||||||
/// レート制限のためのデバイス識別に使用
|
/// レート制限・ライセンス管理のためのデバイス識別に使用
|
||||||
|
///
|
||||||
|
/// ## 安定性の保証
|
||||||
|
/// - Android: ANDROID_ID をハッシュ化して使用(Factory Reset まで不変)
|
||||||
|
/// - iOS: SharedPreferences に UUID を永続化(identifierForVendor は
|
||||||
|
/// 全ベンダーアプリ削除後の再インストールで変わるため不使用)
|
||||||
|
/// - その他/エラー時: SharedPreferences に UUID を永続化
|
||||||
class DeviceService {
|
class DeviceService {
|
||||||
static final DeviceInfoPlugin _deviceInfo = DeviceInfoPlugin();
|
static final DeviceInfoPlugin _deviceInfo = DeviceInfoPlugin();
|
||||||
static String? _cachedDeviceId;
|
static String? _cachedDeviceId;
|
||||||
|
|
||||||
|
static const _prefKey = 'ponshu_device_id';
|
||||||
|
|
||||||
/// デバイス固有のIDを取得(SHA256ハッシュ化)
|
/// デバイス固有のIDを取得(SHA256ハッシュ化)
|
||||||
static Future<String> getDeviceId() async {
|
static Future<String> getDeviceId() async {
|
||||||
// キャッシュがあれば返す
|
if (_cachedDeviceId != null) return _cachedDeviceId!;
|
||||||
if (_cachedDeviceId != null) {
|
|
||||||
|
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!;
|
return _cachedDeviceId!;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// 初回: ハードウェアIDから生成してSharedPreferencesに保存
|
||||||
|
final id = await _generateAndPersist(prefs);
|
||||||
|
_cachedDeviceId = id;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String> _generateAndPersist(SharedPreferences prefs) async {
|
||||||
String deviceIdentifier;
|
String deviceIdentifier;
|
||||||
|
|
||||||
|
try {
|
||||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||||
final androidInfo = await _deviceInfo.androidInfo;
|
final info = await _deviceInfo.androidInfo;
|
||||||
// Android IDを使用(アプリ再インストールでも同じIDを維持)
|
// ANDROID_ID: アプリ再インストールでは変わらない。Factory Resetで変わる。
|
||||||
deviceIdentifier = androidInfo.id;
|
deviceIdentifier = 'android-${info.id}';
|
||||||
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
|
|
||||||
final iosInfo = await _deviceInfo.iosInfo;
|
|
||||||
// identifierForVendor(アプリ再インストールで変わる可能性あり)
|
|
||||||
deviceIdentifier = iosInfo.identifierForVendor ?? 'unknown-ios';
|
|
||||||
} else {
|
} else {
|
||||||
// その他のプラットフォーム
|
// iOS / その他: UUIDを生成して永続化
|
||||||
deviceIdentifier = 'unknown-platform';
|
// 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) {
|
} catch (e) {
|
||||||
debugPrint('Error getting device ID: $e');
|
debugPrint('[Device] Failed to get hardware ID, using UUID fallback: $e');
|
||||||
// エラー時はランダムなIDを生成(セッション中は同じIDを使用)
|
deviceIdentifier = 'fallback-${const Uuid().v4()}';
|
||||||
_cachedDeviceId = sha256.convert(utf8.encode('fallback-${DateTime.now().millisecondsSinceEpoch}')).toString();
|
|
||||||
return _cachedDeviceId!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
_cachedDeviceId = null;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_prefKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,10 @@ class GeminiService {
|
||||||
"manufacturingYearMonth": "2023.10"
|
"manufacturingYearMonth": "2023.10"
|
||||||
}
|
}
|
||||||
|
|
||||||
値が不明な場合は null または 適切な推測値を入れてください。特にtasteStatsは必ず1-5の数値で埋めてください。
|
★重要な指示:
|
||||||
|
- "name"(銘柄名)と "brand"(蔵元名)は、ラベルに明記されている文字を**そのまま**使用してください。知識から補完・推測・変更しないでください。例:ラベルに「東魁」とあれば「東魁盛」に変えない。
|
||||||
|
- tasteStatsは必ず1-5の数値で埋めてください。
|
||||||
|
- その他の値が不明な場合は null または 適切な推測値を入れてください。
|
||||||
''';
|
''';
|
||||||
|
|
||||||
return _callProxyApi(
|
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
★重要な指示:
|
|
||||||
- tasteStats(香り、甘味、酸味、苦味、ボディ)は必ず1-5の整数で埋めてください。不明な場合は3を設定してください。
|
|
||||||
- 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コール
|
/// 共通実装: ProxyへのAPIコール
|
||||||
Future<SakeAnalysisResult> _callProxyApi({
|
Future<SakeAnalysisResult> _callProxyApi({
|
||||||
required List<String> imagePaths,
|
required List<String> imagePaths,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,6 +54,15 @@ class _OtherSettingsSectionState extends ConsumerState<OtherSettingsSection> {
|
||||||
color: appColors.surfaceSubtle,
|
color: appColors.surfaceSubtle,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
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) ...[
|
if (widget.showBusinessMode) ...[
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(LucideIcons.store, color: appColors.warning),
|
leading: Icon(LucideIcons.store, color: appColors.warning),
|
||||||
|
|
|
||||||
|
|
@ -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.21+32
|
version: 1.0.28+35
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.1
|
sdk: ^3.10.1
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1,13 +1,25 @@
|
||||||
# Gemini API Key
|
# ============================================================
|
||||||
|
# Ponshu Room Proxy Server — 環境変数設定例
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# [必須] Gemini API Key
|
||||||
GEMINI_API_KEY=your_gemini_api_key_here
|
GEMINI_API_KEY=your_gemini_api_key_here
|
||||||
|
|
||||||
# Proxy Authentication Token (recommended for security)
|
# [必須] Proxy認証トークン (生成: openssl rand -hex 32)
|
||||||
# Generate a random string: openssl rand -hex 32
|
|
||||||
PROXY_AUTH_TOKEN=your_secure_random_token_here
|
PROXY_AUTH_TOKEN=your_secure_random_token_here
|
||||||
|
|
||||||
# Daily request limit per device
|
# [任意] 1デバイスあたりの日次リクエスト上限
|
||||||
DAILY_LIMIT=50
|
DAILY_LIMIT=50
|
||||||
|
|
||||||
# Redis connection settings (default values for Docker Compose)
|
# Redis接続設定 (Docker Compose デフォルト値)
|
||||||
REDIS_HOST=redis
|
REDIS_HOST=redis
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# サポート連絡先 (ライセンス有効化画面で表示)
|
||||||
|
APP_SUPPORT_EMAIL=support@posimai.soar-enrich.com
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 注意: Stripe/Resendの処理はメインサーバー (server.js) で行います
|
||||||
|
# Stripe Webhook → server.js → ライセンスキー生成 → Resendでメール送信
|
||||||
|
# このプロキシサーバーはライセンス検証 (/license/validate) のみ担当
|
||||||
|
# ============================================================
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ const express = require('express');
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
const { GoogleGenerativeAI } = require('@google/generative-ai');
|
const { GoogleGenerativeAI } = require('@google/generative-ai');
|
||||||
const { createClient } = require('redis');
|
const { createClient } = require('redis');
|
||||||
|
const crypto = require('crypto');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const app = express();
|
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_HOST = process.env.REDIS_HOST || 'localhost';
|
||||||
const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10);
|
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({
|
const redisClient = createClient({
|
||||||
socket: {
|
socket: {
|
||||||
host: REDIS_HOST,
|
host: REDIS_HOST,
|
||||||
|
|
@ -30,22 +33,30 @@ redisClient.on('connect', () => {
|
||||||
console.log(`[Redis] Connected to ${REDIS_HOST}:${REDIS_PORT}`);
|
console.log(`[Redis] Connected to ${REDIS_HOST}:${REDIS_PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize Redis connection
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await redisClient.connect();
|
await redisClient.connect();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Redis] Failed to connect:', 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) {
|
function authMiddleware(req, res, next) {
|
||||||
if (!AUTH_TOKEN) {
|
if (!AUTH_TOKEN) {
|
||||||
// If no token configured, skip auth (backward compatibility)
|
console.error('[Auth] FATAL: PROXY_AUTH_TOKEN is not set.');
|
||||||
console.warn('[Auth] WARNING: PROXY_AUTH_TOKEN is not set. Authentication disabled.');
|
return res.status(503).json({ success: false, error: 'Server misconfigured: authentication token not set' });
|
||||||
return next();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers['authorization'];
|
||||||
|
|
@ -54,7 +65,7 @@ function authMiddleware(req, res, next) {
|
||||||
return res.status(401).json({ success: false, error: 'Authentication required' });
|
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) {
|
if (token !== AUTH_TOKEN) {
|
||||||
console.log(`[Auth] Rejected: Invalid token`);
|
console.log(`[Auth] Rejected: Invalid token`);
|
||||||
return res.status(403).json({ success: false, error: 'Invalid authentication token' });
|
return res.status(403).json({ success: false, error: 'Invalid authentication token' });
|
||||||
|
|
@ -63,84 +74,55 @@ function authMiddleware(req, res, next) {
|
||||||
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(bodyParser.json({ limit: '10mb' }));
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
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);
|
authMiddleware(req, res, next);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Gemini Client with JSON response configuration
|
// ========== Helper Functions ==========
|
||||||
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: Get Today's Date String (YYYY-MM-DD)
|
|
||||||
function getTodayString() {
|
function getTodayString() {
|
||||||
return new Date().toISOString().split('T')[0];
|
return new Date().toISOString().split('T')[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Check & Update Rate Limit (Redis-based)
|
|
||||||
async function checkRateLimit(deviceId) {
|
async function checkRateLimit(deviceId) {
|
||||||
const today = getTodayString();
|
const today = getTodayString();
|
||||||
const redisKey = `usage:${deviceId}:${today}`;
|
const redisKey = `usage:${deviceId}:${today}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get current usage count
|
|
||||||
const currentCount = await redisClient.get(redisKey);
|
const currentCount = await redisClient.get(redisKey);
|
||||||
const count = currentCount ? parseInt(currentCount, 10) : 0;
|
const count = currentCount ? parseInt(currentCount, 10) : 0;
|
||||||
const remaining = DAILY_LIMIT - count;
|
const remaining = DAILY_LIMIT - count;
|
||||||
|
|
||||||
return {
|
return { allowed: remaining > 0, current: count, limit: DAILY_LIMIT, remaining, redisKey };
|
||||||
allowed: remaining > 0,
|
|
||||||
current: count,
|
|
||||||
limit: DAILY_LIMIT,
|
|
||||||
remaining: remaining,
|
|
||||||
redisKey: redisKey
|
|
||||||
};
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Redis] Error checking rate limit:', 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) {
|
async function incrementUsage(deviceId) {
|
||||||
const today = getTodayString();
|
const today = getTodayString();
|
||||||
const redisKey = `usage:${deviceId}:${today}`;
|
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 now = new Date();
|
||||||
const midnight = new Date(now);
|
const midnight = new Date(now);
|
||||||
midnight.setHours(24, 0, 0, 0);
|
midnight.setHours(24, 0, 0, 0);
|
||||||
const secondsUntilMidnight = Math.floor((midnight - now) / 1000);
|
const secondsUntilMidnight = Math.floor((midnight - now) / 1000);
|
||||||
|
|
||||||
await redisClient.expire(redisKey, secondsUntilMidnight);
|
await redisClient.expire(redisKey, secondsUntilMidnight);
|
||||||
|
|
||||||
return newCount;
|
return newCount;
|
||||||
} catch (err) {
|
|
||||||
console.error('[Redis] Error incrementing usage:', err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Endpoint (authentication enforced by global middleware)
|
// ========== API Endpoints ==========
|
||||||
|
|
||||||
|
// 既存: AI解析 (認証必須)
|
||||||
app.post('/analyze', async (req, res) => {
|
app.post('/analyze', async (req, res) => {
|
||||||
const { device_id, images, prompt } = req.body;
|
const { device_id, images, prompt } = req.body;
|
||||||
|
|
||||||
|
|
@ -149,7 +131,6 @@ app.post('/analyze', async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Check Rate Limit (Redis-based)
|
|
||||||
const limitStatus = await checkRateLimit(device_id);
|
const limitStatus = await checkRateLimit(device_id);
|
||||||
if (!limitStatus.allowed) {
|
if (!limitStatus.allowed) {
|
||||||
console.log(`[Limit Reached] Device: ${device_id} (${limitStatus.current}/${limitStatus.limit})`);
|
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 => ({
|
const imageParts = (images || []).map(base64 => ({
|
||||||
inlineData: {
|
inlineData: { data: base64, mimeType: "image/jpeg" }
|
||||||
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 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)}`);
|
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]*?)```/);
|
const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/) || text.match(/```([\s\S]*?)```/);
|
||||||
let jsonData;
|
let jsonData;
|
||||||
|
|
||||||
if (jsonMatch) {
|
if (jsonMatch) {
|
||||||
console.log('[Debug] Found JSON in code block');
|
|
||||||
jsonData = JSON.parse(jsonMatch[1]);
|
jsonData = JSON.parse(jsonMatch[1]);
|
||||||
} else {
|
} else {
|
||||||
// Try parsing raw text if no code blocks
|
|
||||||
console.log('[Debug] Attempting to parse raw text as JSON');
|
|
||||||
try {
|
try {
|
||||||
jsonData = JSON.parse(text);
|
jsonData = JSON.parse(text);
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
|
|
@ -195,38 +167,39 @@ app.post('/analyze', async (req, res) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Increment Usage (Redis-based)
|
|
||||||
const newCount = await incrementUsage(device_id);
|
const newCount = await incrementUsage(device_id);
|
||||||
console.log(`[Success] Device: ${device_id} | New Count: ${newCount}/${DAILY_LIMIT}`);
|
console.log(`[Success] Device: ${device_id} | New Count: ${newCount}/${DAILY_LIMIT}`);
|
||||||
|
|
||||||
// 5. Send Response
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: jsonData,
|
data: jsonData,
|
||||||
usage: {
|
usage: { today: newCount, limit: DAILY_LIMIT }
|
||||||
today: newCount,
|
|
||||||
limit: DAILY_LIMIT
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Error] Gemini API or Redis Error:', error);
|
console.error('[Error] Gemini API or Redis Error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({ success: false, error: error.message || 'Internal Server Error' });
|
||||||
success: false,
|
|
||||||
error: error.message || 'Internal Server Error'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Health Check
|
// ヘルスチェック
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.send('OK');
|
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', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Proxy Server running on port ${PORT}`);
|
console.log(`[Server] Ponshu Room Proxy running on port ${PORT}`);
|
||||||
if (!API_KEY) console.error('WARNING: GEMINI_API_KEY is not set!');
|
console.log(`[Server] Auth: Bearer Token enabled`);
|
||||||
if (!AUTH_TOKEN) console.error('WARNING: PROXY_AUTH_TOKEN is not set! Authentication is disabled.');
|
console.log(`[Server] Daily Limit: ${DAILY_LIMIT} requests per device`);
|
||||||
else console.log('Authentication: Bearer Token enabled');
|
console.log(`[Server] License validation: enabled`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
{
|
{
|
||||||
"date": "2026-04-06",
|
"date": "2026-04-11",
|
||||||
"name": "Ponshu Room 1.0.25 (2026-04-10)",
|
"name": "Ponshu Room 1.0.28 (2026-04-11)",
|
||||||
"version": "v1.0.25",
|
"version": "v1.0.28",
|
||||||
"apks": {
|
"apks": {
|
||||||
"eiji": {
|
"eiji": {
|
||||||
"lite": {
|
"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,
|
"size_mb": 89,
|
||||||
"filename": "ponshu_room_consumer_eiji.apk"
|
"filename": "ponshu_room_consumer_eiji.apk"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"maita": {
|
"maita": {
|
||||||
"lite": {
|
"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,
|
"size_mb": 89,
|
||||||
"filename": "ponshu_room_consumer_maita.apk"
|
"filename": "ponshu_room_consumer_maita.apk"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,17 +57,17 @@ a {
|
||||||
.container {
|
.container {
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: var(--space-lg) var(--space-md);
|
padding: var(--space-md);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-xl);
|
gap: var(--space-md);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Hero ===== */
|
/* ===== Hero ===== */
|
||||||
.hero {
|
.hero {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding-top: var(--space-xl);
|
padding-top: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
|
|
@ -78,9 +78,9 @@ a {
|
||||||
}
|
}
|
||||||
|
|
||||||
.sake-icon {
|
.sake-icon {
|
||||||
width: 48px;
|
font-size: 48px;
|
||||||
height: 48px;
|
line-height: 1;
|
||||||
color: var(--kohaku-gold);
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo h1 {
|
.logo h1 {
|
||||||
|
|
@ -97,55 +97,6 @@ a {
|
||||||
letter-spacing: 0.1em;
|
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 ===== */
|
||||||
.download {
|
.download {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -258,18 +209,6 @@ a {
|
||||||
letter-spacing: 0.1em;
|
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 ===== */
|
/* ===== Dark Mode ===== */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
|
|
@ -281,14 +220,6 @@ footer p {
|
||||||
--gray-600: #9E9A94;
|
--gray-600: #9E9A94;
|
||||||
}
|
}
|
||||||
|
|
||||||
.phone-frame {
|
|
||||||
background: #2A2A2A;
|
|
||||||
}
|
|
||||||
|
|
||||||
.screen-placeholder {
|
|
||||||
background: linear-gradient(135deg, #1E1E1E 0%, #2A2A2A 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-card {
|
.version-card {
|
||||||
background: #1E1E1E;
|
background: #1E1E1E;
|
||||||
border-color: #2A2A2A;
|
border-color: #2A2A2A;
|
||||||
|
|
@ -312,11 +243,6 @@ footer p {
|
||||||
/* ===== Responsive ===== */
|
/* ===== Responsive ===== */
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.container {
|
.container {
|
||||||
padding: var(--space-xl);
|
padding: var(--space-lg);
|
||||||
}
|
|
||||||
|
|
||||||
.phone-frame {
|
|
||||||
width: 240px;
|
|
||||||
height: 480px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue