merge: claude/sync-cursor-history-cSlsP — SakeItem setter廃止 + セマンティックカラー置換
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
5d8689b7ee
|
|
@ -157,12 +157,20 @@ class SakeItem extends HiveObject {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow setting for UI updates (呼び出し元で必ず await sakeItem.save() すること)
|
/// displayData/hiddenSpecs をまとめて更新して即座にHiveへ保存する。
|
||||||
|
/// setterを直接使わずこのメソッドを使うこと。
|
||||||
|
Future<void> applyUpdates({
|
||||||
|
DisplayData? displayData,
|
||||||
|
HiddenSpecs? hiddenSpecs,
|
||||||
|
}) async {
|
||||||
|
if (displayData != null) _displayData = displayData;
|
||||||
|
if (hiddenSpecs != null) _hiddenSpecs = hiddenSpecs;
|
||||||
|
await save();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated('Use applyUpdates() instead to ensure save() is always called.')
|
||||||
set displayData(DisplayData val) {
|
set displayData(DisplayData val) {
|
||||||
_displayData = val;
|
_displayData = val;
|
||||||
// save() はここで呼ばない。setter は同期のため await できず、
|
|
||||||
// unawaited save() はデータ消失リスクがある。
|
|
||||||
// 呼び出し元(sakenowa_auto_matching_service.dart)で明示的に await save() する。
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HiddenSpecs get hiddenSpecs {
|
HiddenSpecs get hiddenSpecs {
|
||||||
|
|
@ -176,7 +184,7 @@ class SakeItem extends HiveObject {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow setting for さけのわ auto-matching
|
@Deprecated('Use applyUpdates() instead to ensure save() is always called.')
|
||||||
set hiddenSpecs(HiddenSpecs val) {
|
set hiddenSpecs(HiddenSpecs val) {
|
||||||
_hiddenSpecs = val;
|
_hiddenSpecs = val;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -184,11 +184,11 @@ class HomeScreen extends ConsumerWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(LucideIcons.clipboardCheck, size: 60, color: Colors.grey[400]),
|
Icon(LucideIcons.clipboardCheck, size: 60, color: appColors.iconSubtle),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(t['noMenuItems'], style: const TextStyle(fontWeight: FontWeight.bold)),
|
Text(t['noMenuItems'], style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(t['goBackToList'], textAlign: TextAlign.center, style: const TextStyle(color: Colors.grey)),
|
Text(t['goBackToList'], textAlign: TextAlign.center, style: TextStyle(color: appColors.textSecondary)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ class _MainScreenState extends ConsumerState<MainScreen> {
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(featureIcon, size: 48, color: Colors.grey.shade400),
|
Icon(featureIcon, size: 48, color: appColors.iconSubtle),
|
||||||
Positioned(
|
Positioned(
|
||||||
right: -8,
|
right: -8,
|
||||||
top: -8,
|
top: -8,
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ class _MenuPricingScreenState extends ConsumerState<MenuPricingScreen> {
|
||||||
preferredSize: const Size.fromHeight(2),
|
preferredSize: const Size.fromHeight(2),
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: 2 / 3, // Step 2 of 3 = 66%
|
value: 2 / 3, // Step 2 of 3 = 66%
|
||||||
backgroundColor: Colors.grey[200],
|
backgroundColor: Theme.of(context).extension<AppColors>()!.surfaceSubtle,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
|
valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
|
||||||
minHeight: 2,
|
minHeight: 2,
|
||||||
),
|
),
|
||||||
|
|
@ -320,9 +320,9 @@ class _MenuPricingScreenState extends ConsumerState<MenuPricingScreen> {
|
||||||
// Drag Handle
|
// Drag Handle
|
||||||
ReorderableDragStartListener(
|
ReorderableDragStartListener(
|
||||||
index: index,
|
index: index,
|
||||||
child: const Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.only(right: 12, top: 4, bottom: 4),
|
padding: const EdgeInsets.only(right: 12, top: 4, bottom: 4),
|
||||||
child: Icon(Icons.drag_indicator, color: Colors.grey),
|
child: Icon(Icons.drag_indicator, color: appColors.iconSubtle),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
|
||||||
|
|
@ -363,10 +363,10 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
|
||||||
width: 60,
|
width: 60,
|
||||||
height: 60,
|
height: 60,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: appColors.divider,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: const Icon(LucideIcons.image, color: Colors.grey),
|
child: Icon(LucideIcons.image, color: appColors.iconSubtle),
|
||||||
),
|
),
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'解析待ち',
|
'解析待ち',
|
||||||
|
|
@ -424,7 +424,7 @@ class _PendingAnalysisScreenState extends ConsumerState<PendingAnalysisScreen> {
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: appColors.brandPrimary,
|
backgroundColor: appColors.brandPrimary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
disabledBackgroundColor: Colors.grey.shade400,
|
disabledBackgroundColor: appColors.textTertiary,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -159,12 +159,10 @@ class SakenowaAutoMatchingService {
|
||||||
sakenowaFlavorChart: flavorChartMap,
|
sakenowaFlavorChart: flavorChartMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
// SakeItem更新
|
await sakeItem.applyUpdates(
|
||||||
sakeItem.displayData = updatedDisplayData;
|
displayData: updatedDisplayData,
|
||||||
sakeItem.hiddenSpecs = updatedHiddenSpecs;
|
hiddenSpecs: updatedHiddenSpecs,
|
||||||
|
);
|
||||||
// Hiveに保存
|
|
||||||
await sakeItem.save();
|
|
||||||
|
|
||||||
debugPrint(' [SakenowaAutoMatching] 適用完了!');
|
debugPrint(' [SakenowaAutoMatching] 適用完了!');
|
||||||
debugPrint(' displayName: ${sakeItem.displayData.displayName}');
|
debugPrint(' displayName: ${sakeItem.displayData.displayName}');
|
||||||
|
|
@ -226,10 +224,10 @@ class SakenowaAutoMatchingService {
|
||||||
sakenowaFlavorChart: null,
|
sakenowaFlavorChart: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
sakeItem.displayData = clearedDisplayData;
|
await sakeItem.applyUpdates(
|
||||||
sakeItem.hiddenSpecs = clearedHiddenSpecs;
|
displayData: clearedDisplayData,
|
||||||
|
hiddenSpecs: clearedHiddenSpecs,
|
||||||
await sakeItem.save();
|
);
|
||||||
|
|
||||||
debugPrint(' [SakenowaAutoMatching] クリア完了');
|
debugPrint(' [SakenowaAutoMatching] クリア完了');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ class PrefectureTileMap extends ConsumerWidget {
|
||||||
if (v.contains(prefName)) prefId = k;
|
if (v.contains(prefName)) prefId = k;
|
||||||
});
|
});
|
||||||
final regionId = JapanMapData.getRegionId(prefId);
|
final regionId = JapanMapData.getRegionId(prefId);
|
||||||
final regionColor = regionColors[regionId] ?? Colors.grey;
|
final regionColor = regionColors[regionId] ?? appColors.divider;
|
||||||
|
|
||||||
Color baseColor;
|
Color baseColor;
|
||||||
Color textColor;
|
Color textColor;
|
||||||
|
|
|
||||||
|
|
@ -75,9 +75,9 @@ class _SakeDetailSpecsState extends State<SakeDetailSpecs> {
|
||||||
if (_isEditing) {
|
if (_isEditing) {
|
||||||
// Warn user about external update while editing
|
// Warn user about external update while editing
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('データが外部から更新されました。編集をキャンセルして再度お試しください。'),
|
content: const Text('データが外部から更新されました。編集をキャンセルして再度お試しください。'),
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: Theme.of(context).extension<AppColors>()!.warning,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_cancel(); // Force exit edit mode
|
_cancel(); // Force exit edit mode
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
||||||
|
|
||||||
Future<void> _createBackup() async {
|
Future<void> _createBackup() async {
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||||
setState(() => _state = _BackupState.backingUp);
|
setState(() => _state = _BackupState.backingUp);
|
||||||
final success = await _backupService.createBackup();
|
final success = await _backupService.createBackup();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
@ -93,11 +94,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(success ? 'バックアップが完了しました' : 'バックアップに失敗しました'),
|
content: Text(success ? 'バックアップが完了しました' : 'バックアップに失敗しました'),
|
||||||
// Snackbars can keep Green/Red for semantic clarity, or be neutral.
|
backgroundColor: success ? appColors.success : appColors.error,
|
||||||
// User asked to remove Green/Red icons from the UI, but feedback (Snackbar) usually stays semantic.
|
|
||||||
// However, to be safe and "Washi", let's use Sumi (Black) for success?
|
|
||||||
// Or just leave snackbars as they are ephemeral. The request was likely about the visible static UI.
|
|
||||||
backgroundColor: success ? Colors.green : Colors.red,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -184,7 +181,7 @@ enum _BackupState { idle, signingIn, signingOut, backingUp, restoring }
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(success ? '復元が完了しました' : '復元に失敗しました'),
|
content: Text(success ? '復元が完了しました' : '復元に失敗しました'),
|
||||||
backgroundColor: success ? Colors.green : Colors.red,
|
backgroundColor: success ? appColors.success : appColors.error,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,57 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('SakeItem - ensureMigrated', () {
|
||||||
|
test('レガシーフィールドからdisplayDataを構築する', () {
|
||||||
|
final sake = SakeItem(
|
||||||
|
id: 'legacy-001',
|
||||||
|
legacyName: '出羽桜',
|
||||||
|
legacyBrand: '出羽桜酒造',
|
||||||
|
legacyPrefecture: '山形県',
|
||||||
|
legacyCatchCopy: '花と夢と',
|
||||||
|
legacyImagePaths: ['/path/legacy.jpg'],
|
||||||
|
);
|
||||||
|
|
||||||
|
// displayData未設定の状態でgetterがlegacyから返す
|
||||||
|
expect(sake.displayData.name, '出羽桜');
|
||||||
|
expect(sake.displayData.brewery, '出羽桜酒造');
|
||||||
|
expect(sake.displayData.prefecture, '山形県');
|
||||||
|
|
||||||
|
// ensureMigratedで新構造に昇格
|
||||||
|
final migrated = sake.ensureMigrated();
|
||||||
|
expect(migrated, true); // 移行が実行された
|
||||||
|
|
||||||
|
// 2回目はfalseを返す(既に移行済み)
|
||||||
|
final again = sake.ensureMigrated();
|
||||||
|
expect(again, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('displayDataが既に設定されている場合はfalseを返す', () {
|
||||||
|
final sake = SakeItem(
|
||||||
|
id: 'new-001',
|
||||||
|
displayData: DisplayData(
|
||||||
|
name: '新政',
|
||||||
|
brewery: '新政酒造',
|
||||||
|
prefecture: '秋田県',
|
||||||
|
imagePaths: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final migrated = sake.ensureMigrated();
|
||||||
|
expect(migrated, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacyNameがnullの場合はUnknownにフォールバックする', () {
|
||||||
|
final sake = SakeItem(id: 'legacy-002');
|
||||||
|
|
||||||
|
sake.ensureMigrated();
|
||||||
|
|
||||||
|
expect(sake.displayData.name, 'Unknown');
|
||||||
|
expect(sake.displayData.brewery, 'Unknown');
|
||||||
|
expect(sake.displayData.prefecture, 'Unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('SakeItem - HiddenSpecs / TasteStats', () {
|
group('SakeItem - HiddenSpecs / TasteStats', () {
|
||||||
test('should return SakeTasteStats from tasteStats map', () {
|
test('should return SakeTasteStats from tasteStats map', () {
|
||||||
final sake = SakeItem(
|
final sake = SakeItem(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ponshu_room_lite/services/gemini_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('SakeAnalysisResult.fromJson', () {
|
||||||
|
test('正常なJSONから全フィールドを正しくパースする', () {
|
||||||
|
final json = {
|
||||||
|
'name': '獺祭',
|
||||||
|
'brand': '旭酒造',
|
||||||
|
'prefecture': '山口県',
|
||||||
|
'type': '純米大吟醸',
|
||||||
|
'description': 'フルーティーで華やかな香り',
|
||||||
|
'catchCopy': '磨きその先へ',
|
||||||
|
'confidenceScore': 92,
|
||||||
|
'flavorTags': ['フルーティー', '辛口'],
|
||||||
|
'tasteStats': {'aroma': 5, 'sweetness': 2, 'acidity': 3, 'bitterness': 2, 'body': 3},
|
||||||
|
'alcoholContent': 16.0,
|
||||||
|
'polishingRatio': 23,
|
||||||
|
'sakeMeterValue': 3.0,
|
||||||
|
'riceVariety': '山田錦',
|
||||||
|
'yeast': 'きょうかい9号',
|
||||||
|
'manufacturingYearMonth': '2024.01',
|
||||||
|
};
|
||||||
|
|
||||||
|
final result = SakeAnalysisResult.fromJson(json);
|
||||||
|
|
||||||
|
expect(result.name, '獺祭');
|
||||||
|
expect(result.brand, '旭酒造');
|
||||||
|
expect(result.prefecture, '山口県');
|
||||||
|
expect(result.type, '純米大吟醸');
|
||||||
|
expect(result.confidenceScore, 92);
|
||||||
|
expect(result.flavorTags, ['フルーティー', '辛口']);
|
||||||
|
expect(result.alcoholContent, 16.0);
|
||||||
|
expect(result.polishingRatio, 23);
|
||||||
|
expect(result.riceVariety, '山田錦');
|
||||||
|
expect(result.manufacturingYearMonth, '2024.01');
|
||||||
|
expect(result.isFromCache, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('フィールドが全てnullの場合にデフォルト値が設定される', () {
|
||||||
|
final result = SakeAnalysisResult.fromJson({});
|
||||||
|
|
||||||
|
expect(result.name, isNull);
|
||||||
|
expect(result.brand, isNull);
|
||||||
|
expect(result.prefecture, isNull);
|
||||||
|
expect(result.flavorTags, isEmpty);
|
||||||
|
expect(result.isFromCache, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tasteStats: 範囲外の値(0, 6)が1〜5にクランプされる', () {
|
||||||
|
final json = {
|
||||||
|
'tasteStats': {
|
||||||
|
'aroma': 0,
|
||||||
|
'sweetness': 6,
|
||||||
|
'acidity': -1,
|
||||||
|
'bitterness': 10,
|
||||||
|
'body': 3,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
final result = SakeAnalysisResult.fromJson(json);
|
||||||
|
|
||||||
|
expect(result.tasteStats['aroma'], 1);
|
||||||
|
expect(result.tasteStats['sweetness'], 5);
|
||||||
|
expect(result.tasteStats['acidity'], 1);
|
||||||
|
expect(result.tasteStats['bitterness'], 5);
|
||||||
|
expect(result.tasteStats['body'], 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tasteStats: 一部キーが欠損していると3で補完される', () {
|
||||||
|
final json = {
|
||||||
|
'tasteStats': {
|
||||||
|
'aroma': 5,
|
||||||
|
// sweetness, acidity, bitterness, body が欠損
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
final result = SakeAnalysisResult.fromJson(json);
|
||||||
|
|
||||||
|
expect(result.tasteStats['aroma'], 5);
|
||||||
|
expect(result.tasteStats['sweetness'], 3);
|
||||||
|
expect(result.tasteStats['acidity'], 3);
|
||||||
|
expect(result.tasteStats['bitterness'], 3);
|
||||||
|
expect(result.tasteStats['body'], 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tasteStats: nullまたは不正な型の場合は全キーが3になる', () {
|
||||||
|
final json = {'tasteStats': null};
|
||||||
|
final result = SakeAnalysisResult.fromJson(json);
|
||||||
|
|
||||||
|
expect(result.tasteStats['aroma'], 3);
|
||||||
|
expect(result.tasteStats['sweetness'], 3);
|
||||||
|
expect(result.tasteStats['body'], 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alcoholContent: intで渡された場合もdoubleとして取得できる', () {
|
||||||
|
final json = {'alcoholContent': 15};
|
||||||
|
final result = SakeAnalysisResult.fromJson(json);
|
||||||
|
expect(result.alcoholContent, 15.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flavorTags: nullの場合は空リストになる', () {
|
||||||
|
final json = {'flavorTags': null};
|
||||||
|
final result = SakeAnalysisResult.fromJson(json);
|
||||||
|
expect(result.flavorTags, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SakeAnalysisResult.asCached', () {
|
||||||
|
test('asCached()はisFromCache=trueを返す', () {
|
||||||
|
final original = SakeAnalysisResult(name: '獺祭', brand: '旭酒造');
|
||||||
|
final cached = original.asCached();
|
||||||
|
|
||||||
|
expect(cached.isFromCache, true);
|
||||||
|
expect(cached.name, '獺祭');
|
||||||
|
expect(cached.brand, '旭酒造');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('元のインスタンスはisFromCache=falseを維持する', () {
|
||||||
|
final original = SakeAnalysisResult(name: '久保田');
|
||||||
|
original.asCached();
|
||||||
|
expect(original.isFromCache, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SakeAnalysisResult.toJson / fromJson 往復', () {
|
||||||
|
test('toJson → fromJson で値が保持される', () {
|
||||||
|
final original = SakeAnalysisResult(
|
||||||
|
name: '八海山',
|
||||||
|
brand: '八海醸造',
|
||||||
|
prefecture: '新潟県',
|
||||||
|
type: '特別本醸造',
|
||||||
|
confidenceScore: 80,
|
||||||
|
flavorTags: ['辛口', 'すっきり'],
|
||||||
|
tasteStats: {'aroma': 2, 'sweetness': 2, 'acidity': 3, 'bitterness': 3, 'body': 3},
|
||||||
|
alcoholContent: 15.5,
|
||||||
|
polishingRatio: 55,
|
||||||
|
);
|
||||||
|
|
||||||
|
final json = original.toJson();
|
||||||
|
final restored = SakeAnalysisResult.fromJson(json);
|
||||||
|
|
||||||
|
expect(restored.name, original.name);
|
||||||
|
expect(restored.brand, original.brand);
|
||||||
|
expect(restored.prefecture, original.prefecture);
|
||||||
|
expect(restored.confidenceScore, original.confidenceScore);
|
||||||
|
expect(restored.flavorTags, original.flavorTags);
|
||||||
|
expect(restored.alcoholContent, original.alcoholContent);
|
||||||
|
expect(restored.polishingRatio, original.polishingRatio);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toJson に isFromCache は含まれない', () {
|
||||||
|
final result = SakeAnalysisResult(name: 'テスト').asCached();
|
||||||
|
final json = result.toJson();
|
||||||
|
expect(json.containsKey('isFromCache'), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue