diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..70195fa --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "includeCoAuthoredBy": false, + "enableAllProjectMcpServers": true, + "permissions": { + "allow": [ + "Bash(gemini:*)", + "Bash(git:*)" + ], + "deny": [ + "Bash(sudo:*)", + "Read(.env.*)", + "Read(id_rsa)", + "Read(id_ed25519)", + "Read(**/*token*)", + "Read(**/*key*)", + "Write(.env*)", + "Write(**/secrets/**)", + "Bash(wget:*)", + "Bash(nc:*)", + "Bash(rm:*)", + "Bash(npm uninstall:*)", + "Bash(npm remove:*)", + "Bash(psql:*)", + "Bash(mysql:*)", + "Bash(mongod:*)", + "mcp__supabase__execute_sql" + ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "if jq -r '.tool_input.command' | grep -q '^git commit' && jq -r '.tool_input.command' | grep -q '🤖 Generated with'; then echo 'Error: コミットメッセージに AI 署名が含まれている' 1>&2; exit 2; fi" + } + ] + } + ], + "PostToolUse": [], + "Notification": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "osascript -e 'display notification \"確認待ち\" with title \"Claude Code\"'" + } + ] + } + ], + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "osascript -e 'display notification \"タスク完了\" with title \"Claude Code\"'" + } + ] + } + ] + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1b60f1e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +[*.{kt,kts}] +ktlint_code_style = ktlint_official +ktlint_standard_no-wildcard-imports = disabled +ktlint_standard_filename = disabled diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..da39ea4 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,48 @@ +{ + "mcpServers": { + "serena": { + "type": "stdio", + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/oraios/serena", + "serena-mcp-server", + "--context", + "ide-assistant", + "--project", + "." + ], + "env": {} + }, + "playwright": { + "command": "npx", + "args": [ + "@playwright/mcp@latest" + ] + }, + "sequential-thinking": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-sequential-thinking" + ] + }, + "context7": { + "command": "npx", + "args": [ + "-y", + "@upstash/context7-mcp" + ] + } + }, + "scopes": { + "ide-assistant": { + "mcpServers": [ + "serena", + "playwright", + "sequential-thinking", + "context7" + ] + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..16cf8f6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,103 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## プロジェクト情報 +- **名称**: KidsPOS for Android - キッズビジネスタウンいちかわ用POSシステム +- **パッケージ**: info.nukoneko.cuc.android.kidspos +- **Android SDK**: minSdk 23, targetSdk 33 (buildSrc/Versions.ktで管理) +- **Kotlin JVM Target**: 17 + +## 開発コマンド + +### ビルド +```bash +# Prodビルド(本番用) +./gradlew assembleProdDebug # デバッグビルド +./gradlew assembleProdRelease # リリースビルド + +# Demoビルド(デモ用) +./gradlew assembleDemoDebug +./gradlew assembleDemoRelease + +# クリーンビルド +./gradlew clean assembleProdDebug +``` + +### テストとLint +```bash +# ユニットテスト実行 +./gradlew test +./gradlew testProdDebugUnitTest + +# Lint実行(必須) +./gradlew lint +./gradlew lintProdDebug + +# Lint自動修正 +./gradlew lintFix +``` + +### デバイスへのインストール +```bash +./gradlew installProdDebug +./gradlew uninstallProdDebug +``` + +## アーキテクチャ構造 + +### MVVMパターン +- **View層**: Activity/Fragment (DataBinding/ViewBinding使用) +- **ViewModel層**: AndroidX ViewModelを継承 +- **Model層**: Repository、API、Entity + +### 依存性注入 +- **Koin**: メインのDIフレームワーク(modules: coreModule, apiModule, viewModelModule) +- **Hilt**: Dagger Hiltも併用可能 +- 新規依存は `di/module/` 配下の適切なモジュールに追加 + +### イベント駆動 +- **EventBus** (org.greenrobot): アプリ全体のイベント通信 +- イベントクラスは `event/` パッケージに配置 +- `@Subscribe(threadMode = ThreadMode.MAIN)` でハンドラー定義 + +### 非同期処理 +- **Kotlin Coroutines**: `viewModelScope`、`lifecycleScope` を使用 +- **Retrofit + OkHttp**: API通信(ServerSelectionInterceptorで動的サーバー切替) + +## コーディング規則 + +### ログ出力(重要) +```kotlin +// ❌ 禁止 +print("message") +println("message") +debugPrint("message") + +// ✅ 必須:Logger使用 +Logger.d("Debug message") +Logger.e("Error message") +Logger.i("Info message") +``` + +### ファイル命名 +- Activity: `*Activity.kt` +- Fragment: `*Fragment.kt` +- ViewModel: `*ViewModel.kt` +- レイアウト: `activity_*.xml`, `fragment_*.xml`, `item_*.xml` + +### バーコード機能 +- `BarcodeReadDelegate` インターフェースを実装 +- `BaseBarcodeReadableActivity` を継承してActivity作成 + +### リソース管理 +- 全ての文字列は `strings.xml` に定義(日本語のみ) +- 既存実装パターンを必ず参照・踏襲する + +## タスク完了時の確認事項 + +1. **ビルド成功確認**: `./gradlew assembleProdDebug` +2. **Lintエラーゼロ**: `./gradlew lint` +3. **テスト合格**: `./gradlew test` +4. **Logger使用確認**: print文が含まれていないこと +5. **既存パターン準拠**: 類似機能の実装を参照したこと diff --git a/app/build.gradle b/app/build.gradle index f13e4c8..e9b0320 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,7 @@ import dependencies.Dep import dependencies.Packages import dependencies.Versions +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { id 'com.android.application' @@ -11,6 +12,7 @@ plugins { id 'com.google.gms.google-services' id 'com.google.firebase.crashlytics' id 'com.google.dagger.hilt.android' + id 'org.openapi.generator' } android { @@ -117,9 +119,7 @@ dependencies { implementation libs.androidx.preference.ktx implementation libs.androidx.constraintlayout - implementation(Dep.Zxing.android) { - transitive = false - } + implementation(Dep.Zxing.android) coreLibraryDesugaring libs.desugar.jdk.libs @@ -133,7 +133,41 @@ dependencies { implementation libs.retrofit.client implementation libs.retrofit.converter.serialization + implementation libs.converter.scalars implementation libs.logger implementation libs.eventbus } + +// OpenAPI Generator Configuration +openApiGenerate { + generatorName.set("kotlin") + inputSpec.set("$projectDir/openapi/api.yaml") + outputDir.set("$buildDir/generated/openapi") + apiPackage.set("info.nukoneko.cuc.android.kidspos.api.generated") + modelPackage.set("info.nukoneko.cuc.android.kidspos.api.generated.model") + invokerPackage.set("info.nukoneko.cuc.android.kidspos.api.generated.invoker") + configOptions.set([ + "library" : "jvm-retrofit2", + "useCoroutines" : "true", + "serializationLibrary": "kotlinx_serialization", + "dateLibrary" : "java8", + "enumPropertyNaming" : "UPPERCASE", + "collectionType" : "list" + ]) + additionalProperties.set([ + "generateApiDocumentation" : "false", + "generateModelDocumentation": "false" + ]) +} + +// Add generated sources to source sets +android.sourceSets { + main.kotlin.srcDirs += "$buildDir/generated/openapi/src/main/kotlin" +} + +// Make sure code is generated before compilation +tasks.withType(KotlinCompile).configureEach { + dependsOn tasks.openApiGenerate +} + diff --git a/app/openapi/api.yaml b/app/openapi/api.yaml new file mode 100644 index 0000000..8ef708e --- /dev/null +++ b/app/openapi/api.yaml @@ -0,0 +1,1026 @@ +openapi: 3.0.3 +info: + title: KidsPOS API + description: 子供向けPOSシステムのREST API + version: 1.0.0 + contact: + name: KidsPOS Team + url: https://github.com/KidsPOSProject/KidsPOS-Server + +servers: + - url: http://localhost:8080 + description: Local development server + - url: https://api.kidspos.example.com + description: Production server + +tags: + - name: Items + description: 商品管理 + - name: Sales + description: 売上管理 + - name: Staff + description: スタッフ管理 + - name: Stores + description: 店舗管理 + - name: Settings + description: 設定管理 + - name: Users + description: ユーザー管理 + +paths: + # Items + /api/item: + get: + tags: + - Items + summary: 商品一覧取得 + description: 登録されている全商品を取得します + operationId: getAllItems + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ItemResponse' + + post: + tags: + - Items + summary: 商品登録 + description: 新しい商品を登録します + operationId: createItem + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateItemRequest' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/ItemResponse' + '400': + description: Invalid request + '409': + description: Barcode already exists + + /api/item/{id}: + get: + tags: + - Items + summary: 商品取得 + description: 指定したIDの商品を取得します + operationId: getItemById + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/ItemResponse' + '404': + description: Item not found + + put: + tags: + - Items + summary: 商品更新 + description: 指定したIDの商品を更新します + operationId: updateItem + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateItemRequest' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/ItemResponse' + '404': + description: Item not found + + patch: + tags: + - Items + summary: 商品部分更新 + description: 指定したIDの商品を部分的に更新します + operationId: partialUpdateItem + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + barcode: + type: string + name: + type: string + price: + type: integer + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/ItemResponse' + '404': + description: Item not found + + delete: + tags: + - Items + summary: 商品削除 + description: 指定したIDの商品を削除します + operationId: deleteItem + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '204': + description: No Content + '404': + description: Item not found + + /api/item/barcode/{barcode}: + get: + tags: + - Items + summary: バーコードで商品取得 + description: バーコードから商品を検索します + operationId: getItemByBarcode + parameters: + - name: barcode + in: path + required: true + schema: + type: string + pattern: '^[0-9]{4,}$' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/ItemResponse' + '400': + description: Invalid barcode format + '404': + description: Item not found + + /api/item/barcode-pdf: + get: + tags: + - Items + summary: バーコードPDF生成 + description: 全商品のバーコードをPDF形式で生成します + operationId: generateBarcodePdf + responses: + '200': + description: Success + content: + application/pdf: + schema: + type: string + format: binary + + # Sales + /api/sales: + get: + tags: + - Sales + summary: 売上一覧取得 + description: 全売上データを取得します + operationId: getAllSales + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SaleResponse' + + post: + tags: + - Sales + summary: 売上登録 + description: 新しい売上を登録します + operationId: createSale + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSaleRequest' + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + status: + type: string + saleId: + type: integer + totalAmount: + type: integer + change: + type: integer + receiptPrinted: + type: boolean + '400': + description: Validation error + '500': + description: Processing error + + /api/sales/{id}: + get: + tags: + - Sales + summary: 売上詳細取得 + description: 指定したIDの売上詳細を取得します + operationId: getSaleById + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/SaleResponse' + '404': + description: Sale not found + + /api/sales/validate-printer/{storeId}: + get: + tags: + - Sales + summary: プリンター設定確認 + description: 指定した店舗のプリンター設定を確認します + operationId: validatePrinter + parameters: + - name: storeId + in: path + required: true + schema: + type: integer + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + isValid: + type: boolean + + # Staff + /api/staff: + get: + tags: + - Staff + summary: スタッフ一覧取得 + description: 全スタッフを取得します + operationId: getAllStaff + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/StaffEntity' + + post: + tags: + - Staff + summary: スタッフ登録 + description: 新しいスタッフを登録します + operationId: createStaff + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateStaffRequest' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/StaffEntity' + + /api/staff/{barcode}: + get: + tags: + - Staff + summary: スタッフ取得 + description: バーコードからスタッフを取得します + operationId: getStaffByBarcode + parameters: + - name: barcode + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/StaffEntity' + '404': + description: Staff not found + + put: + tags: + - Staff + summary: スタッフ更新 + description: スタッフ情報を更新します + operationId: updateStaff + parameters: + - name: barcode + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateStaffRequest' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/StaffEntity' + '404': + description: Staff not found + + delete: + tags: + - Staff + summary: スタッフ削除 + description: スタッフを削除します + operationId: deleteStaff + parameters: + - name: barcode + in: path + required: true + schema: + type: string + responses: + '204': + description: No Content + '404': + description: Staff not found + + # Stores + /api/stores: + get: + tags: + - Stores + summary: 店舗一覧取得 + description: 全店舗を取得します + operationId: getAllStores + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/StoreEntity' + + post: + tags: + - Stores + summary: 店舗登録 + description: 新しい店舗を登録します + operationId: createStore + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/StoreEntity' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/StoreEntity' + + /api/stores/{id}: + get: + tags: + - Stores + summary: 店舗取得 + description: 指定したIDの店舗を取得します + operationId: getStoreById + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/StoreEntity' + '404': + description: Store not found + + put: + tags: + - Stores + summary: 店舗更新 + description: 店舗情報を更新します + operationId: updateStore + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/StoreEntity' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/StoreEntity' + '404': + description: Store not found + + delete: + tags: + - Stores + summary: 店舗削除 + description: 店舗を削除します + operationId: deleteStore + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '204': + description: No Content + '404': + description: Store not found + + # Settings + /api/setting: + get: + tags: + - Settings + summary: 設定一覧取得 + description: 全設定を取得します + operationId: getAllSettings + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SettingEntity' + + post: + tags: + - Settings + summary: 設定作成 + description: 新しい設定を作成します + operationId: createSetting + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SettingEntity' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/SettingEntity' + + /api/setting/status: + get: + tags: + - Settings + summary: ステータス取得 + description: APIのステータスを取得します + operationId: getStatus + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: "OK" + + /api/setting/{key}: + get: + tags: + - Settings + summary: 設定取得 + description: 指定したキーの設定を取得します + operationId: getSettingByKey + parameters: + - name: key + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/SettingEntity' + '404': + description: Setting not found + + put: + tags: + - Settings + summary: 設定更新 + description: 設定を更新します + operationId: updateSetting + parameters: + - name: key + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + value: + type: string + required: + - value + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/SettingEntity' + '404': + description: Setting not found + + delete: + tags: + - Settings + summary: 設定削除 + description: 設定を削除します + operationId: deleteSetting + parameters: + - name: key + in: path + required: true + schema: + type: string + responses: + '204': + description: No Content + '404': + description: Setting not found + + /api/setting/printer/{storeId}: + get: + tags: + - Settings + summary: プリンター設定取得 + description: 店舗のプリンター設定を取得します + operationId: getPrinterSettings + parameters: + - name: storeId + in: path + required: true + schema: + type: integer + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + storeId: + type: integer + host: + type: string + port: + type: integer + '404': + description: Printer settings not found + + post: + tags: + - Settings + summary: プリンター設定保存 + description: 店舗のプリンター設定を保存します + operationId: savePrinterSettings + parameters: + - name: storeId + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + host: + type: string + port: + type: integer + required: + - host + - port + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + storeId: + type: integer + host: + type: string + port: + type: integer + message: + type: string + + /api/setting/application: + get: + tags: + - Settings + summary: アプリケーション設定取得 + description: アプリケーション全体の設定を取得します + operationId: getApplicationSettings + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/ApplicationSetting' + '404': + description: Application settings not found + + post: + tags: + - Settings + summary: アプリケーション設定保存 + description: アプリケーション全体の設定を保存します + operationId: saveApplicationSettings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ApplicationSetting' + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + serverHost: + type: string + serverPort: + type: integer + message: + type: string + + # Users + /api/users: + get: + tags: + - Users + summary: ユーザー一覧取得 + description: 全ユーザーを取得します + operationId: getAllUsers + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/StaffEntity' + + /api/users/{barcode}: + get: + tags: + - Users + summary: ユーザー取得 + description: バーコードからユーザーを取得します + operationId: getUserByBarcode + parameters: + - name: barcode + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/StaffEntity' + '404': + description: User not found + +components: + schemas: + ItemResponse: + type: object + properties: + id: + type: integer + example: 1 + barcode: + type: string + example: "1234567890" + name: + type: string + example: "ポテトチップス" + price: + type: integer + example: 150 + description: 価格(リバー) + required: + - id + - barcode + - name + - price + + CreateItemRequest: + type: object + properties: + barcode: + type: string + minLength: 4 + example: "1234567890" + name: + type: string + minLength: 1 + maxLength: 100 + example: "ポテトチップス" + price: + type: integer + minimum: 0 + maximum: 99999 + example: 150 + required: + - barcode + - name + - price + + SaleResponse: + type: object + properties: + id: + type: integer + storeId: + type: integer + staffBarcode: + type: string + createdAt: + type: string + format: date-time + totalAmount: + type: integer + description: 合計金額(リバー) + deposit: + type: integer + description: 預り金(リバー) + items: + type: array + items: + type: object + properties: + barcode: + type: string + name: + type: string + price: + type: integer + quantity: + type: integer + + CreateSaleRequest: + type: object + properties: + storeId: + type: integer + staffBarcode: + type: string + items: + type: array + items: + type: object + properties: + barcode: + type: string + quantity: + type: integer + minimum: 1 + required: + - barcode + - quantity + deposit: + type: integer + minimum: 0 + printReceipt: + type: boolean + default: true + required: + - storeId + - staffBarcode + - items + - deposit + + StaffEntity: + type: object + properties: + barcode: + type: string + example: "STAFF001" + name: + type: string + example: "田中太郎" + required: + - barcode + - name + + CreateStaffRequest: + type: object + properties: + barcode: + type: string + minLength: 1 + maxLength: 50 + name: + type: string + minLength: 1 + maxLength: 100 + required: + - barcode + - name + + UpdateStaffRequest: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 100 + required: + - name + + StoreEntity: + type: object + properties: + id: + type: integer + name: + type: string + printerUri: + type: string + nullable: true + required: + - name + + SettingEntity: + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + + ApplicationSetting: + type: object + properties: + serverHost: + type: string + serverPort: + type: integer + required: + - serverHost + - serverPort + + ErrorResponse: + type: object + properties: + error: + type: string + message: + type: string + timestamp: + type: string + format: date-time + path: + type: string + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + +# セキュリティは現在未実装のため、コメントアウト +# security: +# - bearerAuth: [] \ No newline at end of file diff --git a/app/src/demo/kotlin/info/nukoneko/cuc/android/kidspos/di/module/apiModule.kt b/app/src/demo/kotlin/info/nukoneko/cuc/android/kidspos/di/module/apiModule.kt index 5529635..cd0ff66 100644 --- a/app/src/demo/kotlin/info/nukoneko/cuc/android/kidspos/di/module/apiModule.kt +++ b/app/src/demo/kotlin/info/nukoneko/cuc/android/kidspos/di/module/apiModule.kt @@ -8,35 +8,58 @@ import info.nukoneko.cuc.android.kidspos.entity.Store import org.koin.dsl.module import java.util.* -val apiModule = module { - single { - object : APIService { - override suspend fun getStatus(): Any = Any() +/** + * Demo用のAPIService実装 + * ローカルで動作確認用のモックデータを返す + */ +class DemoAPIService : APIService( + itemsApi = throw NotImplementedError("Demo mode - itemsApi not used"), + salesApi = throw NotImplementedError("Demo mode - salesApi not used"), + staffApi = throw NotImplementedError("Demo mode - staffApi not used"), + storesApi = throw NotImplementedError("Demo mode - storesApi not used"), + settingsApi = throw NotImplementedError("Demo mode - settingsApi not used") +) { + override suspend fun getStatus(): Any = mapOf("status" to "OK", "mode" to "demo") + + override suspend fun createSale( + storeId: Int, + staffBarcode: String, + deposit: Int, + itemIds: String + ): Sale = Sale( + id = 1, + barcode = "123456", + createdAt = Date().toString(), + points = itemIds.split(",").size, + price = 100, + items = itemIds, + storeId = storeId, + staffId = 0 + ) - override suspend fun createSale( - storeId: Int, - staffBarcode: String, - deposit: Int, - itemIds: String - ): Sale = Sale( - 1, - "123456", - Date().toString(), - itemIds.split(",").size, - 0, - itemIds, - storeId, - 0 - ) + override suspend fun fetchStores(): List = + listOf( + Store(1, "お店1", null), + Store(2, "お店2", null) + ) - override suspend fun fetchStores(): List = - listOf(Store(1, "お店1"), Store(2, "お店2")) + override suspend fun getItem(itemBarcode: String): Item = + Item( + id = 1, + barcode = itemBarcode, + name = "DemoItem", + price = 100, + storeId = 1, + genreId = 1 + ) - override suspend fun getItem(itemBarcode: String): Item = - Item(1, itemBarcode, "DemoItem", 100, 1, 1) + override suspend fun getStaff(staffBarcode: String): Staff = + Staff(staffBarcode, "DemoStaff") +} - override suspend fun getStaff(staffBarcode: String): Staff = - Staff(staffBarcode, "DemoStaff") - } +val apiModule = module { + // Demo用のAPIServiceを登録 + single { + DemoAPIService() } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index faf6a9e..2a541d4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,9 @@ + + + + open suspend fun fetchStores(): List { + val response = storesApi.getAllStores() + return if (response.isSuccessful) { + response.body()?.map { storeEntity -> + Store( + id = storeEntity.id ?: 0, + name = storeEntity.name ?: "", + printerUri = storeEntity.printerUri + ) + } ?: emptyList() + } else { + throw Exception("Failed to fetch stores: ${response.code()}") + } + } - @FormUrlEncoded - @POST("sale/create") - suspend fun createSale( - @Field("storeId") storeId: Int, - @Field("staffBarcode") staffBarcode: String, - @Field("deposit") deposit: Int, - @Field("itemIds") itemIds: String - ): Sale + open suspend fun createSale( + storeId: Int, + staffBarcode: String, + deposit: Int, + itemIds: String + ): Sale { + // itemIdsをカンマ区切りからリストに変換 + // 注意: 新しいAPIではitemIdではなくバーコードを使用 + val itemBarcodes = itemIds.split(",") - @GET("item/{barcode}") - suspend fun getItem(@Path("barcode") itemBarcode: String): Item + val request = CreateSaleRequest( + storeId = storeId, + staffBarcode = staffBarcode, + deposit = deposit, + items = itemBarcodes.map { barcode -> + CreateSaleRequestItemsInner( + barcode = barcode, + quantity = 1 // デフォルトで1個とする + ) + } + ) - @GET("staff/{barcode}") - suspend fun getStaff(@Path("barcode") staffBarcode: String): Staff + val response = salesApi.createSale(request) + return if (response.isSuccessful) { + val saleResponse = response.body()!! + Sale( + id = saleResponse.saleId ?: 0, + barcode = saleResponse.saleId?.toString() ?: "", // バーコードはIDから生成 + createdAt = java.util.Date().toString(), // 現在時刻を設定 + points = 0, // ポイントは新APIにない + price = saleResponse.totalAmount ?: 0, + items = itemIds, // 元のitemIdsをそのまま使用 + storeId = storeId, + staffId = 0 // スタッフIDは取得できないため仮値 + ) + } else { + throw Exception("Failed to create sale: ${response.code()}") + } + } - @GET("setting/status") - suspend fun getStatus(): Any -} + open suspend fun getItem(itemBarcode: String): Item { + val response = itemsApi.getItemByBarcode(itemBarcode) + return if (response.isSuccessful) { + val itemResponse = response.body()!! + Item( + id = itemResponse.id, + barcode = itemResponse.barcode, + name = itemResponse.name, + price = itemResponse.price, + storeId = 0, // 新APIではstoreIdが返ってこない + genreId = 0 // 新APIではgenreIdが返ってこない + ) + } else { + throw Exception("Failed to get item: ${response.code()}") + } + } + + open suspend fun getStaff(staffBarcode: String): Staff { + val response = staffApi.getStaffByBarcode(staffBarcode) + return if (response.isSuccessful) { + val staffResponse = response.body()!! + Staff( + barcode = staffResponse.barcode ?: staffBarcode, + name = staffResponse.name ?: "" + ) + } else { + throw Exception("Failed to get staff: ${response.code()}") + } + } + + open suspend fun getStatus(): Any { + val response = settingsApi.getStatus() + return if (response.isSuccessful) { + response.body() ?: mapOf() + } else { + throw Exception("Failed to get status: ${response.code()}") + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/common/ErrorDialogFragment.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/common/ErrorDialogFragment.kt index 50b024c..830840a 100644 --- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/common/ErrorDialogFragment.kt +++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/common/ErrorDialogFragment.kt @@ -49,13 +49,25 @@ class ErrorDialogFragment : DialogFragment(), CoroutineScope { fragmentManager: FragmentManager, message: String ): DialogResult { + // Fragment表示前に状態をチェック + if (fragmentManager.isStateSaved) { + // 状態が保存済みの場合は表示をスキップ + return DialogResult.OK + } + val fragment = ErrorDialogFragment().also { it.arguments = Bundle().apply { putString(EXTRA_MESSAGE, message) } } - fragment.show(fragmentManager, message) - return fragment.channel.openSubscription().receive() + + try { + fragment.show(fragmentManager, message) + return fragment.channel.openSubscription().receive() + } catch (e: IllegalStateException) { + // 万が一エラーが発生した場合は無視 + return DialogResult.OK + } } } diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/calculate/AccountResultDialogFragment.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/calculate/AccountResultDialogFragment.kt index 6968487..b8996ff 100644 --- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/calculate/AccountResultDialogFragment.kt +++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/calculate/AccountResultDialogFragment.kt @@ -74,8 +74,19 @@ class AccountResultDialogFragment : DialogFragment(), CoroutineScope { } suspend fun showAndSuspend(fm: FragmentManager, tag: String? = null): DialogResult { - show(fm, tag) - return channel.openSubscription().receive() + // Fragment表示前に状態をチェック + if (fm.isStateSaved) { + // 状態が保存済みの場合は表示をスキップ + return DialogResult.OK + } + + try { + show(fm, tag) + return channel.openSubscription().receive() + } catch (e: IllegalStateException) { + // 万が一エラーが発生した場合は無視 + return DialogResult.OK + } } companion object { diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/itemlist/ItemListViewModel.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/itemlist/ItemListViewModel.kt index 4706352..1491fc1 100644 --- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/itemlist/ItemListViewModel.kt +++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/itemlist/ItemListViewModel.kt @@ -11,6 +11,7 @@ import info.nukoneko.cuc.android.kidspos.entity.Item import info.nukoneko.cuc.android.kidspos.event.BarcodeEvent import info.nukoneko.cuc.android.kidspos.event.EventBus import info.nukoneko.cuc.android.kidspos.event.SystemEvent +import info.nukoneko.cuc.android.kidspos.util.Mode import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -31,6 +32,9 @@ class ItemListViewModel( private val accountButtonEnabled = MutableLiveData() fun getAccountButtonEnabled(): LiveData = accountButtonEnabled + private val addItemButtonVisibility = MutableLiveData() + fun getAddItemButtonVisibility(): LiveData = addItemButtonVisibility + var listener: Listener? = null private val data: MutableList = mutableListOf() @@ -51,6 +55,21 @@ class ItemListViewModel( listener?.onStartAccount(data) } + fun onClickAddItem(@Suppress("UNUSED_PARAMETER") view: View?) { + // ダミー商品を追加 + val dummyItems = listOf( + Item(1, "123456789", "おもちゃ", 100, 1, 1), + Item(2, "223456789", "お菓子", 50, 1, 1), + Item(3, "323456789", "本", 200, 1, 1), + Item(4, "423456789", "文房具", 150, 1, 1), + Item(5, "523456789", "ゲーム", 300, 1, 1) + ) + val randomItem = dummyItems.random() + data.add(randomItem) + listener?.onDataAdded(randomItem) + updateViews() + } + fun onResume() { updateViews() } @@ -69,6 +88,10 @@ class ItemListViewModel( currentStaffVisibility.value = if (config.currentStaff == null) View.INVISIBLE else View.VISIBLE currentStaff.postValue("たんとう: ${config.currentStaff?.name ?: ""}") + // 練習モードのときのみ商品追加ボタンを表示 + addItemButtonVisibility.postValue( + if (config.currentRunningMode == Mode.PRACTICE) View.VISIBLE else View.GONE + ) } @Subscribe(threadMode = ThreadMode.MAIN) diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/storelist/StoreListDialogFragment.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/storelist/StoreListDialogFragment.kt index 7284676..32d8999 100644 --- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/storelist/StoreListDialogFragment.kt +++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/storelist/StoreListDialogFragment.kt @@ -71,11 +71,11 @@ class StoreListDialogFragment : DialogFragment() { binding.recyclerView.adapter = adapter binding.recyclerView.addItemDecoration( DividerItemDecoration( - context!!, + requireContext(), DividerItemDecoration.VERTICAL ) ) - binding.recyclerView.layoutManager = LinearLayoutManager(context) + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) } private fun setupSubscriber() { diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/storelist/StoreListViewModel.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/storelist/StoreListViewModel.kt index fe395ee..7ef4a04 100644 --- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/storelist/StoreListViewModel.kt +++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/storelist/StoreListViewModel.kt @@ -8,10 +8,12 @@ import info.nukoneko.cuc.android.kidspos.api.APIService import info.nukoneko.cuc.android.kidspos.api.RequestStatus import info.nukoneko.cuc.android.kidspos.di.GlobalConfig import info.nukoneko.cuc.android.kidspos.entity.Store +import info.nukoneko.cuc.android.kidspos.util.Mode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import kotlin.coroutines.CoroutineContext class StoreListViewModel( @@ -90,10 +92,24 @@ class StoreListViewModel( if (requestStatus == RequestStatus.REQUESTING) { return } + + // 練習モードの場合はダミー店舗を即座に表示 + if (config.currentRunningMode == Mode.PRACTICE) { + val dummyStores = listOf( + Store(1, "100リバー", null), + Store(2, "デパート", null) + ) + onFetchStoresSuccess(dummyStores) + return + } + requestStatus = RequestStatus.REQUESTING launch { requestStatus = try { - val stores: List = requestFetchStores() + // 3秒のタイムアウトを設定 + val stores: List = withTimeout(3000L) { + requestFetchStores() + } onFetchStoresSuccess(stores) RequestStatus.SUCCESS } catch (e: Throwable) { @@ -118,7 +134,15 @@ class StoreListViewModel( } private fun onFetchStoresFailure(error: Throwable) { - error.localizedMessage?.let { listener?.onShouldShowErrorDialog(it) } + val message = when { + error is kotlinx.coroutines.TimeoutCancellationException -> + "サーバーとの通信がタイムアウトしました。\n接続を確認してください。" + error.localizedMessage != null -> + error.localizedMessage!! + else -> + "通信エラーが発生しました。" + } + listener?.onShouldShowErrorDialog(message) requestStatus = RequestStatus.FAILURE } diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/setting/SettingActivity.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/setting/SettingActivity.kt index cd3149f..061fa49 100644 --- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/setting/SettingActivity.kt +++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/setting/SettingActivity.kt @@ -15,11 +15,21 @@ class SettingActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // ActionBarに戻るボタンを表示 + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowHomeEnabled(true) + supportActionBar?.title = "設定" + supportFragmentManager.beginTransaction() .replace(android.R.id.content, SettingFragment.newInstance()) .commit() } + override fun onSupportNavigateUp(): Boolean { + onBackPressed() + return true + } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data) if (result != null) { diff --git a/app/src/main/res/layout/fragment_item_list.xml b/app/src/main/res/layout/fragment_item_list.xml index bf19bb1..6dcdf66 100644 --- a/app/src/main/res/layout/fragment_item_list.xml +++ b/app/src/main/res/layout/fragment_item_list.xml @@ -43,10 +43,27 @@ android:background="@drawable/layout_button_rect_red" android:maxLines="1" android:onClick="@{viewModel::onClickClear}" + android:paddingStart="16dp" + android:paddingEnd="16dp" android:text="@string/delete" android:textColor="@android:color/white" android:textSize="22sp" /> +