commit 5c9de2594a04fd87067d2a5dd4c822bbbc34b370 Author: JiWoong Sul Date: Mon Sep 22 17:38:51 2025 +0900 환경 초기화 및 벤더 리포지토리 스켈레톤 도입 diff --git a/.env.development.example b/.env.development.example new file mode 100644 index 0000000..4bf95cd --- /dev/null +++ b/.env.development.example @@ -0,0 +1,14 @@ +API_BASE_URL=http://localhost:8080 + +# 기능 플래그 (true/false) +# 백엔드 엔드포인트 준비 상태에 따라 개별 화면 제어에 활용 +FEATURE_VENDORS_ENABLED=false +FEATURE_PRODUCTS_ENABLED=false +FEATURE_WAREHOUSES_ENABLED=false +FEATURE_CUSTOMERS_ENABLED=false +FEATURE_USERS_ENABLED=false +FEATURE_GROUPS_ENABLED=false +FEATURE_MENUS_ENABLED=false +FEATURE_GROUP_PERMISSIONS_ENABLED=false +FEATURE_APPROVALS_ENABLED=false +FEATURE_ZIPCODE_SEARCH_ENABLED=false diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..8ea95fe --- /dev/null +++ b/.env.production.example @@ -0,0 +1,13 @@ +API_BASE_URL=https://api.superport.example.com + +# 기능 플래그 (true/false) +FEATURE_VENDORS_ENABLED=true +FEATURE_PRODUCTS_ENABLED=true +FEATURE_WAREHOUSES_ENABLED=true +FEATURE_CUSTOMERS_ENABLED=true +FEATURE_USERS_ENABLED=true +FEATURE_GROUPS_ENABLED=true +FEATURE_MENUS_ENABLED=true +FEATURE_GROUP_PERMISSIONS_ENABLED=true +FEATURE_APPROVALS_ENABLED=true +FEATURE_ZIPCODE_SEARCH_ENABLED=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fdca77f --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Env files +.env.* +!.env.*.example diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..3268d00 --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "a402d9a4376add5bc2d6b1e33e53edaae58c07f8" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: android + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: ios + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: linux + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: macos + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: web + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: windows + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4e261df --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,99 @@ +# Repository Guidelines + +## Project Structure & Module Organization +Place all Flutter source in `lib/`, keeping cross-cutting pieces in `lib/core/` (config, constants, services) and feature screens under `lib/features//`. Shared widgets and theming go in `lib/widgets/` and `lib/theme/`. Tests mirror this layout in `test/` and, when browser flows matter, `integration_test/`. Static specs, including the UI breakdown in `doc/입출고 대여 폼 정리.md`, stay in `doc/`. Keep assets in `assets/` and register them inside `pubspec.yaml`. + +## Build, Test, and Development Commands +- `flutter pub get` — install or refresh package dependencies. +- `flutter analyze` — static analysis; treat warnings as blockers before review. +- `flutter test` — run the unit/widget suite; add `--coverage` when validating overall health. +- `flutter run -d chrome --web-renderer canvaskit` — local web run matching production rendering. +- `dart run build_runner build --delete-conflicting-outputs` — regenerate freezed/json_serializable files when models change. + +## Coding Style & Naming Conventions +Use Flutter’s two-space indentation and run `dart format .` before committing. Follow the Clean Architecture layering: DTOs/remote in `data`, domain interfaces/use cases in `domain`, controllers/widgets in presentation. File names use `snake_case.dart`; classes use `UpperCamelCase`; methods and fields use `lowerCamelCase`. Prefer `const` constructors/widgets, and use `shadcn_ui` components (especially `ShadTable`) for new screens. Register dependencies in `lib/injection_container.dart` via `get_it`. + +## Testing Guidelines +Each feature ships with unit tests (`*_test.dart`) living beside the source module. Widget tests should verify key flows like list rendering and form validation; leverage `WidgetTester` with fake data. Integration scenarios covering navigation across the inventory, approval, and reporting flows belong in `integration_test/`. Target meaningful coverage (≈70% per feature) and ensure CI-friendly seeds by avoiding real API calls. + +## Commit & Pull Request Guidelines +Commits follow the existing Superport convention: Korean imperative summaries with optional English technical nouns, e.g., `"대여 상세 테이블 정렬 수정"`. For PRs, include (1) a concise summary of user-visible impact, (2) screenshots or GIFs for UI changes, (3) linked issue or JIRA reference, and (4) verification notes (commands run, tests passing). Squash before merge unless release tagging requires history. + +## Architecture & Environment Notes +Initialize environments via `.env.development` / `.env.production` and load them through `Environment.initialize()` before bootstrapping DI. New data sources should expose repository interfaces in `domain/` and rely on the shared `ApiClient` instance. Do not use mock data in the application; always call the real backend (staging/production as appropriate). If an endpoint is not available, mark the feature as disabled behind a feature flag rather than mocking. + +--- + +## Language & Communication Policy + +- This document (AGENTS.md) and repository guidelines are written in English. +- Assistant responses to users (conversations, task summaries, PR descriptions) must be in Korean by default, unless the user explicitly requests English. +- Code comments must be written in Korean (see “Commenting Policy — Korean”). +- User-facing UI copy is Korean-first unless a requirement states otherwise. +- Identifiers (class/variable/function/file names) remain in English following the naming conventions above. + +--- + +## SRP & Clean Architecture Enforcement + +Apply these principles repo-wide. Use the checklist below during reviews. + +- Single Responsibility Principle (SRP) + - A class/file must have exactly one reason to change. + - In widgets, separate layout/rendering from state/event handling. Move complex logic to controllers/use cases. + - Extract form validation, transformations/mapping, sorting/filtering into dedicated utilities/services. + - Avoid god pages/widgets (split when file > 400 LOC or a build method > 200 LOC). + +- Clean Architecture boundaries + - presentation: views/controllers/state (view models). No business rules or direct data access. + - domain: entities, repository interfaces, use cases. No framework dependencies. + - data: DTOs and data sources (API/local) implementing domain interfaces. Never depend on presentation. + - DI: wire dependencies only in `lib/injection_container.dart`. No cross-layer back references. + +- Dependency rule + - Allowed direction is presentation → domain → data; never the reverse. + - domain must not depend on `flutter/*`, `shadcn_ui`, or any UI packages. + - data must not reference presentation. Convert DTOs to domain entities before exposing. + +- UI composition (shadcn_ui) + - Lists: standardize on `ShadTable`-based components; keep table/column specs in dedicated classes. + - Forms: separate field widgets, validation logic, and dialog layout; manage form state in a controller. + - Modals: use `SuperportShadDialog` consistently with header/body/footer sections. + +- Testing + - presentation: widget tests for rendering, interactions, and validation messages. + - domain: unit tests for use-case scenarios. + - data: contract tests for JSON mapping; never hit real network endpoints. + +## Networking Stack Guidelines + +- Use a single `ApiClient` (Dio wrapper) for all HTTP calls; do not instantiate raw `Dio` per feature. +- Initialize env via `Environment.initialize()` before DI, then register `ApiClient` in `lib/injection_container.dart` and inject into repositories. +- Attach `AuthInterceptor` to handle token injection and 401 → refresh → retry. On refresh failure, clear session and navigate to login. +- Standardize query params: `page`, `page_size`, `q`, `sort`, `order`, `updated_since`, `include`. +- Keep logging in development only and redact secrets (tokens/cookies). + +## Commenting Policy — Korean + +- Write comments in Korean for all public APIs, core logic, and business rules. + - Public classes/methods: use `///` doc comments in Korean (purpose, parameters, returns, exceptions, examples). + - Complex branches/math/performance: use `//` line comments to explain intent and rationale. + - Prefer Korean domain terms; include the English term in parentheses on first mention (e.g., 결재(approval)). +- Don’t: add meaningless comments, let comments drift from code, or mix excessive English. +- Do: document invariants, preconditions, side effects, and failure scenarios explicitly. + +## File/Function Size Guidelines + +- Files over ~400 LOC: consider splitting (widgets/controllers/utils). +- Functions over ~60 LOC: refactor into private helpers. +- Widget tree nesting > 5 levels: extract sub-widgets. + +## Review Checklist (SRP/Clean) + +- [ ] Does this file/class have exactly one reason to change? +- [ ] Are business rules absent from presentation? +- [ ] Does the domain layer avoid dependencies on frameworks/UI packages? +- [ ] Does the data layer avoid referencing presentation? +- [ ] Is DI centralized in `lib/injection_container.dart`? +- [ ] Are validation/transforms/sort/filter extracted into utilities/services? +- [ ] Are comments written in Korean and do they clearly cover intent/constraints/failure cases? diff --git a/README.md b/README.md new file mode 100644 index 0000000..410aade --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Superport v2 + +간단 입·출고 + 결재 시스템(웹) UI. + +## 환경 설정 + +1) 환경 파일 준비 (.env) + +루트에 다음 파일을 생성하세요(예시 제공): + +- `.env.development` → 개발 환경 +- `.env.production` → 운영 환경 + +`*.example` 파일을 복사하여 값을 채우면 됩니다. + +핵심 키: + +- `API_BASE_URL` — 백엔드 API 베이스 URL +- `FEATURE_*` — 기능 플래그 (예: `FEATURE_VENDORS_ENABLED`) + +2) 의존성 설치 + +``` +flutter pub get +``` + +3) 개발 실행 (웹) + +``` +flutter run -d chrome --web-renderer canvaskit --dart-define=ENV=development +``` + +## 구조 + +- `lib/core/` — 공통 구성(환경, 네트워크, 라우팅) +- `lib/features//` — 기능별 폴더 (clean architecture: domain/data/presentation) +- `lib/widgets/` — 공용 위젯 + +## 빌드/검증 + +- `flutter analyze` — 정적 분석 +- `flutter test` — 단위/위젯 테스트 diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..9f9214f --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.superport_v2" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.superport_v2" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9788172 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/superport_v2/MainActivity.kt b/android/app/src/main/kotlin/com/example/superport_v2/MainActivity.kt new file mode 100644 index 0000000..4679c01 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/superport_v2/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.superport_v2 + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ac3b479 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..fb605bc --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/doc/API_CLIENT_SPEC.md b/doc/API_CLIENT_SPEC.md new file mode 100644 index 0000000..c2548c3 --- /dev/null +++ b/doc/API_CLIENT_SPEC.md @@ -0,0 +1,151 @@ +# ApiClient 설계서 (Dio 기반, Superport 스타일) + +본 문서는 Superport 레포 스타일과 동일한 인증/네트워킹 패턴을 본 프로젝트에 적용하기 위한 ApiClient 설계를 정의한다. 실제 구현은 이후 단계에서 진행한다(문서 선정리). + +## 1) 목표 +- 단일 진입점 ApiClient(Dio 래퍼)로 모든 네트워크 호출 일원화 +- 환경 변수 기반 BaseURL/타임아웃/로그 레벨 설정 +- 인증 토큰 주입, 401 자동 처리(토큰 갱신 → 재시도), 에러 매핑 일관화 +- 목록/단건 표준 응답 구조에 맞춘 헬퍼 제공 + +## 2) 의존성(추가 예정) +- dio: ^5.x (HTTP 클라이언트) +- get_it: ^7.x (DI) — 이미 사용 중 +- flutter_secure_storage(or web localStorage 대체): 토큰 저장(플랫폼별 분기) +- intl: ^0.20.x (기존) +- 개발 전용: pretty_dio_logger(선택) + +## 3) 환경 변수 +- API_BASE_URL: 예) https://api.example.com/api/v1 +- API_CONNECT_TIMEOUT_MS: 예) 15000 +- API_RECEIVE_TIMEOUT_MS: 예) 30000 +- LOG_LEVEL: debug|info|warn|error + +로드 순서: `await Environment.initialize()` → DI에서 ApiClient 생성 시 사용 + +## 4) 인증 방식(슈퍼포트와 동일) +- 로그인: `POST /auth/login` → `{ data: { token: string, user?: {...} } }` +- 요청 헤더: `Authorization: Bearer ` +- 토큰 저장: 보안 저장소(모바일)/localStorage(웹) 또는 httpOnly 쿠키(백엔드 정책에 따름) +- 토큰 갱신(선택): `POST /auth/refresh` → `{ data: { token: string } }` +- 401 처리: `AuthInterceptor`가 401 수신 시 자동 갱신 → 원요청 재시도(1회). 갱신 실패 시 로그아웃/세션 초기화 및 로그인 화면 이동 + +## 5) 에러 매핑 정책 +- 400 BAD_REQUEST: 검증 오류 → 필드 에러로 매핑 +- 404 NOT_FOUND: 리소스 없음 +- 409 CONFLICT: 유니크 충돌/상태 충돌 +- 422 UNPROCESSABLE_ENTITY: 비즈니스 규칙 위반(예: 출고 고객 미선택, blocking 전이) +- 500+: 서버 오류 → 공통 메시지 + 로그 수집 +- 표준 포맷: `{ error: { code, message, details? } }` 수용. 비표준 응답은 DioException 메시지로 대체 + +## 6) 쿼리 규약/헬퍼 +- 페이지네이션: `page`, `page_size` +- 정렬: `sort`, `order=asc|desc` +- 검색: `q` +- 증분: `updated_since` +- include 확장: `include=lines,customers,approval` 등 +- 헬퍼: `buildQuery({page, pageSize, q, sort, order, include, filters})` + +## 7) ApiClient 스켈레톤(인터페이스) + +```dart +/// 네트워크 공통 클라이언트 (Dio 래퍼) +class ApiClient { + // 내부 Dio 인스턴스(외부 사용 금지, 필요한 경우 read-only 게터 제공) + final Dio _dio; + + ApiClient({required Dio dio}) : _dio = dio; + + Dio get dio => _dio; // 과도한 사용은 지양하고, 가능하면 아래 헬퍼 사용 + + Future> get( + String path, { + Map? query, + Options? options, + CancelToken? cancelToken, + }); + + Future> post( + String path, { + dynamic data, + Map? query, + Options? options, + CancelToken? cancelToken, + }); + + Future> patch( + String path, { + dynamic data, + Map? query, + Options? options, + CancelToken? cancelToken, + }); + + Future> delete( + String path, { + dynamic data, + Map? query, + Options? options, + CancelToken? cancelToken, + }); +} +``` + +구현 시 기본 옵션 +- BaseOptions: baseUrl, connectTimeout, receiveTimeout +- 공통 헤더: `Accept: application/json`, `Authorization: Bearer ` +- Interceptors: + - `AuthInterceptor`(요청 전 토큰 주입, 401에서 갱신/재시도) + - `LoggingInterceptor`(개발 모드에서만) + +## 8) Interceptor 설계 +- AuthInterceptor + - 요청: 저장된 토큰이 있으면 `Authorization` 헤더 추가 + - 응답: 401이면 1) 갱신 중 동시성 잠금 2) 갱신 성공 시 대기 중 요청 재시도 3) 실패 시 토큰 삭제/로그아웃 +- Retry 정책: 재시도는 1회, idempotent GET/HEAD 위주. POST/PATCH는 401 갱신 후 재시도 1회만 허용 + +## 9) 표준 응답 파서 +- 목록: `{ items: [...], page, page_size, total }` +- 단건: `{ data: {...} }` +- 제네릭 파서 유틸 제공: `parseList(res, fromJson)`, `parseItem(res, fromJson)` + +## 10) 샘플 사용 (Repository) + +```dart +class VendorRepositoryImpl implements VendorRepository { + final ApiClient api; + VendorRepositoryImpl(this.api); + + @override + Future> list({int page = 1, int pageSize = 20, String? q}) async { + final res = await api.get('/vendors', query: { 'page': page, 'page_size': pageSize, if (q != null) 'q': q }); + return parseList(res.data, Vendor.fromJson); + } + + @override + Future create(VendorCreate body) async { + final res = await api.post('/vendors', data: body.toJson()); + return parseItem(res.data, Vendor.fromJson); + } +} +``` + +## 11) 보안/스토리지 +- 토큰 저장: 플랫폼별로 적합한 저장소 사용(웹은 localStorage, 모바일은 secure storage) +- 민감정보 로깅 금지(토큰/쿠키 마스킹) +- CORS/쿠키 기반 인증 사용 시, Dio 요청에 `withCredentials=true` 설정 필요(백엔드 정책에 따름) + +## 12) 테스트 전략 +- 위젯/도메인 테스트: 네트워크 의존 제거(리포지토리를 테스트 더블로 대체) +- 통합 테스트: 실제 스테이징 API를 사용하여 로그인→호출→401→갱신→재시도 플로우 검증 + +## 13) 구현 순서 요약(체크) +- [ ] pubspec에 `dio`(필수), `pretty_dio_logger`(개발) 추가 +- [ ] `ApiClient`/`AuthInterceptor` 스켈레톤 작성 +- [ ] `Environment.initialize()` → `get_it` DI에서 ApiClient 생성/주입 +- [ ] 리포지토리 구현에서 ApiClient 사용으로 통일(직접 Dio 인스턴스화 금지) +- [ ] 에러/토큰/재시도 정책 위젯 레벨 연결(토스트/로그아웃) + +참고 +- Superport 레포: `.env`의 `API_BASE_URL`, `test_api_integration.sh`의 `/auth/login` + Bearer 사용 +- 본 프로젝트: AGENTS.md의 “Do not use mock data” 및 DI/레이어 경계 정책 준수 diff --git a/doc/IMPLEMENTATION_TASKS.md b/doc/IMPLEMENTATION_TASKS.md new file mode 100644 index 0000000..5e64bcd --- /dev/null +++ b/doc/IMPLEMENTATION_TASKS.md @@ -0,0 +1,119 @@ +# Superport v2 프론트엔드 구현 Task List (UI First → API Integration) + +본 체크리스트는 PRD(`doc/PRD_입출고_결재_v2.md`)를 기준으로 shadcn_ui 스타일과 반응형 패턴을 준수하여 화면을 구현하기 위한 단계별 작업 목록입니다. 작업 순서는 ① 코드 시작 전 최종 확인 → ② UI 스캐폴딩/상호작용 구현 → ③ 실제 API 연동(Dio/ApiClient/DI)입니다. Mock 데이터는 사용하지 않습니다. + +## 0) 코드 시작 전 최종 확인(Repository/환경) +- [ ] Flutter 버전/채널 확인, `flutter pub get` +- [ ] `pubspec.yaml` 확인: `go_router`, `shadcn_ui`, `intl`, `two_dimensional_scrollables`, `lucide_icons_flutter` 포함 +- [ ] `Environment.initialize()` 호출 및 `.env.development`/`.env.production`에서 `API_BASE_URL`/`TIMEOUT_MS`/`LOG_LEVEL` 로드 +- [ ] 라우팅 스켈레톤(go_router) 구성: 로그인/대시보드/입·출·대여/마스터/결재/보고서 +- [ ] 글로벌 테마/ShadTheme 확인(폰트/간격/배지/버튼 일관) + +## 1) 공통 컴포넌트/레이아웃(UI) +- [ ] AppLayout(좌 사이드바/상단 헤더/본문) 적용, 브레드크럼·타이틀·툴바 영역 정리 +- [ ] 테이블: `ShadTable.list` 표준화(고정 헤더/가로 스크롤/소팅/페이지네이션 UI만) +- [ ] 모달: `SuperportShadDialog`(헤더/본문/푸터 분리, 모바일 풀스크린) 공통 wrapper +- [ ] 입력 위젯: `ShadInput/Select/Switch`, `SuperportShadDatePicker/RangePicker` 적용 가이드 +- [ ] 필터바(검색/기간/상태/창고/Reset) 공통 위젯 +- [ ] 반응형 프리셋: 데스크톱/태블릿/모바일 열 가시성 설정(섹션 12 규칙 반영) +- [ ] 토스트/스낵바/스켈레톤/Empty 상태 공통 처리 + +## 2) 인증/대시보드(UI) +- [ ] 로그인 화면(`/login`): 아이디/비밀번호 UI(제출/로딩/에러 표시 흐름) +- [ ] 대시보드(`/`): KPI 카드, 최근 트랜잭션, 내 결재 대기 리스트 — 스켈레톤/Empty 상태 구현 + +## 3) 입고(`/inbounds`) UI +- [ ] 라우트/네비게이션 연결 +- [ ] 목록 테이블: 번호/처리일자/창고/트랜잭션번호/상태/작성자/품목수/총수량/비고 +- [ ] 필터: 기간/창고/상태/검색, 소팅/페이지네이션 +- [ ] 신규 모달: 헤더(처리일자/창고/상태/작성자/비고) + 시스템 필드(`transaction_type_id=입고` RO/숨김) +- [ ] 라인 테이블: 제품(자동완성)→제조사/단위 자동, 수량/단가/비고, (+)/(−) 행 편집 +- [ ] 검증: 필수/수량>=1/단가>=0, 상단 요약 + 인라인 에러 +- [ ] 수정 모달: 작성자/트랜잭션번호 RO, 종결 상태 수정 제한 + +## 4) 출고(`/outbounds`) UI +- [ ] 목록 테이블: 번호/처리일자/창고/트랜잭션번호/상태/작성자/고객수/품목수/총수량/비고 +- [ ] 필터: 기간/창고/상태/고객/검색 +- [ ] 신규/수정 모달: 헤더(…)+ 시스템 필드(`transaction_type_id=출고` RO/숨김) +- [ ] 고객 연결(멀티): 고객사 자동완성 → 토큰/칩 UI, 최소 1건 검증 +- [ ] 라인 테이블: 제품/제조사RO/단위RO/수량/단가/비고 + +## 5) 대여(`/rentals`) UI +- [ ] 목록 테이블: 번호/처리일자/창고/대여구분/트랜잭션번호/상태/반납예정일/고객수/품목수/비고 +- [ ] 필터: 기간/창고/상태/대여구분/반납예정일 범위/검색 +- [ ] 신규/수정 모달: 헤더(…/대여구분/반납예정일) + 시스템 필드(`transaction_type_id` 대여/반납 자동 매핑) +- [ ] 고객 연결(멀티) + 라인 테이블(입고·출고와 동일) +- [ ] 규칙: 대여구분은 종결 후 변경 불가, 반납예정일은 진행 중 수정 가능 + +## 6) 마스터(UI) +- [ ] 벤더: 목록/필터(q/사용여부), 신규/수정(코드RO), 삭제/복구 UI +- [ ] 제품: 목록/필터(q/제조사/단위/사용), 신규/수정(코드RO) +- [ ] 창고: 목록/필터(q/사용), 신규/수정(우편번호 검색 모달 UI 연동) +- [ ] 고객사: 목록/필터(q/유형/사용), 신규/수정(유형→is_partner/is_general 매핑 UI) +- [ ] 사용자: 목록/필터(q/그룹/사용), 신규/수정(사번RO) +- [ ] 그룹: 목록/필터(q/기본/사용), 신규/수정(그룹명RO) +- [ ] 메뉴: 목록/필터(q/상위/사용), 신규/수정(메뉴코드RO) +- [ ] 그룹 권한: 목록/필터(그룹/메뉴/사용), 체크박스 매트릭스 편집 UI + +## 7) 결재(UI) +- [ ] 결재(`/approvals`): 목록/필터, 상세(개요/단계/이력 탭) +- [ ] 템플릿 불러오기: 단계 탭에서 템플릿 선택 UI(단계 리스트 반영) +- [ ] 단계 행위: 승인/반려/코멘트 버튼(가능 여부 상태에 따라 비활성/툴팁) +- [ ] 단계 관리(`/approval-steps`): 목록/편집(신규/수정) +- [ ] 이력(`/approval-histories`): 조회 전용 테이블 +- [ ] 템플릿(`/approval-templates`): 목록/헤더+단계 반복 폼 + +## 8) 우편번호 검색 모달(UI) +- [ ] 입력: 검색어 텍스트 +- [ ] 결과 테이블: 우편번호/시도/시군구/도로명/건물번호 +- [ ] 선택 시: 부모 폼 `zipcode`/주소 구성요소 채움 + +## 9) 보고서(`/reports`) UI +- [ ] 조건 폼: 기간/유형/창고/상태 +- [ ] 액션: XLSX/PDF 버튼 — 미제공 시 버튼 비활성 UI(연동은 API 단계에서) + +## 10) 데이터 계층/상태 관리 +- [ ] 리포지토리 인터페이스(domain) 정의 및 구현(data): 트랜잭션/결재/마스터/룩업/우편번호 +- [ ] DTO ↔ 도메인 엔티티 매핑(응답 `{ items }`/`{ data }` 구조 표준화) +- [ ] 페이지네이션 상태(현재 페이지/사이즈/전체) 및 필터 상태 싱크(go_router querystring 연동) +- [ ] 정렬/검색/Include 옵션 직렬화 및 유지 + +## 11) API 연동 단계(Dio/ApiClient/DI) +- [ ] 네트워킹 패키지 추가: `dio:^5.x`, `pretty_dio_logger`(dev 선택), 토큰 저장용 `flutter_secure_storage`(모바일)/웹 스토리지 +- [ ] `ApiClient`/`AuthInterceptor` 스켈레톤 작성(설계: `doc/API_CLIENT_SPEC.md`) +- [ ] `Environment.initialize()` → `get_it` DI에서 ApiClient 생성/주입 +- [ ] 공통 에러 매핑(400/404/409/422) 및 토스트/필드 바인딩 연결 +- [ ] 메뉴/권한 로딩 → 버튼/액션 노출 제어 +- [ ] 각 화면 API 연결: + - 입고/출고/대여: 목록/상세/생성/수정/삭제/복구 + include/필터/정렬/페이지네이션 + - 마스터: vendors/products/warehouses/customers/employees/menus/groups/group-permissions(+ 일괄 저장) + - 결재: approvals(+steps/histories), actions, can-proceed, 템플릿 CRUD/단계 배치 + - 우편번호: `GET /zipcodes?...` + - 보고서: 다운로드 엔드포인트 연동(제공 시) + +## 12) 검증/접근성/상호작용 +- [ ] 필수/형식/업무 규칙 검증(출고/대여 고객 최소 1건 등) +- [ ] 키보드: Esc 닫기, Enter 제출/셀 이동, Tab 포커스 이동, 포커스 트랩 +- [ ] 합계/요약 배지 실시간 반영(수량/단가 변경 시) + +## 13) 반응형/열 가시성 +- [ ] 데스크톱/태블릿/모바일 프리셋 구현(PRD 섹션 12 규칙 적용) +- [ ] 모바일 카드형 요약(핵심 3~4필드) 구성 + +## 14) 테스트/품질 +- [ ] `flutter analyze` 경고 0 +- [ ] 위젯 테스트: 테이블 렌더/필터/페이지네이션/모달 열기/검증 메시지 +- [ ] 내비 통합 테스트(선택): 로그인 → 대시보드 → 입/출/대여 → 결재 → 마스터 +- [ ] `dart format .` 적용 + +## 15) Definition of Done(DoD) +- [ ] 모든 목록/폼/모달/필터/페이지네이션 동작 +- [ ] 모바일/태블릿/데스크톱 레이아웃 검증(핵심 열 가시성) +- [ ] 실제 API로 주요 플로우(신규/수정/삭제/복구) 검증 완료 +- [ ] 문서 최신화(PRD/체크리스트) + +## 참고 +- PRD: `doc/PRD_입출고_결재_v2.md` +- 사양: `doc/stock_approval_system_spec_v4.md`, `doc/stock_approval_system_api_v4.md` +- 네트워킹 설계: `doc/API_CLIENT_SPEC.md` +- 스타일 참조: `/Users/maximilian.j.sul/Documents/flutter/superport` diff --git a/doc/PRD_입출고_결재_v2.md b/doc/PRD_입출고_결재_v2.md new file mode 100644 index 0000000..cb5b22b --- /dev/null +++ b/doc/PRD_입출고_결재_v2.md @@ -0,0 +1,538 @@ +# Superport v2 입·출고 + 결재 시스템 PRD (Draft v1) + +본 문서는 `doc/입출고 대여 폼 정리.md`, `doc/stock_approval_system_spec_v4.md`, `doc/stock_approval_system_api_v4.md`를 근거로 Flutter 웹 프론트엔드 구현을 위한 상세 요구사항을 정리한다. 스타일은 `/Users/maximilian.j.sul/Documents/flutter/superport`의 UI/UX 패턴을 준수한다. + +## 1. 범위 및 목표 +- 범위: 로그인/대시보드/입고/출고/대여/마스터(벤더, 제품, 창고, 고객사, 사용자, 그룹, 메뉴, 권한)/결재(결재, 단계, 이력, 템플릿)/보고서. +- 목표: 백엔드 API와 연동하여 화면/네비게이션/상호작용(폼 검증, 필터, 페이징, 모달)을 완성. DTO/리포지토리 인터페이스를 분리해 유지보수를 용이하게 함. +- 기술: Flutter + shadcn_ui + go_router + two_dimensional_scrollables. 반응형 웹 최적화. +- 제약: 입력·수정 폼은 팝업 모달(ShadDialog 기반)로 노출. 테이블은 ShadTable 계열 사용. + +## 2. 전역 UX/스타일 가이드 +- 레이아웃(AppLayout): 좌측 사이드바 내비게이션 + 상단 헤더(타이틀, 브레드크럼, 액션) + 콘텐츠. +- 컴포넌트(슈퍼포트 준수): + - 테이블: `ShadTable.list`(고정 헤더, 필요 시 첫 컬럼 고정, 가로 스크롤 허용). + - 버튼: `ShadButton.(primary|outline|ghost)`, 행 액션은 `ghost` 아이콘 버튼. + - 입력: `ShadInput`, `ShadSelect`, `SuperportShadDatePicker/DateRangePicker`, `ShadSwitch`, `ShadBadge`, `ShadTooltip`. + - 모달: `SuperportShadDialog`(헤더/본문/푸터 구분, ESC/배경 클릭 닫기 옵션). + - 페이징: 테이블 하단 우측 `page / perPage / total` 표시 + 페이지네이션. +- 필터/검색 바: 테이블 상단. 기본 `검색(q)` + 주요 조건(기간/상태/창고 등). `Reset` 버튼 제공. +- 정렬: 테이블 헤더 클릭 1열 정렬(추후 멀티 정렬 확장 가능). +- 반응형: + - Breakpoints: `>=1280` 데스크톱 3열, `>=1024` 2열, `>=768` 2열(compact), `<768` 1열 카드형 목록 대체 및 모달 전면(full-screen) 전환. + - 테이블은 작은 화면에서 핵심 컬럼만 우선 노출(열 가시성 프리셋), 나머지는 가로 스크롤. +- 접근성: 포커스 트랩, 키보드 내비게이션(Tab/Enter/Esc), 명확한 에러 메시지. +- 로딩/빈 상태: Skeleton과 Empty 상태 메시지(“조건을 변경해 보세요”). 오류는 Toaster로 노출. + +## 3. 권한/메뉴 정책(뷰 관점) +- 그룹 기반 메뉴 권한(`group_menu_permissions`)은 서버 응답을 기반으로 적용: `can_create/read/update/delete`에 따라 버튼/액션 노출 제어. +- 메뉴 트리: 대시보드, 입고, 출고, 대여, 마스터(벤더, 제품, 창고, 고객사, 사용자, 그룹, 메뉴, 권한), 결재(결재, 단계, 이력, 템플릿), 보고서. + +## 4. 공통 데이터 소스 매핑(드롭다운/자동입력) +- 공통 룩업: + - 단위: `/uoms` → `uom_name` 표시 + - 트랜잭션 유형: `/transaction-types` (입고/출고/대여/반납) — 화면 진입 시 자동 지정 또는 대여구분에 따라 자동 매핑 + - 트랜잭션 상태: `/transaction-statuses`(대기/진행/보류/승인/반려 등) + - 결재 상태: `/approval-statuses`(blocking/terminal 속성 포함) + - 결재 행위: `/approval-actions`(approve/reject/comment) +- 마스터: + - 창고: `/warehouses`(우편번호/주소 포함) + - 벤더: `/vendors` + - 제품: `/products?include=vendor,uom`(제품 선택 시 제조사/단위 자동 세팅, RO) + - 고객사: `/customers`(유형 플래그: 파트너/일반) + - 사원: `/employees` +- 공통 자동입력/읽기전용 규칙: + - `작성자`: 로그인 사용자로 자동 세팅(RO). + - `트랜잭션번호/결재번호`: 생성 시 자동 발번(RO)·수정 불가. + - `생성일시/변경일시`: RO. + - 제품 선택 시 `제조사/단위` 자동 세팅(RO). 수량/단가 변경 시 `합계` 자동 계산(표시용). + - 고객사 유형(파트너/일반) 선택은 내부적으로 `is_partner`/`is_general` 불린 필드로 매핑하여 저장. + +## 5. 화면별 상세 사양 + +### 5.0 로그인/로그아웃 +- 라우트: `/login` +- 목적: 세션 시작/종료(로컬 상태). 성공 시 대시보드로 이동. +- 입력(모달 아님): 아이디[TXT], 비밀번호[PWD]. +- 검증: 아이디/비밀번호 필수. 실제 인증 성공 후 사용자/그룹/권한/메뉴를 서버에서 로딩. + +### 5.1 대시보드 +- 라우트: `/` +- 목적: 당일 입고/출고/대기 결재 위젯, 최근 트랜잭션, 내 결재 대기 요약. +- 위젯: + - KPI 배지: 오늘 입고/출고 건수, 대기 결재 수. + - 최근 트랜잭션: 번호, 일자, 유형, 상태, 작성자. + - 내 결재 대기: 결재번호, 현재 단계, 요청일시. + +### 5.2 입고(Stock Inbound) +- 라우트: `/inbounds` +- 역할: 구매/반입 처리. 라인에 제품/수량/단가 입력. +- 테이블 컬럼: 번호(id), 처리일자(processed_at), 창고(warehouse_name), 트랜잭션번호(transaction_no), 상태(status_name), 작성자(employee_name), 품목수(line_count), 총수량(total_qty), 비고(note). +- 기본 정렬: `processed_at desc`. +- 필터: 기간, 창고, 상태, 검색(q: 번호/비고 등). +- 행 액션: 상세(모달), 수정(모달), 삭제(소프트), 복구. +- 신규(모달: “입고 등록”): + - 헤더 섹션: 처리일자[DT, 필수], 창고[DD, 필수], 상태[DD, 기본=대기], 작성자[자동, RO], 비고[TXT]. + - 시스템 필드: 입출고유형[RO/숨김, `transaction_type_id`=입고] — 라우트 진입 시 고정 설정. + - 라인 섹션(ShadTable): + - 제품[DD/자동완성, 필수] → 선택 시 제조사[RO], 단위[RO] 자동 표시 + - 수량[NUM, >=1, 필수], 단가[NUM, >=0], 비고[TXT] + - (+)행 추가, (−)행 삭제, 최소 1행 필수 + - 검증: 전역 에러 바인딩(상단), 행 단위 인라인 에러. +- 수정(모달: “입고 수정”): + - 읽기전용: 작성자, 트랜잭션번호. + - 상태는 일부 제한(승인/반려 등 종결 상태는 수정 불가). + +### 5.3 출고(Stock Outbound) +- 라우트: `/outbounds` +- 역할: 출하/반출 처리. 고객사 연결 필수. +- 테이블 컬럼: 번호, 처리일자, 창고, 트랜잭션번호, 상태, 작성자, 고객수, 품목수, 총수량, 비고. +- 필터: 기간, 창고, 상태, 고객사, 검색. +- 신규/수정 모달: + - 헤더: 처리일자[DT], 창고[DD], 상태[DD], 작성자[RO], 비고[TXT]. + - 시스템 필드: 입출고유형[RO/숨김, `transaction_type_id`=출고] — 라우트 진입 시 고정 설정. + - 고객 연결: 고객사[DD-멀티|자동완성, 최소 1건], 비고[TXT]. + - 라인: 제품[DD/자동완성], 제조사[RO], 단위[RO], 수량[NUM], 단가[NUM], 비고. + - 규칙: 고객 최소 1건 없으면 저장 불가. + +### 5.4 대여(Rental) +- 라우트: `/rentals` +- 역할: 대여/반납 트랜잭션 관리. +- 테이블 컬럼: 번호, 처리일자, 창고, 대여/반납(유형), 트랜잭션번호, 상태, 반납예정일, 고객수, 품목수, 비고. +- 필터: 기간, 창고, 상태, 대여구분, 반납예정일 범위, 검색. +- 신규/수정 모달: + - 헤더: 처리일자[DT], 창고[DD], 상태[DD], 작성자[RO], 대여구분[DD: 대여/반납], 반납예정일[DT], 비고[TXT]. + - 시스템 필드: 입출고유형[RO/숨김, `transaction_type_id`=대여/반납(대여구분에 따라 자동 매핑)]. + - 고객 연결: 고객사[DD-멀티], 비고. + - 라인: 제품[DD/자동완성], 제조사[RO], 단위[RO], 수량[NUM], 단가[NUM], 비고. + - 규칙: 대여구분은 진행 중 제약(종결 후 변경 불가), 반납예정일은 수정 가능. + +### 5.5 제조사 관리(벤더) +- 라우트: `/masters/vendors` +- 테이블: 번호, 벤더코드, 벤더명, 사용여부, 비고, 변경일시. +- 필터: 검색(q), 사용여부, 삭제 포함(고급) 토글. +- 신규(모달): 벤더코드[TXT, 고유], 벤더명[TXT], 사용여부[SW], 비고[TXT]. +- 수정(모달): 벤더코드[RO], 생성/변경일시[RO]. + +### 5.6 장비 모델 관리(제품) +- 라우트: `/masters/products` +- 테이블: 번호, 제품코드, 제품명, 제조사, 단위, 사용여부, 비고, 변경일시. +- 필터: 검색(q), 제조사, 단위, 사용여부, 삭제 포함(고급) 토글. +- 신규: 제품코드[TXT], 제품명[TXT], 제조사[DD], 단위[DD], 사용여부[SW], 비고. +- 수정: 제품코드[RO], 생성일시[RO]. + +### 5.7 입고지 관리(창고) +- 라우트: `/masters/warehouses` +- 테이블: 번호, 창고코드, 창고명, 우편번호, 상세주소, 사용여부, 비고, 변경일시. +- 필터: 검색(q), 사용여부, 삭제 포함(고급) 토글. +- 신규/수정: 창고코드[TXT], 창고명[TXT], 우편번호[검색 모달], 상세주소[TXT], 사용여부[SW], 비고. 코드/일시는 RO. +- 우편번호 검색: 전용 모달(입력: 검색어 → 결과 리스트에서 선택 시 필드 채움). + +### 5.8 회사 관리(고객사) +- 라우트: `/masters/customers` +- 테이블: 번호, 고객사코드, 고객사명, 유형, 이메일, 연락처, 우편번호, 상세주소, 사용여부, 비고. +- 필터: 검색(q), 유형, 사용여부, 삭제 포함(고급) 토글. +- 신규: 고객사코드[TXT], 고객사명[TXT], 유형(파트너/일반)[DD(복수 선택 가능)], 이메일[TXT], 연락처[TXT], 우편번호[검색], 상세주소[TXT], 사용여부[SW], 비고. +- 수정: 고객사코드/생성일시 RO. + +### 5.9 사용자 관리(사원) +- 라우트: `/masters/employees` +- 테이블: 번호, 사번, 성명, 이메일, 연락처, 그룹, 사용여부, 비고, 변경일시. +- 필터: 검색(q), 그룹, 사용여부, 삭제 포함(고급) 토글. +- 신규: 사번[TXT], 성명[TXT], 이메일[TXT], 연락처[TXT], 그룹[DD], 사용여부[SW], 비고. +- 수정: 사번/생성일시 RO. + +### 5.10 그룹 관리 +- 라우트: `/masters/groups` +- 테이블: 번호, 그룹명, 설명, 기본여부, 사용여부, 비고, 변경일시. +- 필터: 검색(q), 기본여부, 사용여부, 삭제 포함(고급) 토글. +- 신규: 그룹명[TXT], 설명[TXT], 기본여부[SW], 사용여부[SW], 비고. +- 수정: 그룹명/생성일시 RO. + +### 5.11 메뉴 관리 +- 라우트: `/masters/menus` +- 테이블: 번호, 메뉴코드, 메뉴명, 상위메뉴, 경로, 사용여부, 비고, 변경일시. +- 필터: 검색(q), 상위메뉴, 사용여부, 삭제 포함(고급) 토글. +- 신규: 메뉴코드[TXT], 메뉴명[TXT], 상위메뉴[DD], 경로[TXT], 표시순서[NUM], 사용여부[SW], 비고. +- 수정: 메뉴코드/생성일시 RO. + +### 5.12 그룹 메뉴 권한 관리 +- 라우트: `/masters/group-permissions` +- 테이블: 번호, 그룹명, 메뉴명, 생성, 조회, 수정, 삭제, 사용여부, 변경일시. +- 필터: 그룹, 메뉴, 사용여부. +- 신규: 그룹[DD], 메뉴[DD], 생성/조회/수정/삭제[CHK], 사용여부[SW]. +- 수정: 그룹/메뉴 RO. + +### 5.13 결재 관리(Approvals) +- 라우트: `/approvals` +- 테이블: 번호, 결재번호, 트랜잭션번호, 상태, 상신자, 요청일시, 최종결정일시, 비고. +- 신규: 트랜잭션번호[DD], 결재번호[자동], 결재상태[DD(기본=대기)], 상신자[자동], 비고. +- 상세(우측 패널 또는 모달 탭): + - 개요: 현재 상태/현재 단계/상신자/요청일시/결정일시 + - 단계 탭: 단계 리스트(step_order, 승인자, 상태, 배정/결정 일시) + [템플릿 불러오기] 버튼 → 템플릿 선택 후 단계 일괄 생성(`POST /approvals/{id}/steps`) + - 이력 탭: 행위/변경 전/후 상태/일시/비고 +- 단계 행위(행 액션): 승인/반려/코멘트(결재 상태 규칙에 따라 전이 가능 여부 표시). + +### 5.14 결재 단계 관리(Approval Steps) +- 라우트: `/approval-steps` +- 테이블: 번호, 결재ID, 단계순서, 승인자, 상태, 배정일시, 결정일시, 비고. +- 신규/수정: 결재ID[DD], 단계순서[NUM], 승인자[DD], 단계상태[DD], 비고. (결재ID/단계순서 RO 규칙 준수) + +### 5.15 결재 이력 조회(Logs) +- 라우트: `/approval-histories` +- 테이블 전용: 번호, 결재ID, 단계ID, 승인자, 행위, 변경전상태, 변경후상태, 작업일시, 비고. + +### 5.16 결재 템플릿 관리 +- 라우트: `/approval-templates` +- 테이블: 번호, 템플릿코드, 템플릿명, 설명, 작성자, 사용여부, 변경일시. +- 신규/수정: + - 헤더: 템플릿코드[TXT], 템플릿명[TXT], 설명[TXT], 작성자[RO], 사용여부[SW], 비고[TXT]. + - 단계 섹션(반복): (+추가) 순서[NUM], 승인자[DD]. + +### 5.17 우편번호 검색(모달) +- 트리거: 창고/고객사 폼의 우편번호 필드의 “검색”. +- 입력: 검색어[TXT]. 결과 테이블: 우편번호 | 시도 | 시군구 | 도로명 | 건물번호. 선택 시 부모 폼에 채움. + +### 5.18 보고서 +- 라우트: `/reports` +- 조건: 기간[DT-기간], 유형[DD], 창고[DD], 상태[DD]. +- 액션: XLSX/PDF 다운로드(백엔드 제공 엔드포인트 연동, 미제공 시 버튼 비활성 처리). + +## 6. 입력/수정 폼 규칙(공통) +- 모달 구조: 헤더(타이틀) / 본문(스크롤) / 푸터(취소, 저장). 모바일에서 풀스크린 모달. +- 검증: 필수값 표기(*), 저장 시 필수/형식/업무 규칙 검사. 에러는 필드 하단 및 상단 요약으로 노출. +- 자동입력: 작성자/번호/일시 RO. 제품 선택 시 제조사/단위 자동 표시. 합계/라인수는 표시만. +- 멀티 선택: 고객사 다건은 토큰/칩 형태로 표시 및 제거 지원. +- 라인 편집: 테이블 내 인라인 편집, 행 추가/삭제. 최소 1행. + +## 7. API 연동 청사진(후속 단계) +- 목록: `{ items, page, page_size, total }` 포맷 사용. 쿼리: `page, page_size, q, sort, order, updated_since, include, active, deleted`. +- 단건: `{ data: { ... } }` 포맷. 생성 시 PK 미포함, 응답에 PK 포함. 수정 시 `id` 포함. +- 소프트 삭제/복구: `DELETE /{res}/{id}`, `POST /{res}/{id}/restore`. +- 엔드포인트 매핑 예: + - 입고/출고/대여: `GET /stock-transactions?include=lines,customers,approval` | `POST /stock-transactions`(헤더+라인+고객 일괄) | `PATCH /stock-transactions/{id}` | `DELETE /stock-transactions/{id}` | `POST /stock-transactions/{id}/restore` + - 결재: `GET /approvals?include=steps,histories` | `GET /approvals/{id}?include=steps,histories` | `POST /approvals/{id}/steps` | `PATCH /approvals/{id}/steps` | `POST /approval-steps/{id}/actions` | `GET /approvals/{id}/can-proceed` + - 마스터: `/vendors`, `/products`, `/warehouses`, `/customers`, `/employees`, `/menus`, `/groups`, `/group-permissions` + - 권한 일괄 갱신: `POST /groups/{id}/permissions` (체크박스 매트릭스 일괄 저장) + - 룩업: `/uoms`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions` + - 우편번호: `GET /zipcodes?zipcode=06000&road_name=세종대로` (검색 모달에서 복합 쿼리 지원) + +## 8. 와이어프레임(텍스트) +- 공통 리스트 스크린 + - 헤더: [타이틀] [우측: +신규, 기타액션] + - 필터바: [검색] [기간] [상태] [창고/유형 등] [Reset] + - 본문: ShadTable.list(컬럼들…) [좌측 체크박스(옵션)] [우측 행 액션] + - 하단: 페이지네이션(좌: 건수, 우: 페이지 컨트롤) +- 공통 폼 모달 + - 헤더: [타이틀] + - 본문: [필드 그리드(2~3열)] [라인 테이블(필요 시)] [고객/단계 섹션] + - 푸터: [취소] [저장(primary)] +- 예시) 출고 리스트 + - 필터바: 검색 | 기간 | 창고 | 상태 | 고객사 | Reset + - 테이블: 번호 | 처리일자 | 창고 | 트랜잭션번호 | 상태 | 작성자 | 고객수 | 품목수 | 총수량 | 비고 | (행액션) +- 예시) 출고 등록 모달 + - 헤더 필드: 처리일자 | 창고 | 상태 | 작성자(RO) | 비고 + - 고객사(멀티) 섹션: 고객사(+추가/자동완성) | 비고 + - 라인 섹션: 제품(자동완성) | 제조사(RO) | 단위(RO) | 수량 | 단가 | 비고 | (+행) + +## 9. 비기능 요구사항 +- 성능: 가상 스크롤 또는 배치 렌더링 고려, 페이지 당 기본 20~50행. +- 국제화: 한국어 우선. 날짜/숫자 포맷 `intl` 사용. +- 코드 구조: `lib/features//presentation` 중심, 위젯 재사용은 `lib/widgets/`에 공통화. DI는 `lib/injection_container.dart`. +- 정적 분석: `flutter analyze` 경고 0 유지. 포맷팅 `dart format .`. + +## 10. 테스트(프론트) +- 위젯 테스트: + - 테이블 렌더링(컬럼/행 수), 필터 적용, 페이징 이동, 모달 열기/검증 에러 표시. + - 위젯 테스트는 네트워크 의존 없는 UI 로직 검증에 한정(실제 API는 통합 테스트에서 검증). +- 통합 테스트(선택): 주요 내비게이션(대시보드 → 입/출/대여 → 결재 → 마스터) 흐름 확인. + +--- +참고 문서: `doc/입출고 대여 폼 정리.md`, `doc/stock_approval_system_spec_v4.md`, `doc/stock_approval_system_api_v4.md` + +## 11. 화면별 필드 매트릭스(유형/검증/자동/소스) + +표기 규칙: 유형(TXT/NUM/DT/CHK/SW/RO/DD/AA=자동완성), 소스(API/룩업/없음), 기본값, 검증(필수/형식/업무), 비고. + +### 11.1 입고(신규/수정 모달) +헤더 필드 +| key | 라벨 | 유형 | 소스 | 기본값 | 검증 | 비고 | +|---|---|---|---|---|---|---| +| processed_at | 처리일자 | DT | 없음 | 오늘 | 필수 | SuperportShadDatePicker | +| warehouse_id | 창고 | DD | `/warehouses` | 없음 | 필수 | 라벨=창고명 | +| status_id | 상태 | DD | `/transaction-statuses` | 대기 | 필수 | 진행/승인/반려 등 | +| created_by_id | 작성자 | RO | 로그인 | 로그인 사용자 | - | 자동 세팅 | +| note | 비고 | TXT | 없음 | - | - | 다중라인 허용 | +| transaction_type_id | 입출고유형 | RO/숨김 | `/transaction-types` | 입고 | - | 라우트로 자동 설정 | + +라인 필드(반복) +| key | 라벨 | 유형 | 소스 | 기본값 | 검증 | 비고 | +|---|---|---|---|---|---|---| +| product_id | 제품 | AA | `/products?q=` | - | 필수 | 선택 시 제조사/단위 자동 | +| vendor_name | 제조사 | RO | products.vendor | - | - | 제품 선택 시 자동 | +| uom_name | 단위 | RO | products.uom | - | - | 제품 선택 시 자동 | +| quantity | 수량 | NUM | 없음 | 1 | 필수, >=1 | 우측 정렬 | +| unit_price | 단가 | NUM | 없음 | 0 | >=0 | 우측 정렬 | +| line_note | 비고 | TXT | 없음 | - | - | | + +자동 표시(요약): 품목수(line_count), 총수량(total_qty), 총금액(total_amount) — 읽기전용. + +### 11.2 출고(신규/수정 모달) +헤더 필드: 입고와 동일 + 고객 연결 섹션 +| key | 라벨 | 유형 | 소스 | 기본값 | 검증 | 비고 | +|---|---|---|---|---|---|---| +| processed_at | 처리일자 | DT | - | 오늘 | 필수 | | +| warehouse_id | 창고 | DD | `/warehouses` | - | 필수 | | +| status_id | 상태 | DD | `/transaction-statuses` | 대기 | 필수 | | +| created_by_id | 작성자 | RO | 로그인 | 로그인 사용자 | - | | +| note | 비고 | TXT | - | - | - | | +| transaction_type_id | 입출고유형 | RO/숨김 | `/transaction-types` | 출고 | - | 라우트로 자동 설정 | + +고객 연결(반복 가능) +| key | 라벨 | 유형 | 소스 | 기본값 | 검증 | 비고 | +|---|---|---|---|---|---|---| +| customer_id | 고객사 | AA | `/customers?q=` | - | 최소 1건 | 토큰/칩 UI | +| customer_note | 비고 | TXT | - | - | - | | + +라인 필드: 입고와 동일(제품/제조사/단위/수량/단가/비고). + +업무 규칙: 고객사 최소 1건 필수, 없으면 저장 불가(422 메시지 노출). + +### 11.3 대여(신규/수정 모달) +헤더 필드 +| key | 라벨 | 유형 | 소스 | 기본값 | 검증 | 비고 | +|---|---|---|---|---|---|---| +| processed_at | 처리일자 | DT | - | 오늘 | 필수 | | +| warehouse_id | 창고 | DD | `/warehouses` | - | 필수 | | +| status_id | 상태 | DD | `/transaction-statuses` | 대기 | 필수 | | +| rental_type | 대여구분 | DD | 로컬(대여/반납) | 대여 | 필수 | 종결 후 변경 불가 | +| due_date | 반납예정일 | DT | - | +7일 | 선택 | 진행 중 수정 가능 | +| created_by_id | 작성자 | RO | 로그인 | 로그인 사용자 | - | | +| note | 비고 | TXT | - | - | - | | +| transaction_type_id | 입출고유형 | RO/숨김 | `/transaction-types` | 대여/반납 | - | 대여구분에 따른 자동 매핑 | + +고객 연결/라인 필드: 출고와 동일. + +### 11.4 마스터: 벤더/제품/창고/고객/사용자/그룹/메뉴/권한 +벤더 +| key | 라벨 | 유형 | 검증 | 비고 | +|---|---|---|---|---| +| vendor_code | 벤더코드 | TXT | 필수, 고유 | 수정 RO | +| vendor_name | 벤더명 | TXT | 필수 | | +| is_active | 사용여부 | SW | - | | +| note | 비고 | TXT | - | | + +제품 +| key | 라벨 | 유형 | 검증 | 비고 | +|---|---|---|---|---| +| product_code | 제품코드 | TXT | 필수, 고유 | 수정 RO | +| product_name | 제품명 | TXT | 필수 | | +| vendor_id | 제조사 | DD | 필수 | `/vendors` | +| uom_id | 단위 | DD | 필수 | `/uoms` | +| is_active | 사용여부 | SW | - | | +| note | 비고 | TXT | - | | + +창고 +| key | 라벨 | 유형 | 검증 | 비고 | +|---|---|---|---|---| +| warehouse_code | 창고코드 | TXT | 필수, 고유 | 수정 RO | +| warehouse_name | 창고명 | TXT | 필수 | | +| zipcode | 우편번호 | TXT | 필수 | 검색 모달로 채움 | +| address_detail | 상세주소 | TXT | 필수 | | +| is_active | 사용여부 | SW | - | | +| note | 비고 | TXT | - | | + +고객사 +| key | 라벨 | 유형 | 검증 | 비고 | +|---|---|---|---|---| +| customer_code | 고객사코드 | TXT | 필수, 고유 | 수정 RO | +| customer_name | 고객사명 | TXT | 필수 | | +| types | 유형 | DD(멀티) | 최소 1 | 파트너/일반 | +| email | 이메일 | TXT | 이메일형식 | | +| phone | 연락처 | TXT | - | | +| zipcode | 우편번호 | TXT | - | 검색 모달 | +| address_detail | 상세주소 | TXT | - | | +| is_active | 사용여부 | SW | - | | +| note | 비고 | TXT | - | | + +사용자(사원) +| key | 라벨 | 유형 | 검증 | 비고 | +|---|---|---|---|---| +| employee_no | 사번 | TXT | 필수, 고유 | 수정 RO | +| employee_name | 성명 | TXT | 필수 | | +| email | 이메일 | TXT | 이메일형식 | | +| phone | 연락처 | TXT | - | | +| group_id | 그룹 | DD | 필수 | `/groups` | +| is_active | 사용여부 | SW | - | | +| note | 비고 | TXT | - | | + +그룹 +| key | 라벨 | 유형 | 검증 | 비고 | +|---|---|---|---|---| +| group_name | 그룹명 | TXT | 필수, 고유 | 수정 RO | +| description | 설명 | TXT | - | | +| is_default | 기본여부 | SW | - | | +| is_active | 사용여부 | SW | - | | +| note | 비고 | TXT | - | | + +메뉴 +| key | 라벨 | 유형 | 검증 | 비고 | +|---|---|---|---|---| +| menu_code | 메뉴코드 | TXT | 필수, 고유 | 수정 RO | +| menu_name | 메뉴명 | TXT | 필수 | | +| parent_id | 상위메뉴 | DD | - | `/menus` | +| route_path | 경로 | TXT | 필수 | | +| display_order | 표시순서 | NUM | >=0 | | +| is_active | 사용여부 | SW | - | | +| note | 비고 | TXT | - | | + +그룹 메뉴 권한 +| key | 라벨 | 유형 | 검증 | 비고 | +|---|---|---|---|---| +| group_id | 그룹 | DD | 필수 | RO(수정) | +| menu_id | 메뉴 | DD | 필수 | RO(수정) | +| can_create | 생성 | CHK | - | | +| can_read | 조회 | CHK | - | | +| can_update | 수정 | CHK | - | | +| can_delete | 삭제 | CHK | - | | +| is_active | 사용여부 | SW | - | | + +결재 +| key | 라벨 | 유형 | 검증 | 비고 | +|---|---|---|---|---| +| transaction_id | 트랜잭션번호 | DD | 필수 | | +| approval_no | 결재번호 | RO | - | 자동 부여 | +| approval_status_id | 상태 | DD | 필수 | 기본=대기 | +| requested_by_id | 상신자 | RO | - | 로그인 사용자 | +| requested_at | 요청일시 | RO | - | 자동 | +| note | 비고 | TXT | - | | + +결재 단계 +| key | 라벨 | 유형 | 검증 | 비고 | +|---|---|---|---|---| +| approval_id | 결재ID | DD | 필수 | 수정 RO | +| step_order | 순서 | NUM | 필수, >=1 | 수정 RO | +| approver_id | 승인자 | DD | 필수 | `/employees` | +| step_status_id | 상태 | DD | 필수 | `/approval-statuses` | +| assigned_at | 배정일시 | RO | - | 자동 | +| decided_at | 결정일시 | RO | - | 자동 | +| note | 비고 | TXT | - | | + +결재 이력: 조회 전용(테이블 컬럼 정의만, 입력/수정 없음). + +결재 템플릿: 헤더(코드/명/설명/작성자/사용여부/비고) + 단계(순서/승인자 반복). + +우편번호 검색: 입력(검색어) → 결과 테이블에서 선택 시 부모의 `zipcode`/주소 구성요소 세팅. + +## 12. 테이블 스펙(열/정렬/반응형) + +공통: 정렬은 기본 `updated_at desc` 또는 업무상 자연스러운 키, 헤더 클릭으로 1열 정렬 전환. 폭(px)은 힌트 값이며 브레이크포인트에 따라 가변. + +입고 목록 +- 컬럼: 번호(80, center, sortable=id) | 처리일자(140, center, sortable) | 창고(160) | 트랜잭션번호(180) | 상태(120, center) | 작성자(140) | 품목수(100, right) | 총수량(120, right) | 비고(1fr) +- 모바일(<768): 번호, 처리일자, 상태, 품목수만 노출. 확장 시 나머지 표시. + +출고 목록 +- 컬럼: 번호 | 처리일자 | 창고 | 트랜잭션번호 | 상태 | 작성자 | 고객수(100, right) | 품목수(100, right) | 총수량(120, right) | 비고(1fr) +- 모바일: 번호/처리일자/상태/고객수. + +대여 목록 +- 컬럼: 번호 | 처리일자 | 창고 | 대여/반납(120, center) | 트랜잭션번호 | 상태 | 반납예정일(160, center) | 고객수 | 품목수 | 비고 +- 모바일: 번호/대여구분/반납예정일/상태. + +마스터 테이블(벤더/제품/창고/고객/사용자/그룹/메뉴/권한)과 결재/단계/이력/템플릿은 `doc/입출고 대여 폼 정리.md`의 1행 예시를 기준으로 동일한 컬럼 구성, 모바일에서는 코드/이름/상태 위주 노출. + +열 가시성 프리셋(예시) +- 데스크톱(≥1280): 모든 열 표시. 비고는 1fr로 확장. +- 태블릿(≥1024): 우선순위 낮은 열(비고/표시순서 등) 일부 숨김, 총합/상태/핵심 식별자 유지. +- 모바일(<768): 핵심 3~4열만 표시(예: 번호/상태/일자/구분). 나머지는 행 확장 또는 상세에서 제공. + +## 13. 폼 레이아웃 & 와이어프레임(텍스트) + +공통 모달(데스크톱) +``` +[모달 헤더: 타이틀] +──────────────────────────────────────────── +[필드 그리드 2~3열] + [라벨] [입력] [라벨] [입력] [라벨] [입력] + ... + +[섹션 타이틀] (예: 라인 품목) +[라인 테이블: 제품 | 제조사 | 단위 | 수량 | 단가 | 비고 | (+행)] + +[하단 합계/요약 배지] + +[푸터: 취소][저장] +``` + +모바일(풀스크린) +``` +[<] 제목 [저장] +──────────────────────────────────────── +[필드 1열 스택] +[섹션] +[라인: 카드형 반복] +[합계] +``` + +입고 등록 예시(데스크톱, 3열) +``` +처리일자 [DT] 창고 [DD] 상태 [DD] +작성자 [RO] 비고 [TXT···(colspan=2)] + +라인 섹션: +┌제품 [AA]┬제조사[RO]┬단위[RO]┬수량[NUM]┬단가[NUM]┬비고[TXT]┬(+) +└────────┴─────────┴──────┴───────┴───────┴───────┴──── + +[품목수: n][총수량: x][총금액: ₩y] +``` + +출고 등록: 상단 동일 + [고객사(멀티) 토큰 리스트 + (+추가) 자동완성] + +## 14. 상호작용/플로우 +- 리스트 → +신규 클릭 → 모달 → 저장 → 성공 토스트 → 리스트 리프레시/최상단 스크롤. +- 행 클릭 → 상세 모달(또는 우측 패널) → 수정 가능 상태면 “수정” 노출. +- 라인 편집: 수량/단가 변경 시 합계 즉시 반영. Enter/Tab으로 다음 셀 이동. (+)는 마지막 행 뒤에 포커스. +- 출고/대여 고객 토큰: 입력 자동완성 → Enter로 선택 → 토큰 생성. 토큰 X 클릭으로 제거. +- 삭제: 소프트 삭제 확인 모달 → 성공 시 상태 배지/행 스타일로 삭제 표시(필터로 숨김 기본). + +## 15. 검증/오류 메시지(예시) +- 필수 누락: “처리일자를 입력해 주세요.” +- 수량 음수: “수량은 1 이상이어야 합니다.” +- 고객 미선택(출고/대여): “고객사는 최소 1건 선택해야 합니다.” +- 결재 상태 전이 금지: “현재 단계 상태에서는 다음 단계로 이동할 수 없습니다.” +- 네트워크 오류 시: 오류 토스트/다시 시도 버튼/오류 상세 제공(더미 데이터 사용 금지). + +## 16. 키보드/접근성 +- 단축키: Cmd/Ctrl+S 저장, Esc 닫기, Enter 다음 필드/행 추가(라인 끝). +- 포커스 순서: 위→아래, 좌→우. 포커스 트랩으로 모달 내에서 순환. +- ARIA 라벨 등 시맨틱: 가능한 범위에서 위젯 라벨/설명 제공. + +## 17. 환경/피처 토글 +- 환경 변수로 API 베이스 URL/타임아웃/로그 레벨 제어. +- 기능 플래그로 베타 화면/행동 토글(데이터는 항상 실 API 사용). +- 페이지네이션: 기본 20행, 최대 100행. + +## 18. 상태 전이(결재) +- approval_statuses: 대기(기본, blocking), 진행중(blocking), 보류(blocking), 승인(non-blocking), 반려(terminal, blocking). +- 단계 행위: approve → 다음 단계 배정, 모든 단계 승인 시 전체 승인. reject → 전체 반려(terminal) 처리. +- 전이 금지: blocking 상태에서는 `can_proceed=false` 처리(버튼 비활성/툴팁). + +## 19. API 매핑(연동 시) +- 트랜잭션 목록: `GET /stock-transactions?include=lines,customers` +- 생성: `POST /stock-transactions` 바디 내 헤더/라인/고객 배열 동시 전달 +- 결재 상세: `GET /approvals/{id}?include=steps,histories` +- 단계 행위: `POST /approval-steps/{id}/actions` with `approval_action_id` +- 결재 템플릿: `GET/POST/PATCH /approval-templates`, `POST/PATCH /approval-templates/{id}/steps` +- 룩업: `/uoms`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions` + +## 20. 컴포넌트 매핑(shadcn_ui) +- 입력: `ShadInput`, 숫자 입력은 우측 정렬 스타일. +- 선택: `ShadSelect`(단일), 자동완성은 `ShadSelect + 검색` 패턴 또는 커스텀 콤보(리스트 팝오버, 키보드 선택) — 레퍼런스: 슈퍼포트 구현체. +- 날짜: `SuperportShadDatePicker`, 기간 필터는 `SuperportShadDateRangePicker`. +- 스위치/체크: `ShadSwitch`, `ShadCheckbox`. +- 모달: `SuperportShadDialog`(헤더/본문/푸터), 모바일 풀스크린 전환. +- 테이블: `ShadTable.list` + `two_dimensional_scrollables`로 스크롤 최적화. + +--- +부록: 필요 시 각 스크린의 모바일 카드형 레이아웃 명세(요약 배지, 핵심 필드 순서)를 추가 정의한다. diff --git a/doc/stock_approval_system_api_v4.md b/doc/stock_approval_system_api_v4.md new file mode 100644 index 0000000..8131254 --- /dev/null +++ b/doc/stock_approval_system_api_v4.md @@ -0,0 +1,955 @@ +# 간단 입·출고 + 결재 시스템 API 규격 (v4) + +**기준 버전:** 2025-09-18 16:22:30Z (UTC) + +본 문서는 `stock_approval_system_spec_full_v4.md`의 데이터 모델과 비즈니스 규칙을 기반으로 한 REST API 구성을 정의한다. 기본 CRUD를 제공하며, 목록·상세 조회 시 FK로 연결된 주요 엔터티 정보를 함께 반환한다. 모든 엔드포인트는 소프트 삭제 컬럼(`is_deleted`)을 노출하지 않는다. + +--- + +## 1. 공통 규칙 +- **URI 규칙:** 복수형 리소스 명 사용. 기본 경로 예) `/api/v1/vendors`. +- **표준 응답 구조:** 목록은 `{ items: [], page, page_size, total }`, 단건은 `{ data: { ... } }`. +- **시간대:** 모든 날짜·시간은 ISO8601 UTC 문자열. +- **소프트 삭제:** `DELETE /{res}/{id}` 호출 시 서버는 `is_deleted=true`, `is_active=false`로 처리하고 응답 바디는 `{ data: { id, deleted_at } }` 형식을 사용. +- **복구:** `POST /{res}/{id}/restore`. +- **공통 컬럼:** `note`, `is_active`, `created_at`, `updated_at`는 요청·응답에 필요 시 노출하되 `is_deleted`는 절대 노출하지 않는다. +- **기본 필터:** 목록 조회 시 기본 쿼리 `active=true`, `deleted=false`. `deleted` 파라미터가 `true`일 때에만 삭제된 항목을 반환. +- **증분 조회:** `updated_since=ISO8601`. +- **정렬:** `sort`(기본 `updated_at`), `order=asc|desc`(기본 desc). +- **검색:** `q` 파라미터로 코드/명칭 부분 일치. 필요한 경우 컬럼별 필터 지원. +- **Include 확장:** `include` 쿼리로 추가 데이터(`lines`, `customers`, `approval`, `steps`, `histories`, `permissions`, `employees` 등) 선택 가능. 포함 대상은 FK 요약 정보를 이미 반환하므로 `include`는 상세 컬렉션을 불러올 때 사용. +- **배열 입력:** 트랜잭션 라인, 트랜잭션 고객, 결재 단계, 그룹 메뉴 권한 등 다건 작업은 항상 배열(`[]`) 기반으로 요청한다. +- **Primary Key 규칙:** Create 요청 바디에는 PK를 포함하지 않는다. Create 응답 및 나머지 모든 요청·응답에는 PK가 포함돼야 한다(경로에 이미 포함된 경우라도 바디 내 `id`를 명시). +- **에러 규격:** + - `400 BAD_REQUEST` — 검증 오류, 필수값 누락. + - `404 NOT_FOUND` — 리소스 없음 또는 삭제됨. + - `409 CONFLICT` — 유니크 제약, 결재 단계 상태 충돌. + - `422 UNPROCESSABLE_ENTITY` — 비즈니스 규칙 위반(출고 고객 누락, blocking 상태 전이 등). + - 에러 응답 예: `{ "error": { "code": 422, "message": "출고 트랜잭션에는 고객이 최소 1건 필요합니다.", "details": [...] } }`. + +--- + +## 2. 타입(룩업) API +대상: `/uoms`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions` + +### 2.1 목록 조회 +`GET /{type}?page=1&page_size=50&active=true` +```json +{ + "items": [ + { + "id": 1, + "name": "EA", + "is_default": true, + "is_active": true, + "note": null, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-02-01T03:00:00Z" + } + ], + "page": 1, + "page_size": 50, + "total": 1 +} +``` + +### 2.2 단건 조회 +`GET /{type}/{id}` +```json +{ + "data": { + "id": 3, + "name": "반려", + "is_default": false, + "is_blocking_next": true, + "is_terminal": true, + "is_active": true, + "note": "최종 거절", + "created_at": "2025-01-10T09:00:00Z", + "updated_at": "2025-02-01T10:00:00Z" + } +} +``` + +### 2.3 생성 +`POST /{type}` +```json +{ + "name": "진행중", + "is_default": false, + "is_blocking_next": true, + "is_terminal": false, + "is_active": true, + "note": null +} +``` +응답: +```json +{ + "data": { + "id": 4, + "name": "진행중", + "is_default": false, + "is_blocking_next": true, + "is_terminal": false, + "is_active": true, + "note": null, + "created_at": "2025-03-01T00:00:00Z", + "updated_at": "2025-03-01T00:00:00Z" + } +} +``` + +### 2.4 수정 +`PATCH /{type}/{id}` +```json +{ + "id": 4, + "is_blocking_next": false, + "note": "임시 승인 허용" +} +``` + +### 2.5 삭제 & 복구 +- `DELETE /{type}/{id}` → `{ "data": { "id": 4, "deleted_at": "2025-03-05T09:00:00Z" } }` +- `POST /{type}/{id}/restore` → `{ "data": { "id": 4, "restored_at": "2025-03-06T01:00:00Z" } }` + +> `approval-statuses`는 추가 속성(`is_blocking_next`, `is_terminal`)을 사용하며, 다른 타입 테이블은 `name`, `is_default`, `is_active`, `note` 중심으로 작동한다. + +--- + +## 3. 마스터 데이터 API +리소스: `/vendors`, `/warehouses`, `/customers`, `/employees`, `/products`, `/menus`, `/groups`, `/zipcodes` + +### 3.1 목록 조회 +`GET /vendors?page=1&q=한빛` +```json +{ + "items": [ + { + "id": 10, + "vendor_code": "V001", + "vendor_name": "한빛상사", + "note": "서울/경기 공급처", + "is_active": true, + "created_at": "2025-01-01T12:00:00Z", + "updated_at": "2025-01-03T09:00:00Z" + } + ], + "page": 1, + "page_size": 50, + "total": 1 +} +``` + +`GET /products?page=1&include=vendor` +```json +{ + "items": [ + { + "id": 101, + "product_code": "P100", + "product_name": "샘플", + "vendor": { + "id": 10, + "vendor_code": "V001", + "vendor_name": "한빛상사" + }, + "uom": { + "id": 1, + "uom_name": "EA", + "is_default": true + }, + "note": "출고 우선 재고", + "is_active": true, + "created_at": "2025-02-01T12:00:00Z", + "updated_at": "2025-02-03T09:00:00Z" + } + ], + "page": 1, + "page_size": 50, + "total": 1 +} +``` + +`GET /warehouses?page=1` +```json +{ + "items": [ + { + "id": 20, + "warehouse_code": "WH-001", + "warehouse_name": "1센터", + "zipcode": { + "zipcode": "06000", + "sido": "서울특별시", + "sigungu": "강남구", + "road_name": "테헤란로" + }, + "address_detail": "강남파이낸스센터 10층", + "is_active": true, + "created_at": "2025-01-05T08:00:00Z", + "updated_at": "2025-01-10T09:30:00Z" + } + ], + "page": 1, + "page_size": 50, + "total": 1 +} +``` + +`GET /customers?page=1` +```json +{ + "items": [ + { + "id": 301, + "customer_code": "C001", + "customer_name": "ABC물류", + "is_partner": true, + "is_general": false, + "email": "contact@abc.com", + "mobile_no": "010-1234-5678", + "zipcode": { + "zipcode": "06000", + "sido": "서울특별시", + "sigungu": "강남구", + "road_name": "테헤란로" + }, + "address_detail": "10층", + "note": "VIP 고객", + "is_active": true, + "created_at": "2025-01-15T11:00:00Z", + "updated_at": "2025-01-20T08:10:00Z" + } + ], + "page": 1, + "page_size": 50, + "total": 1 +} +``` + +`GET /employees?page=1` +```json +{ + "items": [ + { + "id": 7, + "employee_no": "E2025001", + "employee_name": "김승인", + "email": "approver@example.com", + "mobile_no": "010-2222-1111", + "group": { + "id": 2, + "group_name": "창고 관리자" + }, + "is_active": true, + "created_at": "2025-01-02T09:00:00Z", + "updated_at": "2025-01-10T11:00:00Z" + } + ], + "page": 1, + "page_size": 50, + "total": 1 +} +``` + +`GET /groups?include=permissions,employees` +```json +{ + "items": [ + { + "id": 2, + "group_name": "창고 관리자", + "group_description": "창고 및 재고 관리", + "is_default": false, + "is_active": true, + "permissions": [ + { + "id": 8, + "menu": { + "id": 12, + "menu_code": "STOCK_MGMT", + "menu_name": "입출고 관리" + }, + "can_create": true, + "can_read": true, + "can_update": true, + "can_delete": false + } + ], + "employees": [ + { + "id": 7, + "employee_no": "E2025001", + "employee_name": "김승인" + } + ], + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-15T00:00:00Z" + } + ], + "page": 1, + "page_size": 50, + "total": 1 +} +``` + +### 3.2 단건 조회 +`GET /products/101` +```json +{ + "data": { + "id": 101, + "product_code": "P100", + "product_name": "샘플", + "vendor": { + "id": 10, + "vendor_code": "V001", + "vendor_name": "한빛상사", + "note": "서울/경기 공급처" + }, + "uom": { + "id": 1, + "uom_name": "EA", + "is_default": true + }, + "note": "출고 우선 재고", + "is_active": true, + "created_at": "2025-02-01T12:00:00Z", + "updated_at": "2025-02-03T09:00:00Z" + } +} +``` + +### 3.3 생성 +`POST /vendors` +```json +{ + "vendor_code": "V002", + "vendor_name": "미래상사", + "note": "부산 공급처", + "is_active": true +} +``` +응답: +```json +{ + "data": { + "id": 11, + "vendor_code": "V002", + "vendor_name": "미래상사", + "note": "부산 공급처", + "is_active": true, + "created_at": "2025-03-01T00:00:00Z", + "updated_at": "2025-03-01T00:00:00Z" + } +} +``` + +### 3.4 수정 +`PATCH /products/101` +```json +{ + "id": 101, + "product_name": "샘플 A", + "note": "재고 우선순위 변경" +} +``` + +### 3.5 삭제 & 복구 +- `DELETE /products/101` +- `POST /products/101/restore` + +### 3.6 그룹 메뉴 권한 일괄 갱신 +`POST /groups/2/permissions` +```json +{ + "id": 2, + "entries": [ + { + "menu_id": 12, + "can_create": true, + "can_read": true, + "can_update": true, + "can_delete": false + }, + { + "menu_id": 13, + "can_create": false, + "can_read": true, + "can_update": false, + "can_delete": false + } + ] +} +``` +응답은 갱신된 권한 목록을 반환. + +> `zipcodes`는 대량 데이터 특성상 `GET /zipcodes?zipcode=06000&road_name=세종대로` 형태로 조회하며, 응답 항목에는 `zipcode`, `sido`, `sigungu`, `road_name`, `building_main_no` 등 주소 구성 요소가 포함된다. + +--- + +## 4. 트랜잭션 API +리소스: `/stock-transactions`, 보조 리소스: `/transaction-lines`, `/transaction-customers` + +### 4.1 생성 (헤더 + 라인 + 고객 다건) +`POST /stock-transactions` +```json +{ + "transaction_no": "TXN-2025-0001", + "transaction_type_id": 1, + "transaction_status_id": 1, + "warehouse_id": 1, + "transaction_date": "2025-09-18", + "created_by_id": 7, + "note": "창고 입고", + "lines": [ + { + "line_no": 1, + "product_id": 101, + "quantity": 50, + "unit_price": 1200 + }, + { + "line_no": 2, + "product_id": 102, + "quantity": 20, + "unit_price": 0 + } + ], + "customers": [] +} +``` +응답은 생성된 트랜잭션 전체 정보를 반환하며, 라인·고객 식별자가 포함된다. + +### 4.2 목록 조회 +`GET /stock-transactions?include=lines,customers,approval` +```json +{ + "items": [ + { + "id": 9001, + "transaction_no": "TXN-2025-0001", + "transaction_type": { + "id": 1, + "type_name": "입고" + }, + "transaction_status": { + "id": 1, + "status_name": "초안" + }, + "warehouse": { + "id": 1, + "warehouse_code": "WH-001", + "warehouse_name": "1센터" + }, + "transaction_date": "2025-09-18", + "created_by": { + "id": 7, + "employee_no": "E2025001", + "employee_name": "김승인" + }, + "note": "창고 입고", + "is_active": true, + "created_at": "2025-09-18T05:00:00Z", + "updated_at": "2025-09-18T05:00:00Z", + "lines": [ + { + "id": 12001, + "line_no": 1, + "product": { + "id": 101, + "product_code": "P100", + "product_name": "샘플", + "vendor": { + "id": 10, + "vendor_name": "한빛상사" + }, + "uom": { + "id": 1, + "uom_name": "EA" + } + }, + "quantity": 50, + "unit_price": 1200, + "note": null + } + ], + "customers": [], + "approval": null + } + ], + "page": 1, + "page_size": 50, + "total": 1 +} +``` + +### 4.3 단건 조회 +`GET /stock-transactions/9001?include=lines,customers,approval,approval.steps` +```json +{ + "data": { + "id": 9001, + "transaction_no": "TXN-2025-0001", + "transaction_type": { + "id": 1, + "type_name": "입고" + }, + "transaction_status": { + "id": 1, + "status_name": "초안" + }, + "warehouse": { + "id": 1, + "warehouse_code": "WH-001", + "warehouse_name": "1센터", + "zipcode": { + "zipcode": "06000", + "sido": "서울특별시" + } + }, + "transaction_date": "2025-09-18", + "created_by": { + "id": 7, + "employee_no": "E2025001", + "employee_name": "김승인" + }, + "note": "창고 입고", + "is_active": true, + "created_at": "2025-09-18T05:00:00Z", + "updated_at": "2025-09-18T05:00:00Z", + "lines": [ + { + "id": 12001, + "line_no": 1, + "product": { + "id": 101, + "product_code": "P100", + "product_name": "샘플", + "vendor": { + "id": 10, + "vendor_name": "한빛상사" + }, + "uom": { + "id": 1, + "uom_name": "EA" + } + }, + "quantity": 50, + "unit_price": 1200, + "note": null + } + ], + "customers": [], + "approval": null + } +} +``` + +### 4.4 헤더 수정 +`PATCH /stock-transactions/9001` +```json +{ + "id": 9001, + "transaction_status_id": 2, + "note": "상신 준비" +} +``` + +### 4.5 라인 다건 추가/수정/삭제 +- **추가:** `POST /stock-transactions/9001/lines` +```json +{ + "id": 9001, + "lines": [ + { + "line_no": 2, + "product_id": 102, + "quantity": 20, + "unit_price": 900 + } + ] +} +``` + +- **일괄 수정:** `PATCH /stock-transactions/9001/lines` +```json +{ + "id": 9001, + "lines": [ + { + "id": 12001, + "line_no": 1, + "quantity": 60, + "note": "추가 입고" + }, + { + "id": 12002, + "line_no": 2, + "unit_price": 950 + } + ] +} +``` + +- **삭제:** `DELETE /transaction-lines/12002` +- **복구:** `POST /transaction-lines/12002/restore` + +### 4.6 고객 연결 다건 관리 +- **추가:** `POST /stock-transactions/9100/customers` +```json +{ + "id": 9100, + "customers": [ + { + "customer_id": 301, + "note": "1차 납품" + }, + { + "customer_id": 302, + "note": "2차 납품" + } + ] +} +``` + +- **수정:** `PATCH /stock-transactions/9100/customers` +```json +{ + "id": 9100, + "customers": [ + { + "id": 33001, + "note": "수량 조정" + } + ] +} +``` + +- **삭제:** `DELETE /transaction-customers/33001` + +### 4.7 상태 전이 권장 API +- `POST /stock-transactions/9001/submit` +- `POST /stock-transactions/9001/complete` + +응답은 `{ "data": { "id": 9001, "transaction_status": { ... }, "updated_at": "..." } }` 형태. + +--- + +## 5. 결재 API +리소스: `/approvals`, 보조 리소스: `/approval-steps`, `/approval-histories` + +### 5.1 결재 생성 +`POST /approvals` +```json +{ + "transaction_id": 9001, + "approval_no": "APP-2025-0001", + "approval_status_id": 1, + "requested_by_id": 7, + "note": "입고 결재" +} +``` +응답에는 `id`와 현재 단계 정보가 포함된다. + +### 5.2 목록 조회 +`GET /approvals?include=steps,histories` +```json +{ + "items": [ + { + "id": 5001, + "approval_no": "APP-2025-0001", + "transaction": { + "id": 9001, + "transaction_no": "TXN-2025-0001" + }, + "approval_status": { + "id": 1, + "status_name": "대기", + "is_blocking_next": true + }, + "current_step": null, + "requested_by": { + "id": 7, + "employee_no": "E2025001", + "employee_name": "김승인" + }, + "requested_at": "2025-09-18T06:00:00Z", + "decided_at": null, + "note": "입고 결재", + "is_active": true, + "created_at": "2025-09-18T06:00:00Z", + "updated_at": "2025-09-18T06:00:00Z", + "steps": [], + "histories": [] + } + ], + "page": 1, + "page_size": 50, + "total": 1 +} +``` + +### 5.3 단건 조회 +`GET /approvals/5001?include=steps,histories` +```json +{ + "data": { + "id": 5001, + "approval_no": "APP-2025-0001", + "transaction": { + "id": 9001, + "transaction_no": "TXN-2025-0001" + }, + "approval_status": { + "id": 1, + "status_name": "대기", + "is_blocking_next": true, + "is_terminal": false + }, + "current_step": null, + "requested_by": { + "id": 7, + "employee_no": "E2025001", + "employee_name": "김승인" + }, + "requested_at": "2025-09-18T06:00:00Z", + "decided_at": null, + "note": "입고 결재", + "steps": [ + { + "id": 7001, + "step_order": 1, + "approver": { + "id": 21, + "employee_no": "E2025002", + "employee_name": "박검토" + }, + "step_status": { + "id": 1, + "status_name": "대기", + "is_blocking_next": true + }, + "assigned_at": "2025-09-18T06:05:00Z", + "decided_at": null, + "note": null + } + ], + "histories": [], + "created_at": "2025-09-18T06:00:00Z", + "updated_at": "2025-09-18T06:05:00Z" + } +} +``` + +### 5.4 단계 구성 (배치 생성) +`POST /approvals/5001/steps` +```json +{ + "id": 5001, + "steps": [ + { + "step_order": 1, + "approver_id": 21, + "note": null + }, + { + "step_order": 2, + "approver_id": 34, + "note": "재무 확인" + } + ] +} +``` + +### 5.5 단계 일괄 수정/재배치 +`PATCH /approvals/5001/steps` +```json +{ + "id": 5001, + "steps": [ + { + "id": 7001, + "step_order": 1, + "note": "서류 확인 중" + }, + { + "id": 7002, + "step_order": 2, + "approver_id": 35 + } + ] +} +``` + +### 5.6 단계 행위 +`POST /approval-steps/7001/actions` +```json +{ + "id": 7001, + "approval_action_id": 1, + "note": "승인합니다." +} +``` +응답에는 전후 상태(`from_status`, `to_status`), 차기 단계 정보가 포함되며, `approval_histories`에 기록된다. + +### 5.7 결재 상태 확인 +`GET /approvals/5001/can-proceed` +```json +{ + "data": { + "id": 5001, + "can_proceed": true, + "reason": null + } +} +``` + +### 5.8 결재 수정·삭제·복구 +- `PATCH /approvals/5001` +```json +{ + "id": 5001, + "approval_status_id": 2, + "note": "보류 처리" +} +``` +- `DELETE /approvals/5001` +- `POST /approvals/5001/restore` + +--- + +## 6. 결재 템플릿 API +리소스: `/approval-templates` + +### 6.1 목록 조회 +`GET /approval-templates?page=1` +```json +{ + "items": [ + { + "id": 3001, + "template_code": "AP_INBOUND", + "template_name": "입고 결재 기본", + "description": "입고 결재 2단계", + "created_by": { + "id": 7, + "employee_no": "E2025001", + "employee_name": "김승인" + }, + "is_active": true, + "created_at": "2025-01-20T00:00:00Z", + "updated_at": "2025-01-25T00:00:00Z" + } + ], + "page": 1, + "page_size": 50, + "total": 1 +} +``` + +### 6.2 단건 조회 +`GET /approval-templates/3001?include=steps` +```json +{ + "data": { + "id": 3001, + "template_code": "AP_INBOUND", + "template_name": "입고 결재 기본", + "description": "입고 결재 2단계", + "created_by": { + "id": 7, + "employee_no": "E2025001", + "employee_name": "김승인" + }, + "steps": [ + { + "id": 9101, + "step_order": 1, + "approver": { + "id": 21, + "employee_no": "E2025002", + "employee_name": "박검토" + }, + "note": null + } + ], + "is_active": true, + "created_at": "2025-01-20T00:00:00Z", + "updated_at": "2025-01-25T00:00:00Z" + } +} +``` + +### 6.3 생성·수정 +- `POST /approval-templates` +```json +{ + "template_code": "AP_OUTBOUND", + "template_name": "출고 결재 기본", + "description": "출고 결재 3단계", + "created_by_id": 7, + "note": "표준 출고" +} +``` + +- `POST /approval-templates/3002/steps` +```json +{ + "id": 3002, + "steps": [ + { + "step_order": 1, + "approver_id": 34 + }, + { + "step_order": 2, + "approver_id": 55 + } + ] +} +``` + +- `PATCH /approval-templates/3002` +```json +{ + "id": 3002, + "template_name": "출고 결재 확장", + "note": "정기 출고용" +} +``` + +- `PATCH /approval-templates/3002/steps` +```json +{ + "id": 3002, + "steps": [ + { + "id": 9105, + "step_order": 1, + "approver_id": 36 + } + ] +} +``` + +- 삭제/복구: `DELETE /approval-templates/{id}`, `POST /approval-templates/{id}/restore` + +--- + +## 7. 보고서 API (선택) +- `GET /reports/transactions/export?from=2025-09-01&to=2025-09-30&type_id=2&warehouse_id=1&format=xlsx` +- `GET /reports/approvals/export?status_id=1&format=pdf` + +응답은 파일 다운로드 링크 또는 스트림. 요청 파라미터에는 대상 리소스의 PK를 포함한다. + +--- + +## 8. 구현 참고 +- FK 요약 정보는 기본 응답에 포함하며, 상세 정보가 필요하면 `include` 파라미터를 활용해 확장한다. +- 배열 기반 다건 작업은 전체를 트랜잭션 처리해야 한다. 실패 시 롤백하고 부분 처리 결과를 반환하지 않는다. +- `is_active` 변경은 권한·결재 등의 즉시성 요구를 고려하여 관련 캐시를 무효화한다. +- 결재 단계 상태 전이는 `approval_statuses.is_blocking_next` 규칙을 준수해야 하며, 반려(`is_terminal=true`) 상태 시 결재를 종료한다. diff --git a/doc/stock_approval_system_spec_v4.md b/doc/stock_approval_system_spec_v4.md new file mode 100644 index 0000000..ab51327 --- /dev/null +++ b/doc/stock_approval_system_spec_v4.md @@ -0,0 +1,677 @@ + +# 간단 입·출고 + 결재 시스템 설계서 (최종 v4) + +**버전:** 2025-09-18 16:22:30Z (UTC) +**요약:** 벤더 ↔ 창고 ↔ 고객사 간 물품 이동(입고/출고)을 관리하는 최소구성 시스템. +- 트랜잭션당 1개의 결재(1:1), **승인자 순서 기반의 순차 결재** 지원. +- **다음 승인자로 넘어가면 안 되는 상태**를 `approval_statuses.is_blocking_next`로 제어. +- 모든 테이블(타입/코드 테이블 포함)에 **공통 컬럼** 적용: `is_active`, `is_deleted`, `created_at`, `updated_at`. +- **벤더는 트랜잭션 헤더에 연결하지 않음**(벤더는 제품을 통해서만 추적). +- **customer_roles 제거**: 트랜잭션-고객은 역할 없이 다수 연결만 허용. +- 타입값은 **별도 테이블**로 분리하며 `*_code`/정렬순서 미사용, ID 기반 참조. +- 메뉴 접근은 `groups`와 `group_menu_permissions`를 통해 제어되며, 모든 직원은 정확히 하나의 그룹에 속함. + +--- + +## 0) 핵심 비즈니스 규칙 +- 제품 1개는 반드시 1개의 벤더에 소속 (`products.vendor_id` 필수). +- **트랜잭션 1건당 결재 1건**(1:1, 소프트삭제 제외). +- 결재는 **승인자 순서(`approval_steps.step_order`)대로**만 진행. +- 각 단계 상태가 **blocking**이면 다음 단계로 이동 불가. +- 트랜잭션에는 **여러 고객사**를 연결할 수 있음(역할 없음). +- 모든 직원은 **그룹**에 속하며(`employees.group_id`), 그룹-메뉴 권한(`group_menu_permissions`)으로 메뉴별 CRUD 가능 여부가 결정됨. +- 고객사는 **유형**을 `is_partner`/`is_general` 플래그로 구분하며 둘 중 하나 이상이 true여야 함(기본: 일반 true, 파트너 false). +- 반복되는 결재 라인은 **결재 템플릿**으로 저장 후 호출하여 재사용 가능. +- 모든 삭제는 **소프트 삭제**(`is_deleted=true`)이며, 삭제 시 `is_active=false`로 내림. + +--- + +## 1) 개념 ERD + +```mermaid +erDiagram +vendors ||--o{ products : supplies +uoms ||--o{ products : measured_in + +warehouses ||--o{ stock_transactions : occurs_in +transaction_types ||--o{ stock_transactions : typed_as +transaction_statuses ||--o{ stock_transactions : has_status + +stock_transactions ||--o{ transaction_lines : has +products ||--o{ transaction_lines : item + +stock_transactions ||--o{ transaction_customers : serves +customers ||--o{ transaction_customers : party + +stock_transactions ||--|| approvals : has_one +approval_statuses ||--o{ approvals : overall_status +approvals ||--o{ approval_steps : has_sequence +approval_statuses ||--o{ approval_steps : step_status +approval_steps ||--o{ approval_histories : logs +approval_actions ||--o{ approval_histories : acted_as + +approval_templates ||--o{ approval_template_steps : has_sequence + +employees ||--o{ approvals : requested_by +employees ||--o{ approval_steps : assigned_to +employees ||--o{ approval_histories : actor +employees ||--o{ stock_transactions : created_by +employees ||--o{ approval_templates : authored +employees ||--o{ approval_template_steps : template_approver +groups ||--o{ employees : members +groups ||--o{ group_menu_permissions : controls +menus ||--o{ group_menu_permissions : target +zipcodes ||--o{ warehouses : located +zipcodes ||--o{ customers : addressed +``` + +--- + +## 2) 공통 컬럼 (모든 테이블 공통 적용) +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| note | 비고 | text | - | - | N | N | N | - | +| is_active | 사용여부 | boolean | - | true | Y | N | N | - | +| is_deleted | 삭제여부(소프트) | boolean | - | false | Y | N | N | - | +| created_at | 생성일시 | timestamp | - | now() | Y | N | N | - | +| updated_at | 변경일시 | timestamp | - | now() | Y | N | N | - | + +> 모든 테이블(타입/코드 테이블 포함)에 위 4개 컬럼을 **명시적으로 포함**. +> `note`는 테이블별 메모/추가 설명을 저장하는 자유 텍스트 필드. +> `updated_at`은 UPDATE 시 자동 갱신(트리거/생성 컬럼 권장). 삭제 시 `is_deleted=true`, `is_active=false` 처리. + +--- + +## 3) 테이블 정의 + +### 3.1 `vendors` (벤더) +| 영문테이블명 | 한글테이블명 | +|---|---| +| vendors | 벤더 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 벤더ID | bigint | - | identity | Y | Y | Y | - | +| vendor_code | 벤더코드 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - | +| vendor_name | 벤더명 | varchar | 100 | - | Y | | | | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +--- + +### 3.2 `warehouses` (창고) +| 영문테이블명 | 한글테이블명 | +|---|---| +| warehouses | 창고 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 창고ID | bigint | - | identity | Y | Y | Y | - | +| warehouse_code | 창고코드 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - | +| warehouse_name | 창고명 | varchar | 100 | - | Y | | | | +| zipcode | 우편번호 | varchar | 5 | - | N | | | zipcodes.zipcode | +| address_detail | 상세주소 | varchar | 200 | - | N | | | - | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +--- + +### 3.3 `customers` (고객사) +| 영문테이블명 | 한글테이블명 | +|---|---| +| customers | 고객사 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 고객사ID | bigint | - | identity | Y | Y | Y | - | +| customer_code | 고객사코드 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - | +| customer_name | 고객사명 | varchar | 100 | - | Y | | | | +| is_partner | 파트너여부 | boolean | - | false | Y | | | - | +| is_general | 일반여부 | boolean | - | true | Y | | | - | +| email | 이메일 | varchar | 100 | - | N | | | - | +| mobile_no | 모바일번호 | varchar | 20 | - | N | | | - | +| zipcode | 우편번호 | varchar | 5 | - | N | | | zipcodes.zipcode | +| address_detail | 상세주소 | varchar | 200 | - | N | | | - | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +--- + +### 3.4 `employees` (사원) +| 영문테이블명 | 한글테이블명 | +|---|---| +| employees | 사원 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 사원ID | bigint | - | identity | Y | Y | Y | - | +| employee_no | 사번 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - | +| employee_name | 성명 | varchar | 100 | - | Y | | | | +| email | 이메일 | varchar | 100 | - | N | Y | | | +| mobile_no | 모바일번호 | varchar | 20 | - | N | | | | +| group_id | 그룹ID | bigint | - | - | Y | | | groups.id | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +--- + +### 3.5 `zipcodes` (우편번호) +| 영문테이블명 | 한글테이블명 | +|---|---| +| zipcodes | 우편번호 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| zipcode | 우편번호 | varchar | 5 | - | Y | Y | Y | - | +| sido | 시도 | varchar | 50 | - | Y | | | - | +| sido_eng | 시도영문 | varchar | 100 | - | N | | | - | +| sigungu | 시군구 | varchar | 100 | - | Y | | | - | +| sigungu_eng | 시군구영문 | varchar | 100 | - | N | | | - | +| eupmyeon | 읍면 | varchar | 100 | - | N | | | - | +| eupmyeon_eng | 읍면영문 | varchar | 100 | - | N | | | - | +| road_code | 도로명코드 | varchar | 12 | - | Y | | | - | +| road_name | 도로명 | varchar | 200 | - | Y | | | - | +| road_name_eng | 도로명영문 | varchar | 200 | - | N | | | - | +| underground_flag | 지하여부 | varchar | 1 | 'N' | Y | | | - | +| building_main_no | 건물번호본번 | integer | - | 0 | Y | | | - | +| building_sub_no | 건물번호부번 | integer | - | 0 | N | | | - | +| building_mgmt_no | 건물관리번호 | varchar | 25 | - | Y | | | - | +| bulk_receiver | 다량배달처명 | varchar | 200 | - | N | | | - | +| sigungu_building_name | 시군구용건물명 | varchar | 200 | - | N | | | - | +| legal_dong_code | 법정동코드 | varchar | 10 | - | Y | | | - | +| legal_dong_name | 법정동명 | varchar | 100 | - | Y | | | - | +| ri_name | 리명 | varchar | 100 | - | N | | | - | +| admin_dong_name | 행정동명 | varchar | 100 | - | N | | | - | +| mountain_flag | 산여부 | varchar | 1 | 'N' | Y | | | - | +| land_main_no | 지번본번 | integer | - | 0 | Y | | | - | +| town_serial_no | 읍면동일련번호 | integer | - | 0 | N | | | - | +| land_sub_no | 지번부번 | integer | - | 0 | N | | | - | +| old_zipcode | 구우편번호 | varchar | 6 | - | N | | | - | +| zipcode_serial_no | 우편번호일련번호 | integer | - | 0 | Y | | | - | +| search_text | 검색텍스트 | text | - | - | N | | | - | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +> 도로명 주소 데이터와 매핑되는 5자리 우편번호 기준. `zipcode`가 PK이며 외부 데이터 동기화를 위한 `zipcode_serial_no`를 포함. + +--- + +### 3.6 `menus` (메뉴) +| 영문테이블명 | 한글테이블명 | +|---|---| +| menus | 메뉴 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 메뉴ID | bigint | - | identity | Y | Y | Y | - | +| menu_code | 메뉴코드 | varchar | 50 | - | Y | (부분유니크: is_deleted=false) | N | - | +| menu_name | 메뉴명 | varchar | 100 | - | Y | | | | +| parent_menu_id | 상위메뉴ID | bigint | - | - | N | | | menus.id | +| route_path | 경로 | varchar | 255 | - | N | | | - | +| display_order | 표시순서 | integer | - | 0 | Y | | | - | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +> 메뉴는 계층 구조를 지원하며 `parent_menu_id`가 NULL이면 1차 메뉴로 간주. + +--- + +### 3.8 `groups` (그룹) +| 영문테이블명 | 한글테이블명 | +|---|---| +| groups | 그룹 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 그룹ID | bigint | - | identity | Y | Y | Y | - | +| group_name | 그룹명 | varchar | 100 | - | Y | (부분유니크: is_deleted=false) | N | - | +| group_description | 그룹설명 | varchar | 255 | - | N | | | - | +| is_default | 기본그룹여부 | boolean | - | false | Y | | | | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +> `group_menu_permissions`를 통해 각 그룹별 메뉴 CRUD 권한을 정의하며, 사원은 `employees.group_id`로 그룹에 연결됨. + +--- + +### 3.9 `group_menu_permissions` (그룹_메뉴_권한) +| 영문테이블명 | 한글테이블명 | +|---|---| +| group_menu_permissions | 그룹_메뉴_권한 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 메뉴그룹권한ID | bigint | - | identity | Y | Y | Y | - | +| group_id | 그룹ID | bigint | - | - | Y | (복합유니크: group_id, menu_id, is_deleted) | N | groups.id | +| menu_id | 메뉴ID | bigint | - | - | Y | (복합유니크: group_id, menu_id, is_deleted) | N | menus.id | +| can_create | 생성권한 | boolean | - | false | Y | | | - | +| can_read | 조회권한 | boolean | - | true | Y | | | - | +| can_update | 수정권한 | boolean | - | false | Y | | | - | +| can_delete | 삭제권한 | boolean | - | false | Y | | | - | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +> 각 메뉴에 대한 CRUD 권한을 그룹 단위로 정의하며, 권한 미설정 시 기본적으로 조회만 허용. + +--- + +### 3.10 `uoms` (단위) — 타입 테이블 +| 영문테이블명 | 한글테이블명 | +|---|---| +| uoms | 단위 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 단위ID | bigint | - | identity | Y | Y | Y | - | +| uom_name | 단위명 | varchar | 100 | - | Y | (선택) 부분유니크 | N | - | +| is_default | 기본여부 | boolean | - | false | Y | | | | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +> 예시 값: `EA`(기본 단위), `BOX`, `KG`, `LITER` 등. + +--- + +### 3.11 `products` (제품) +| 영문테이블명 | 한글테이블명 | +|---|---| +| products | 제품 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 제품ID | bigint | - | identity | Y | Y | Y | - | +| product_code | 제품코드 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - | +| product_name | 제품명 | varchar | 100 | - | Y | | | | +| vendor_id | 벤더ID | bigint | - | - | Y | | | vendors.id | +| uom_id | 단위ID | bigint | - | - | Y | | | uoms.id | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +--- + +### 3.12 `transaction_types` (입출고_유형) — 타입 테이블 +| 영문테이블명 | 한글테이블명 | +|---|---| +| transaction_types | 입출고_유형 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 유형ID | bigint | - | identity | Y | Y | Y | - | +| type_name | 유형명 | varchar | 100 | - | Y | (선택) 부분유니크 | N | - | +| is_default | 기본여부 | boolean | - | false | Y | | | | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +> 예시 값: `입고`(is_default=true), `출고`. + +--- + +### 3.13 `transaction_statuses` (트랜잭션_상태) — 타입 테이블 +| 영문테이블명 | 한글테이블명 | +|---|---| +| transaction_statuses | 트랜잭션_상태 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 상태ID | bigint | - | identity | Y | Y | Y | - | +| status_name | 상태명 | varchar | 100 | - | Y | (선택) 부분유니크 | N | - | +| is_default | 기본여부 | boolean | - | false | Y | | | | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +> 예시 값: `정상`(is_default), `반품`, `폐기`. + +--- + +### 3.14 `stock_transactions` (입출고_트랜잭션) +| 영문테이블명 | 한글테이블명 | +|---|---| +| stock_transactions | 입출고_트랜잭션 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 트랜잭션ID | bigint | - | identity | Y | Y | Y | - | +| transaction_no | 트랜잭션번호 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - | +| transaction_type_id | 입출고유형ID | bigint | - | - | Y | | | transaction_types.id | +| transaction_status_id | 트랜잭션상태ID | bigint | - | - | Y | | | transaction_statuses.id | +| warehouse_id | 창고ID | bigint | - | - | Y | | | warehouses.id | +| transaction_date | 처리일자 | date | - | current_date | Y | | | - | +| created_by_id | 작성자ID | bigint | - | - | N | | | employees.id | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +> 주의: **벤더ID 없음**. 벤더 정보는 라인의 `product_id`가 가리키는 `products.vendor_id`로 파생. + +--- + +### 3.15 `transaction_lines` (트랜잭션_라인) +| 영문테이블명 | 한글테이블명 | +|---|---| +| transaction_lines | 트랜잭션_라인 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 라인ID | bigint | - | identity | Y | Y | Y | - | +| transaction_id | 트랜잭션ID | bigint | - | - | Y | | | stock_transactions.id | +| line_no | 라인번호 | integer | - | 1 | Y | (복합유니크: transaction_id, line_no, is_deleted) | N | - | +| product_id | 제품ID | bigint | - | - | Y | | | products.id | +| quantity | 수량 | numeric | 20,6 | 0 | Y | | | - | +| unit_price | 단가 | numeric | 20,6 | 0 | N | | | - | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +--- + +### 3.16 `transaction_customers` (트랜잭션_고객사) +| 영문테이블명 | 한글테이블명 | +|---|---| +| transaction_customers | 트랜잭션_고객사 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 키 | bigint | - | identity | Y | Y | Y | - | +| transaction_id | 트랜잭션ID | bigint | - | - | Y | | | stock_transactions.id | +| customer_id | 고객사ID | bigint | - | - | Y | (복합유니크: transaction_id, customer_id, is_deleted) | N | customers.id | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +--- + +### 3.17 `approval_statuses` (결재_상태) — 타입 테이블 +| 영문테이블명 | 한글테이블명 | +|---|---| +| approval_statuses | 결재_상태 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 상태ID | bigint | - | identity | Y | Y | Y | - | +| status_name | 상태명 | varchar | 100 | - | Y | (선택) 부분유니크 | N | - | +| is_default | 기본여부 | boolean | - | false | Y | | | | +| is_blocking_next | 차기이동차단 | boolean | - | true | Y | | | | +| is_terminal | 종결여부 | boolean | - | false | Y | | | | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +> 예시 값: +> - `대기`(`is_default=true`, `is_blocking_next=true`, `is_terminal=false`) +> - `진행중`(`is_blocking_next=true`, `is_terminal=false`) +> - `보류`(`is_blocking_next=true`, `is_terminal=false`) +> - `승인`(`is_blocking_next=false`, `is_terminal=false`) +> - `반려`(`is_blocking_next=true`, `is_terminal=true`). + +--- + +### 3.18 `approval_actions` (결재_행위) — 타입 테이블 +| 영문테이블명 | 한글테이블명 | +|---|---| +| approval_actions | 결재_행위 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 행위ID | bigint | - | identity | Y | Y | Y | - | +| action_name | 행위명 | varchar | 100 | - | Y | (선택) 부분유니크 | N | - | +| is_default | 기본여부 | boolean | - | false | Y | | | | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +> 예시 값: `approve`(승인), `reject`(반려), `comment`(코멘트). + +--- + +### 3.19 `approvals` (결재) +| 영문테이블명 | 한글테이블명 | +|---|---| +| approvals | 결재 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 결재ID | bigint | - | identity | Y | Y | Y | - | +| transaction_id | 트랜잭션ID | bigint | - | - | Y | (부분유니크: is_deleted=false) | N | stock_transactions.id | +| approval_no | 결재번호 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - | +| approval_status_id | 전체결재상태ID | bigint | - | - | Y | | | approval_statuses.id | +| current_step_id | 현재단계ID | bigint | - | - | N | | | approval_steps.id | +| requested_by_id | 상신자ID | bigint | - | - | Y | | | employees.id | +| requested_at | 상신일시 | timestamp | - | now() | Y | | | - | +| decided_at | 최종결정일시 | timestamp | - | - | N | | | - | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +--- + +### 3.20 `approval_steps` (결재_단계) +| 영문테이블명 | 한글테이블명 | +|---|---| +| approval_steps | 결재_단계 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 단계ID | bigint | - | identity | Y | Y | Y | - | +| approval_id | 결재ID | bigint | - | - | Y | | | approvals.id | +| step_order | 단계순서 | integer | - | 1 | Y | (복합유니크: approval_id, step_order, is_deleted) | N | - | +| approver_id | 승인자ID | bigint | - | - | Y | | | employees.id | +| step_status_id | 단계상태ID | bigint | - | - | Y | | | approval_statuses.id | +| assigned_at | 배정일시 | timestamp | - | now() | Y | | | - | +| decided_at | 결정일시 | timestamp | - | - | N | | | - | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +--- + +### 3.21 `approval_histories` (결재_승인이력) +| 영문테이블명 | 한글테이블명 | +|---|---| +| approval_histories | 결재_승인이력 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 이력ID | bigint | - | identity | Y | Y | Y | - | +| approval_id | 결재ID | bigint | - | - | Y | | | approvals.id | +| approval_step_id | 결재단계ID | bigint | - | - | Y | | | approval_steps.id | +| approver_id | 승인자ID | bigint | - | - | Y | | | employees.id | +| approval_action_id | 결재행위ID | bigint | - | - | Y | | | approval_actions.id | +| from_status_id | 변경전상태ID | bigint | - | - | N | | | approval_statuses.id | +| to_status_id | 변경후상태ID | bigint | - | - | Y | | | approval_statuses.id | +| action_at | 작업일시 | timestamp | - | now() | Y | | | - | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +--- + +### 3.22 `approval_templates` (결재_템플릿) +| 영문테이블명 | 한글테이블명 | +|---|---| +| approval_templates | 결재_템플릿 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 템플릿ID | bigint | - | identity | Y | Y | Y | - | +| template_code | 템플릿코드 | varchar | 30 | - | Y | (부분유니크: is_deleted=false) | N | - | +| template_name | 템플릿명 | varchar | 100 | - | Y | | | | +| description | 설명 | varchar | 255 | - | N | | | - | +| created_by_id | 작성자ID | bigint | - | - | Y | | | employees.id | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +--- + +### 3.23 `approval_template_steps` (결재_템플릿_단계) +| 영문테이블명 | 한글테이블명 | +|---|---| +| approval_template_steps | 결재_템플릿_단계 | + +| 영문필드명 | 한글필드명 | 타입 | 길이 | 기본값 | NOT NULL | UNIQUE | PK | FK | +|---|---|---|---|---|---|---|---|---| +| id | 템플릿단계ID | bigint | - | identity | Y | Y | Y | - | +| template_id | 템플릿ID | bigint | - | - | Y | (복합유니크: template_id, step_order, is_deleted) | N | approval_templates.id | +| step_order | 단계순서 | integer | - | 1 | Y | (복합유니크: template_id, step_order, is_deleted) | N | - | +| approver_id | 승인자ID | bigint | - | - | Y | | | employees.id | +| note | 비고 | text | - | - | N | | | - | +| is_active | 사용여부 | boolean | - | true | Y | | | | +| is_deleted | 삭제여부 | boolean | - | false | Y | | | | +| created_at | 생성일시 | timestamp | - | now() | Y | | | | +| updated_at | 변경일시 | timestamp | - | now() | Y | | | | + +> 템플릿 단계는 실제 결재 단계 생성 시 그대로 복제되며, `step_order` 순서대로 승인자가 배치됨. + +--- + +## 4) FK 관계 (source → target) +- `menus.parent_menu_id` → `menus.id` +- `employees.group_id` → `groups.id` +- `group_menu_permissions.group_id` → `groups.id` +- `group_menu_permissions.menu_id` → `menus.id` +- `warehouses.zipcode` → `zipcodes.zipcode` +- `customers.zipcode` → `zipcodes.zipcode` +- `products.vendor_id` → `vendors.id` +- `products.uom_id` → `uoms.id` +- `stock_transactions.warehouse_id` → `warehouses.id` +- `stock_transactions.created_by_id` → `employees.id` +- `stock_transactions.transaction_type_id` → `transaction_types.id` +- `stock_transactions.transaction_status_id` → `transaction_statuses.id` +- `transaction_lines.transaction_id` → `stock_transactions.id` +- `transaction_lines.product_id` → `products.id` +- `transaction_customers.transaction_id` → `stock_transactions.id` +- `transaction_customers.customer_id` → `customers.id` +- `approvals.transaction_id` → `stock_transactions.id` +- `approvals.approval_status_id` → `approval_statuses.id` +- `approvals.current_step_id` → `approval_steps.id` +- `approvals.requested_by_id` → `employees.id` +- `approval_steps.approval_id` → `approvals.id` +- `approval_steps.approver_id` → `employees.id` +- `approval_steps.step_status_id` → `approval_statuses.id` +- `approval_histories.approval_id` → `approvals.id` +- `approval_histories.approval_step_id` → `approval_steps.id` +- `approval_histories.approver_id` → `employees.id` +- `approval_histories.approval_action_id` → `approval_actions.id` +- `approval_histories.from_status_id` → `approval_statuses.id` +- `approval_histories.to_status_id` → `approval_statuses.id` +- `approval_templates.created_by_id` → `employees.id` +- `approval_template_steps.template_id` → `approval_templates.id` +- `approval_template_steps.approver_id` → `employees.id` + +--- + +## 5) 비즈니스/검증 규칙 +- 제품 등록 시 `vendor_id` **필수**. +- 입고(`transaction_type_id`=입고) 트랜잭션의 공급자 정보는 **라인 제품의 벤더**로만 해석. (헤더에 벤더 금지) +- 출고 트랜잭션은 `transaction_customers` **최소 1건** 필요. +- 결재는 **트랜잭션당 1건**(미삭제 기준)만 허용. +- 단계 전이는 **현재 단계**에서만 수행 가능. blocking 상태에서는 차기 이동 불가. +- 수량/단가 음수 금지(CHECK). +- 그룹이 비활성(`is_active=false`) 또는 삭제되면 해당 그룹 권한/구성원은 즉시 무효 처리. +- 사원의 소속 그룹(`employees.group_id`)에서 해당 메뉴에 대한 `can_create|can_update|can_delete` 중 하나라도 true이면 그 동작을 수행할 수 있음. + +--- + +## 6) 인덱스/유니크 권장 +- 부분 유니크(또는 복합 유니크)로 소프트 삭제와 공존: + - `vendors(vendor_code)`, `warehouses(warehouse_code)`, `customers(customer_code)`, `employees(employee_no)`, `menus(menu_code)`, `groups(group_name)`, `zipcodes(zipcode)`, `products(product_code)`, `stock_transactions(transaction_no)`, `approvals(approval_no)` + - `group_menu_permissions(group_id, menu_id, is_deleted)` + - `approvals(transaction_id)` — 미삭제 조건에서 1:1 보장 + - `transaction_lines(transaction_id, line_no, is_deleted)` + - `transaction_customers(transaction_id, customer_id, is_deleted)` +- FK 및 조회 인덱스: 모든 `*_id`, `updated_at`, `is_deleted`, `is_active`. + +--- + +## 7) 에러 규격(예시) +- `400 BAD_REQUEST` — 필수 필드 누락, 형식 오류 +- `409 CONFLICT` — 유니크 충돌(코드/번호/조합), **현재 단계 아님** +- `422 UNPROCESSABLE_ENTITY` — 비즈니스 규칙 위반(출고인데 고객 없음, blocking 상태에서 이동 등) +- `404 NOT_FOUND` — 리소스 없음 또는 삭제됨(`deleted=false` 기본 필터로 미노출) + +--- + +## 8) 마이그레이션 가이드(요약) +1) `stock_transactions`에서 `vendor_id` 드롭. +2) `customer_roles` 테이블 및 관련 컬럼 드롭. +3) 모든 타입/코드 테이블에 공통 컬럼 4종 추가(미존재 시). +4) 부분 유니크 인덱스(`WHERE is_deleted=false`) 또는 `(컬럼, is_deleted)` 복합 유니크 구성. +5) 기존 결재 이력은 `approval_step_id` 매핑(없으면 1단계로 귀속). +6) `approval_statuses`에 `is_blocking_next`, `is_terminal` 값 시드. +7) `menus`, `groups`, `group_menu_permissions` 신규 생성 및 기존 관리자 권한/사원-그룹 매핑을 `employees.group_id`로 이관. +8) `zipcodes` 테이블 생성 및 도로명 주소 기준 데이터 적재. +9) 모든 테이블에 `note`(text) 컬럼 추가 및 필요한 경우 기본값 NULL 유지. + +--- + +## 9) 초기 시드 값(예시) +- `transaction_types`: [입고, 출고] (`is_default`: 입고) +- `transaction_statuses`: [초안, 상신, 승인, 반려, 완료] (`is_default`: 초안) +- `approval_statuses`: [대기(pending, default, blocking), 진행중(in_progress, blocking), 보류(on_hold, blocking), 승인(approved, !blocking), 반려(rejected, blocking+terminal)] +- `approval_actions`: [승인(approve), 반려(reject), 코멘트(comment)] +- `uoms`: [EA(기본), BOX, KG ...] +- `menus`: [대시보드, 입출고 관리, 결재 관리, 레포트 등] — 상위/하위 메뉴 구조 포함 +- `groups`: [전사 관리자(기본), 창고 관리자, 결재 담당자] +- `group_menu_permissions`: 기본 그룹별 메뉴 권한(CRUD 플래그); 전사 관리자는 모든 메뉴 `can_*`=true, 역할별로 세분화 설정 +- `zipcodes`: 행정안전부 도로명 주소 DB(5자리) 최신본을 기준으로 일괄 적재 + +--- + +## 10) 구현 팁 +- `updated_at` 자동 갱신 트리거, 소프트 삭제 처리 트리거 권장. +- 낙관적 잠금(선택): `version`(int) + ETag. +- 병렬 결재 확장(선택): `approval_steps`에 `group_no`, `approval_mode(all|any)` 도입. diff --git a/doc/입출고 대여 폼 정리.md b/doc/입출고 대여 폼 정리.md new file mode 100644 index 0000000..effd29d --- /dev/null +++ b/doc/입출고 대여 폼 정리.md @@ -0,0 +1,225 @@ +flutter 사용 +[flutter shadCnUI](https://github.com/nank1ro/flutter-shadcn-ui) 라이브러리 사용. +반응형웹서비스. +API연결없이 화면 구성만. 단 네비게이션은 작동되어야 함. +프론트엔드 화면만 구현. +git 저장소 없음 + +# 입·출고 + 결재 시스템 전체 UI 분석 & 와이어프레임 (v2) + +## 0. 로그인/로그아웃 +### 입력 폼 +- 아이디(사번 또는 이메일) [TXT] +- 비밀번호 [PWD] + +### 수정 폼 +- 없음 (세션 기반) + +### 테이블 리스트 +- 없음 + +--- + +## 1. 대시보드 +### 주요 위젯 +- 오늘 입고/출고 건수, 대기 결재 수 +- 최근 트랜잭션 리스트 (번호, 일자, 유형, 상태, 작성자) +- 내 결재 요청/대기 건 + +--- + +## 2. 입고 +### 입력 폼 +- 처리일자[DT], 창고[DD], 상태[DD], 작성자[RO], 비고[TXT] +- 라인: 제품[DD], 제조사[RO], 단위[RO], 수량[NUM], 단가[NUM], 비고[TXT] + +### 수정 폼 +- 작성자[RO], 트랜잭션번호[RO], 상태[일부 제한] + +### 테이블 리스트 (1행) +번호 | 처리일자 | 창고 | 트랜잭션번호 | 상태 | 작성자 | 품목수 | 총수량 | 비고 + +--- + +## 3. 출고 +### 입력 폼 +- 처리일자[DT], 창고[DD], 상태[DD], 작성자[RO], 비고[TXT] +- 라인: 제품[DD], 제조사[RO], 단위[RO], 수량[NUM], 단가[NUM], 비고[TXT] +- 고객사 연결: 고객사[DD-멀티], 비고[TXT] + +### 수정 폼 +- 작성자[RO], 트랜잭션번호[RO], 고객사[수정 가능], 상태[제한] + +### 테이블 리스트 (1행) +번호 | 처리일자 | 창고 | 트랜잭션번호 | 상태 | 작성자 | 고객수 | 품목수 | 총수량 | 비고 + +--- + +## 4. 대여 +### 입력 폼 +- 처리일자[DT], 창고[DD], 상태[DD], 작성자[RO], 대여구분[DD], 반납예정일[DT], 비고[TXT] +- 라인: 제품[DD], 제조사[RO], 단위[RO], 수량[NUM], 단가[NUM], 비고[TXT] +- 고객사 연결: 고객사[DD-멀티], 비고[TXT] + +### 수정 폼 +- 작성자[RO], 트랜잭션번호[RO], 대여구분[제한], 반납예정일[수정가능] + +### 테이블 리스트 (1행) +번호 | 처리일자 | 창고 | 대여/반납 | 트랜잭션번호 | 상태 | 반납예정일 | 고객수 | 품목수 | 비고 + +--- + +## 5. 제조사 관리 (벤더) +### 입력 폼 +- 벤더코드[TXT], 벤더명[TXT], 사용여부[SW], 비고[TXT] + +### 수정 폼 +- 벤더코드[RO], 생성일시[RO], 수정일시[RO] + +### 테이블 리스트 (1행) +번호 | 벤더코드 | 벤더명 | 사용여부 | 비고 | 변경일시 + +--- + +## 6. 장비 모델 관리 (제품) +### 입력 폼 +- 제품코드[TXT], 제품명[TXT], 제조사[DD], 단위[DD], 사용여부[SW], 비고[TXT] + +### 수정 폼 +- 제품코드[RO], 생성일시[RO] + +### 테이블 리스트 (1행) +번호 | 제품코드 | 제품명 | 제조사 | 단위 | 사용여부 | 비고 | 변경일시 + +--- + +## 7. 입고지 관리 (창고) +### 입력 폼 +- 창고코드[TXT], 창고명[TXT], 우편번호[검색], 상세주소[TXT], 사용여부[SW], 비고[TXT] + +### 수정 폼 +- 창고코드[RO], 생성일시[RO] + +### 테이블 리스트 (1행) +번호 | 창고코드 | 창고명 | 우편번호 | 상세주소 | 사용여부 | 비고 | 변경일시 + +--- + +## 8. 회사 관리 (고객사) +### 입력 폼 +- 고객사코드[TXT], 고객사명[TXT], 유형(파트너/일반)[DD], 이메일[TXT], 연락처[TXT], 우편번호[검색], 상세주소[TXT], 사용여부[SW], 비고[TXT] + +### 수정 폼 +- 고객사코드[RO], 생성일시[RO] + +### 테이블 리스트 (1행) +번호 | 고객사코드 | 고객사명 | 유형 | 이메일 | 연락처 | 우편번호 | 상세주소 | 사용여부 | 비고 + +--- + +## 9. 사용자 관리 (사원) +### 입력 폼 +- 사번[TXT], 성명[TXT], 이메일[TXT], 연락처[TXT], 그룹[DD], 사용여부[SW], 비고[TXT] + +### 수정 폼 +- 사번[RO], 생성일시[RO] + +### 테이블 리스트 (1행) +번호 | 사번 | 성명 | 이메일 | 연락처 | 그룹 | 사용여부 | 비고 | 변경일시 + +--- + +## 10. 그룹 관리 +### 입력 폼 +- 그룹명[TXT], 그룹설명[TXT], 기본여부[SW], 사용여부[SW], 비고[TXT] + +### 수정 폼 +- 그룹명[RO], 생성일시[RO] + +### 테이블 리스트 (1행) +번호 | 그룹명 | 설명 | 기본여부 | 사용여부 | 비고 | 변경일시 + +--- + +## 11. 메뉴 관리 +### 입력 폼 +- 메뉴코드[TXT], 메뉴명[TXT], 상위메뉴[DD], 경로[TXT], 표시순서[NUM], 사용여부[SW], 비고[TXT] + +### 수정 폼 +- 메뉴코드[RO], 생성일시[RO] + +### 테이블 리스트 (1행) +번호 | 메뉴코드 | 메뉴명 | 상위메뉴 | 경로 | 사용여부 | 비고 | 변경일시 + +--- + +## 12. 그룹 메뉴 권한 관리 +### 입력 폼 +- 그룹[DD], 메뉴[DD], 생성권한[CHK], 조회권한[CHK], 수정권한[CHK], 삭제권한[CHK], 사용여부[SW] + +### 수정 폼 +- 그룹[RO], 메뉴[RO] + +### 테이블 리스트 (1행) +번호 | 그룹명 | 메뉴명 | 생성 | 조회 | 수정 | 삭제 | 사용여부 | 변경일시 + +--- + +## 13. 결재 관리 +### 입력 폼 +- 트랜잭션번호[DD], 결재번호[자동생성], 결재상태[DD], 상신자[자동], 비고[TXT] + +### 수정 폼 +- 결재번호[RO], 상신자[RO], 요청일시[RO] + +### 테이블 리스트 (1행) +번호 | 결재번호 | 트랜잭션번호 | 상태 | 상신자 | 요청일시 | 최종결정일시 | 비고 + +--- + +## 14. 결재 단계 관리 +### 입력 폼 +- 결재ID[DD], 단계순서[NUM], 승인자[DD], 단계상태[DD], 비고[TXT] + +### 수정 폼 +- 결재ID[RO], 단계순서[RO] + +### 테이블 리스트 (1행) +번호 | 결재ID | 단계순서 | 승인자 | 상태 | 배정일시 | 결정일시 | 비고 + +--- + +## 15. 결재 이력 조회 +- 테이블 전용 (수정 없음) +- 컬럼: 번호 | 결재ID | 단계ID | 승인자 | 행위 | 변경전상태 | 변경후상태 | 작업일시 | 비고 + +--- + +## 16. 결재 템플릿 관리 +### 입력 폼 +- 템플릿코드[TXT], 템플릿명[TXT], 설명[TXT], 작성자[RO], 사용여부[SW], 비고[TXT] +- 단계: (+추가) 순서[NUM], 승인자[DD] + +### 수정 폼 +- 템플릿코드[RO], 작성자[RO] + +### 테이블 리스트 (1행) +번호 | 템플릿코드 | 템플릿명 | 설명 | 작성자 | 사용여부 | 변경일시 + +--- + +## 17. 우편번호 관리 (검색용) +- 모달 전용: 검색어[TXT], 결과 리스트 (우편번호 | 시도 | 시군구 | 도로명 | 건물번호) + +--- + +## 18. 보고서 +### 화면 +- 조건 입력: 기간[DT-기간], 유형[DD], 창고[DD], 상태[DD] +- 출력: [BTN: XLSX 다운로드], [BTN: PDF 다운로드] + +--- + +# ✅ 최종 요약 +- 로그인 → 대시보드 → 입고/출고/대여 → 마스터 관리(벤더, 제품, 창고, 고객사, 사용자, 그룹, 메뉴, 권한) → 결재 관리(결재, 단계, 이력, 템플릿) → 보고서 → 로그아웃까지 전부 포함. +- 각 페이지마다 입력폼, 수정폼, 테이블리스트 1행 예시를 일관되게 정리함. diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..fc1b454 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.superportV2; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.superportV2.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.superportV2.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.superportV2.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.superportV2; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.superportV2; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..ccfad1e --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Superport V2 + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + superport_v2 + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/core/config/environment.dart b/lib/core/config/environment.dart new file mode 100644 index 0000000..a5b64a9 --- /dev/null +++ b/lib/core/config/environment.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +/// 환경 설정 로더 +/// +/// - .env.development / .env.production 파일을 로드하여 런타임 설정을 주입한다. +/// - `--dart-define=ENV=production` 형태로 빌드/실행 시 환경을 지정한다. +/// - 주요 키: `API_BASE_URL`, `FEATURE_*` 플래그들. +class Environment { + Environment._(); + + /// 현재 환경명 (development | production) + static late final String envName; + + /// API 서버 베이스 URL + static late final String baseUrl; + + /// 프로덕션 여부 + static late final bool isProduction; + + /// 환경 초기화 + /// + /// - 기본 환경은 development이며, `ENV` dart-define 으로 변경 가능 + /// - 해당 환경의 .env 파일을 로드하고 핵심 값을 추출한다. + static Future initialize() async { + const envFromDefine = String.fromEnvironment('ENV', defaultValue: 'development'); + envName = envFromDefine.toLowerCase(); + isProduction = envName == 'production'; + + final fileName = '.env.$envName'; + try { + await dotenv.load(fileName: fileName); + } catch (e) { + if (kDebugMode) { + // 개발 편의를 위해 파일 미존재 시 경고만 출력하고 진행 + // 실제 배포에서는 파일 존재가 필수다. + // ignore: avoid_print + print('[Environment] $fileName 로드 실패: $e'); + } + } + + baseUrl = dotenv.maybeGet('API_BASE_URL') ?? 'http://localhost:8080'; + } + + /// 기능 플래그 조회 (기본 false) + static bool flag(String key, {bool defaultValue = false}) { + final v = dotenv.maybeGet(key); + if (v == null) return defaultValue; + switch (v.trim().toLowerCase()) { + case '1': + case 'y': + case 'yes': + case 'true': + return true; + case '0': + case 'n': + case 'no': + case 'false': + return false; + default: + return defaultValue; + } + } +} + diff --git a/lib/core/constants/app_sections.dart b/lib/core/constants/app_sections.dart new file mode 100644 index 0000000..f948dec --- /dev/null +++ b/lib/core/constants/app_sections.dart @@ -0,0 +1,171 @@ +import 'package:flutter/widgets.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +class AppPageDescriptor { + const AppPageDescriptor({ + required this.path, + required this.label, + required this.icon, + required this.summary, + }); + + final String path; + final String label; + final IconData icon; + final String summary; +} + +class AppSectionDescriptor { + const AppSectionDescriptor({required this.label, required this.pages}); + + final String label; + final List pages; +} + +const loginRoutePath = '/login'; +const dashboardRoutePath = '/dashboard'; + +const appSections = [ + AppSectionDescriptor( + label: '대시보드', + pages: [ + AppPageDescriptor( + path: dashboardRoutePath, + label: '대시보드', + icon: LucideIcons.layoutDashboard, + summary: '오늘 입고/출고, 결재 대기, 최근 트랜잭션을 한 화면에서 확인합니다.', + ), + ], + ), + AppSectionDescriptor( + label: '입·출고', + pages: [ + AppPageDescriptor( + path: '/inventory/inbound', + label: '입고', + icon: LucideIcons.packagePlus, + summary: '입고 처리 기본정보와 라인 품목을 등록하고 검토합니다.', + ), + AppPageDescriptor( + path: '/inventory/outbound', + label: '출고', + icon: LucideIcons.packageMinus, + summary: '출고 품목, 고객사 연결, 상태 변경을 관리합니다.', + ), + AppPageDescriptor( + path: '/inventory/rental', + label: '대여', + icon: LucideIcons.handshake, + summary: '대여/반납 구분과 반납예정일을 포함한 대여 흐름입니다.', + ), + ], + ), + AppSectionDescriptor( + label: '마스터', + pages: [ + AppPageDescriptor( + path: '/masters/vendors', + label: '제조사 관리', + icon: LucideIcons.factory, + summary: '벤더코드, 명칭, 사용여부 등을 유지합니다.', + ), + AppPageDescriptor( + path: '/masters/products', + label: '장비 모델 관리', + icon: LucideIcons.box, + summary: '제품코드, 제조사, 단위 정보를 관리합니다.', + ), + AppPageDescriptor( + path: '/masters/warehouses', + label: '입고지 관리', + icon: LucideIcons.warehouse, + summary: '창고 주소와 사용여부를 설정합니다.', + ), + AppPageDescriptor( + path: '/masters/customers', + label: '회사 관리', + icon: LucideIcons.building, + summary: '고객사 연락처와 주소 정보를 관리합니다.', + ), + AppPageDescriptor( + path: '/masters/users', + label: '사용자 관리', + icon: LucideIcons.users, + summary: '사번, 그룹, 사용여부를 관리합니다.', + ), + AppPageDescriptor( + path: '/masters/groups', + label: '그룹 관리', + icon: LucideIcons.layers, + summary: '권한 그룹과 설명, 기본여부를 정의합니다.', + ), + AppPageDescriptor( + path: '/masters/menus', + label: '메뉴 관리', + icon: LucideIcons.listTree, + summary: '메뉴 계층과 경로, 노출 순서를 구성합니다.', + ), + AppPageDescriptor( + path: '/masters/group-permissions', + label: '그룹 메뉴 권한', + icon: LucideIcons.shieldCheck, + summary: '그룹별 메뉴 CRUD 권한을 설정합니다.', + ), + ], + ), + AppSectionDescriptor( + label: '결재', + pages: [ + AppPageDescriptor( + path: '/approvals/requests', + label: '결재 관리', + icon: LucideIcons.fileCheck, + summary: '결재 번호, 상태, 상신자를 관리합니다.', + ), + AppPageDescriptor( + path: '/approvals/steps', + label: '결재 단계', + icon: LucideIcons.workflow, + summary: '단계 순서와 승인자 할당을 설정합니다.', + ), + AppPageDescriptor( + path: '/approvals/history', + label: '결재 이력', + icon: LucideIcons.history, + summary: '결재 단계별 변경 이력을 조회합니다.', + ), + AppPageDescriptor( + path: '/approvals/templates', + label: '결재 템플릿', + icon: LucideIcons.fileSpreadsheet, + summary: '반복되는 결재 흐름을 템플릿으로 관리합니다.', + ), + ], + ), + AppSectionDescriptor( + label: '도구', + pages: [ + AppPageDescriptor( + path: '/utilities/postal-search', + label: '우편번호 검색', + icon: LucideIcons.search, + summary: '모달 기반 우편번호 검색 도구입니다.', + ), + ], + ), + AppSectionDescriptor( + label: '보고', + pages: [ + AppPageDescriptor( + path: '/reports', + label: '보고서', + icon: LucideIcons.fileDown, + summary: '조건 필터와 PDF/XLSX 다운로드 기능입니다.', + ), + ], + ), +]; + +List get allAppPages => [ + for (final section in appSections) ...section.pages, +]; diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart new file mode 100644 index 0000000..f25f2d5 --- /dev/null +++ b/lib/core/network/api_client.dart @@ -0,0 +1,60 @@ +// ignore_for_file: public_member_api_docs + +import 'package:dio/dio.dart'; + +/// 공통 API 클라이언트 (Dio 래퍼) +/// - 모든 HTTP 호출은 이 클래스를 통해 이루어진다. +/// - BaseURL/타임아웃/인증/로깅/에러 처리 등을 중앙집중화한다. +class ApiClient { + final Dio _dio; + + /// 내부에서 사용하는 Dio 인스턴스 + /// 외부에서 Dio 직접 사용을 최소화하고, 가능하면 아래 헬퍼 메서드를 사용한다. + Dio get dio => _dio; + + ApiClient({required Dio dio}) : _dio = dio; + + /// GET 요청 헬퍼 + Future> get( + String path, { + Map? query, + Options? options, + CancelToken? cancelToken, + }) { + return _dio.get(path, queryParameters: query, options: options, cancelToken: cancelToken); + } + + /// POST 요청 헬퍼 + Future> post( + String path, { + dynamic data, + Map? query, + Options? options, + CancelToken? cancelToken, + }) { + return _dio.post(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken); + } + + /// PATCH 요청 헬퍼 + Future> patch( + String path, { + dynamic data, + Map? query, + Options? options, + CancelToken? cancelToken, + }) { + return _dio.patch(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken); + } + + /// DELETE 요청 헬퍼 + Future> delete( + String path, { + dynamic data, + Map? query, + Options? options, + CancelToken? cancelToken, + }) { + return _dio.delete(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken); + } +} + diff --git a/lib/core/network/interceptors/auth_interceptor.dart b/lib/core/network/interceptors/auth_interceptor.dart new file mode 100644 index 0000000..14f2730 --- /dev/null +++ b/lib/core/network/interceptors/auth_interceptor.dart @@ -0,0 +1,29 @@ +// ignore_for_file: public_member_api_docs + +import 'package:dio/dio.dart'; + +/// 인증 인터셉터(스켈레톤) +/// - 요청 전에 Authorization 헤더 주입 +/// - 401 수신 시 토큰 갱신 및 원요청 1회 재시도 (구현 예정) +class AuthInterceptor extends Interceptor { + /// TODO: 토큰 저장/조회 서비스 주입 (예: AuthRepository) + AuthInterceptor(); + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { + // TODO: 저장된 토큰을 읽어 Authorization 헤더에 주입한다. + // final token = await _authRepository.getToken(); + // if (token != null && token.isNotEmpty) { + // options.headers['Authorization'] = 'Bearer $token'; + // } + handler.next(options); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + // TODO: 401 처리 로직(토큰 갱신 → 원요청 재시도) 구현 + // if (err.response?.statusCode == 401) { ... } + handler.next(err); + } +} + diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart new file mode 100644 index 0000000..9c0e850 --- /dev/null +++ b/lib/core/routing/app_router.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../features/approvals/history/presentation/pages/approval_history_page.dart'; +import '../../features/approvals/request/presentation/pages/approval_request_page.dart'; +import '../../features/approvals/step/presentation/pages/approval_step_page.dart'; +import '../../features/approvals/template/presentation/pages/approval_template_page.dart'; +import '../../features/dashboard/presentation/pages/dashboard_page.dart'; +import '../../features/inventory/inbound/presentation/pages/inbound_page.dart'; +import '../../features/inventory/outbound/presentation/pages/outbound_page.dart'; +import '../../features/inventory/rental/presentation/pages/rental_page.dart'; +import '../../features/login/presentation/pages/login_page.dart'; +import '../../features/masters/customer/presentation/pages/customer_page.dart'; +import '../../features/masters/group/presentation/pages/group_page.dart'; +import '../../features/masters/group_permission/presentation/pages/group_permission_page.dart'; +import '../../features/masters/menu/presentation/pages/menu_page.dart'; +import '../../features/masters/product/presentation/pages/product_page.dart'; +import '../../features/masters/user/presentation/pages/user_page.dart'; +import '../../features/masters/vendor/presentation/pages/vendor_page.dart'; +import '../../features/masters/warehouse/presentation/pages/warehouse_page.dart'; +import '../../features/reporting/presentation/pages/reporting_page.dart'; +import '../../features/util/postal_search/presentation/pages/postal_search_page.dart'; +import '../../widgets/app_shell.dart'; +import '../constants/app_sections.dart'; + +final _rootNavigatorKey = GlobalKey(debugLabel: 'root'); + +final appRouter = GoRouter( + navigatorKey: _rootNavigatorKey, + initialLocation: loginRoutePath, + routes: [ + GoRoute(path: '/', redirect: (_, __) => loginRoutePath), + GoRoute( + path: loginRoutePath, + name: 'login', + builder: (context, state) => const LoginPage(), + ), + ShellRoute( + builder: (context, state, child) => + AppShell(currentLocation: state.uri.toString(), child: child), + routes: [ + GoRoute( + path: dashboardRoutePath, + name: 'dashboard', + builder: (context, state) => const DashboardPage(), + ), + GoRoute( + path: '/inventory/inbound', + name: 'inventory-inbound', + builder: (context, state) => const InboundPage(), + ), + GoRoute( + path: '/inventory/outbound', + name: 'inventory-outbound', + builder: (context, state) => const OutboundPage(), + ), + GoRoute( + path: '/inventory/rental', + name: 'inventory-rental', + builder: (context, state) => const RentalPage(), + ), + GoRoute( + path: '/masters/vendors', + name: 'masters-vendors', + builder: (context, state) => const VendorPage(), + ), + GoRoute( + path: '/masters/products', + name: 'masters-products', + builder: (context, state) => const ProductPage(), + ), + GoRoute( + path: '/masters/warehouses', + name: 'masters-warehouses', + builder: (context, state) => const WarehousePage(), + ), + GoRoute( + path: '/masters/customers', + name: 'masters-customers', + builder: (context, state) => const CustomerPage(), + ), + GoRoute( + path: '/masters/users', + name: 'masters-users', + builder: (context, state) => const UserPage(), + ), + GoRoute( + path: '/masters/groups', + name: 'masters-groups', + builder: (context, state) => const GroupPage(), + ), + GoRoute( + path: '/masters/menus', + name: 'masters-menus', + builder: (context, state) => const MenuPage(), + ), + GoRoute( + path: '/masters/group-permissions', + name: 'masters-group-permissions', + builder: (context, state) => const GroupPermissionPage(), + ), + GoRoute( + path: '/approvals/requests', + name: 'approvals-requests', + builder: (context, state) => const ApprovalRequestPage(), + ), + GoRoute( + path: '/approvals/steps', + name: 'approvals-steps', + builder: (context, state) => const ApprovalStepPage(), + ), + GoRoute( + path: '/approvals/history', + name: 'approvals-history', + builder: (context, state) => const ApprovalHistoryPage(), + ), + GoRoute( + path: '/approvals/templates', + name: 'approvals-templates', + builder: (context, state) => const ApprovalTemplatePage(), + ), + GoRoute( + path: '/utilities/postal-search', + name: 'utilities-postal-search', + builder: (context, state) => const PostalSearchPage(), + ), + GoRoute( + path: '/reports', + name: 'reports', + builder: (context, state) => const ReportingPage(), + ), + ], + ), + ], +); diff --git a/lib/features/approvals/history/presentation/pages/approval_history_page.dart b/lib/features/approvals/history/presentation/pages/approval_history_page.dart new file mode 100644 index 0000000..764405d --- /dev/null +++ b/lib/features/approvals/history/presentation/pages/approval_history_page.dart @@ -0,0 +1,47 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../../widgets/spec_page.dart'; + +class ApprovalHistoryPage extends StatelessWidget { + const ApprovalHistoryPage({super.key}); + + @override + Widget build(BuildContext context) { + return const SpecPage( + title: '결재 이력 조회', + summary: '결재 단계별 변경 이력을 조회합니다.', + sections: [ + SpecSection( + title: '조회 테이블', + description: '수정 없이 이력 리스트만 제공.', + table: SpecTable( + columns: [ + '번호', + '결재ID', + '단계ID', + '승인자', + '행위', + '변경전상태', + '변경후상태', + '작업일시', + '비고', + ], + rows: [ + [ + '1', + 'APP-20240301-001', + 'STEP-1', + '최관리', + '승인', + '승인대기', + '승인완료', + '2024-03-01 10:30', + '-', + ], + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/approvals/request/presentation/pages/approval_request_page.dart b/lib/features/approvals/request/presentation/pages/approval_request_page.dart new file mode 100644 index 0000000..2e4ace9 --- /dev/null +++ b/lib/features/approvals/request/presentation/pages/approval_request_page.dart @@ -0,0 +1,59 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../../widgets/spec_page.dart'; + +class ApprovalRequestPage extends StatelessWidget { + const ApprovalRequestPage({super.key}); + + @override + Widget build(BuildContext context) { + return const SpecPage( + title: '결재 관리', + summary: '결재 번호와 상태, 상신자를 확인하고 결재 플로우를 제어합니다.', + sections: [ + SpecSection( + title: '입력 폼', + items: [ + '트랜잭션번호 [Dropdown]', + '결재번호 [자동생성]', + '결재상태 [Dropdown]', + '상신자 [자동]', + '비고 [Text]', + ], + ), + SpecSection( + title: '수정 폼', + items: ['결재번호 [ReadOnly]', '상신자 [ReadOnly]', '요청일시 [ReadOnly]'], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: [ + '번호', + '결재번호', + '트랜잭션번호', + '상태', + '상신자', + '요청일시', + '최종결정일시', + '비고', + ], + rows: [ + [ + '1', + 'APP-20240301-001', + 'IN-20240301-001', + '승인대기', + '홍길동', + '2024-03-01 09:00', + '-', + '-', + ], + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/approvals/step/presentation/pages/approval_step_page.dart b/lib/features/approvals/step/presentation/pages/approval_step_page.dart new file mode 100644 index 0000000..461e6d3 --- /dev/null +++ b/lib/features/approvals/step/presentation/pages/approval_step_page.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../../widgets/spec_page.dart'; + +class ApprovalStepPage extends StatelessWidget { + const ApprovalStepPage({super.key}); + + @override + Widget build(BuildContext context) { + return const SpecPage( + title: '결재 단계 관리', + summary: '결재 단계 순서와 승인자를 구성합니다.', + sections: [ + SpecSection( + title: '입력 폼', + items: [ + '결재ID [Dropdown]', + '단계순서 [Number]', + '승인자 [Dropdown]', + '단계상태 [Dropdown]', + '비고 [Text]', + ], + ), + SpecSection( + title: '수정 폼', + items: ['결재ID [ReadOnly]', '단계순서 [ReadOnly]'], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: ['번호', '결재ID', '단계순서', '승인자', '상태', '배정일시', '결정일시', '비고'], + rows: [ + [ + '1', + 'APP-20240301-001', + '1', + '최관리', + '승인대기', + '2024-03-01 09:00', + '-', + '-', + ], + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/approvals/template/presentation/pages/approval_template_page.dart b/lib/features/approvals/template/presentation/pages/approval_template_page.dart new file mode 100644 index 0000000..3195c7f --- /dev/null +++ b/lib/features/approvals/template/presentation/pages/approval_template_page.dart @@ -0,0 +1,51 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../../widgets/spec_page.dart'; + +class ApprovalTemplatePage extends StatelessWidget { + const ApprovalTemplatePage({super.key}); + + @override + Widget build(BuildContext context) { + return const SpecPage( + title: '결재 템플릿 관리', + summary: '반복적인 결재 흐름을 템플릿으로 정의합니다.', + sections: [ + SpecSection( + title: '입력 폼', + items: [ + '템플릿코드 [Text]', + '템플릿명 [Text]', + '설명 [Text]', + '작성자 [ReadOnly]', + '사용여부 [Switch]', + '비고 [Text]', + '단계 추가: 순서 [Number], 승인자 [Dropdown]', + ], + ), + SpecSection( + title: '수정 폼', + items: ['템플릿코드 [ReadOnly]', '작성자 [ReadOnly]'], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: ['번호', '템플릿코드', '템플릿명', '설명', '작성자', '사용여부', '변경일시'], + rows: [ + [ + '1', + 'TEMP-001', + '입고 기본 결재', + '입고 처리 2단계 결재', + '홍길동', + 'Y', + '2024-03-01 10:00', + ], + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/dashboard/presentation/pages/dashboard_page.dart b/lib/features/dashboard/presentation/pages/dashboard_page.dart new file mode 100644 index 0000000..775bff3 --- /dev/null +++ b/lib/features/dashboard/presentation/pages/dashboard_page.dart @@ -0,0 +1,25 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../widgets/spec_page.dart'; + +class DashboardPage extends StatelessWidget { + const DashboardPage({super.key}); + + @override + Widget build(BuildContext context) { + return const SpecPage( + title: '대시보드', + summary: '오늘 입고/출고, 결재 대기, 최근 트랜잭션을 한 눈에 볼 수 있는 메인 화면 구성.', + sections: [ + SpecSection( + title: '주요 위젯', + items: [ + '오늘 입고/출고 건수, 대기 결재 수 KPI 카드', + '최근 트랜잭션 리스트: 번호 · 일자 · 유형 · 상태 · 작성자', + '내 결재 요청/대기 건 알림 패널', + ], + ), + ], + ); + } +} diff --git a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart new file mode 100644 index 0000000..6cdd73b --- /dev/null +++ b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart @@ -0,0 +1,960 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class InboundPage extends StatefulWidget { + const InboundPage({super.key}); + + @override + State createState() => _InboundPageState(); +} + +class _InboundPageState extends State { + final TextEditingController _searchController = TextEditingController(); + final DateFormat _dateFormatter = DateFormat('yyyy-MM-dd'); + final NumberFormat _currencyFormatter = NumberFormat.currency( + locale: 'ko_KR', + symbol: '₩', + decimalDigits: 0, + ); + + DateTimeRange? _dateRange; + final List _records = _mockRecords; + InboundRecord? _selectedRecord; + + static const _statusOptions = ['작성중', '승인대기', '승인완료']; + static const _warehouseOptions = ['서울 1창고', '부산 센터', '대전 물류']; + + @override + void initState() { + super.initState(); + if (_records.isNotEmpty) { + _selectedRecord = _records.first; + } + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final filtered = _filteredRecords; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('입고 관리', style: theme.textTheme.h2), + const SizedBox(height: 6), + Text( + '입고 처리, 라인 품목, 상태를 한 화면에서 확인하고 관리합니다.', + style: theme.textTheme.muted, + ), + ], + ), + ), + Row( + children: [ + ShadButton( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: _handleCreate, + child: const Text('입고 등록'), + ), + const SizedBox(width: 12), + ShadButton.outline( + leading: const Icon(LucideIcons.pencil, size: 16), + onPressed: _selectedRecord == null + ? null + : () => _handleEdit(_selectedRecord!), + child: const Text('선택 항목 수정'), + ), + ], + ), + ], + ), + const SizedBox(height: 24), + ShadCard( + title: Text('검색 필터', style: theme.textTheme.h3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + placeholder: const Text('트랜잭션번호, 작성자, 제품 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onChanged: (_) => setState(() {}), + ), + ), + SizedBox( + width: 220, + child: ShadButton.outline( + onPressed: _pickDateRange, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(LucideIcons.calendar, size: 16), + const SizedBox(width: 8), + Text( + _dateRange == null + ? '기간 선택' + : '${_dateFormatter.format(_dateRange!.start)} ~ ${_dateFormatter.format(_dateRange!.end)}', + ), + ], + ), + ), + ), + if (_dateRange != null) + ShadButton.ghost( + onPressed: () => setState(() => _dateRange = null), + child: const Text('기간 초기화'), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('입고 내역', style: theme.textTheme.h3), + Text('${filtered.length}건', style: theme.textTheme.muted), + ], + ), + child: SizedBox( + height: 420, + child: filtered.isEmpty + ? Center( + child: Text( + '조건에 맞는 입고 내역이 없습니다.', + style: theme.textTheme.muted, + ), + ) + : ShadTable.list( + header: _tableHeaders + .map( + (header) => + ShadTableCell.header(child: Text(header)), + ) + .toList(), + children: [ + for (final record in filtered) + _buildRecordRow(record).map( + (value) => ShadTableCell( + child: Text( + value, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + columnSpanExtent: (index) => + const FixedTableSpanExtent(140), + rowSpanExtent: (index) => const FixedTableSpanExtent(56), + onRowTap: (rowIndex) { + setState(() { + _selectedRecord = filtered[rowIndex]; + }); + }, + ), + ), + ), + if (_selectedRecord != null) ...[ + const SizedBox(height: 24), + _DetailCard( + record: _selectedRecord!, + dateFormatter: _dateFormatter, + currencyFormatter: _currencyFormatter, + onEdit: () => _handleEdit(_selectedRecord!), + ), + ], + ], + ), + ); + } + + List get _filteredRecords { + final query = _searchController.text.trim().toLowerCase(); + return _records.where((record) { + final matchesQuery = + query.isEmpty || + record.number.toLowerCase().contains(query) || + record.transactionNumber.toLowerCase().contains(query) || + record.writer.toLowerCase().contains(query) || + record.items.any( + (item) => item.product.toLowerCase().contains(query), + ); + final matchesRange = + _dateRange == null || + (!record.processedAt.isBefore(_dateRange!.start) && + !record.processedAt.isAfter(_dateRange!.end)); + return matchesQuery && matchesRange; + }).toList()..sort((a, b) => b.processedAt.compareTo(a.processedAt)); + } + + List _buildRecordRow(InboundRecord record) { + final primaryItem = record.items.first; + return [ + record.number.split('-').last, + _dateFormatter.format(record.processedAt), + record.warehouse, + record.transactionNumber, + primaryItem.product, + primaryItem.manufacturer, + primaryItem.unit, + record.totalQuantity.toString(), + _currencyFormatter.format(primaryItem.price), + record.status, + record.writer, + record.itemCount.toString(), + record.totalQuantity.toString(), + record.remark.isEmpty ? '-' : record.remark, + ]; + } + + Future _pickDateRange() async { + final now = DateTime.now(); + final range = await showDateRangePicker( + context: context, + firstDate: DateTime(now.year - 5), + lastDate: DateTime(now.year + 5), + initialDateRange: _dateRange, + ); + if (range != null) { + setState(() => _dateRange = range); + } + } + + Future _handleCreate() async { + final record = await _showInboundFormDialog(); + if (record != null) { + setState(() { + _records.insert(0, record); + _selectedRecord = record; + }); + } + } + + Future _handleEdit(InboundRecord record) async { + final updated = await _showInboundFormDialog(initial: record); + if (updated != null) { + setState(() { + final index = _records.indexWhere( + (element) => element.number == record.number, + ); + if (index != -1) { + _records[index] = updated; + _selectedRecord = updated; + } + }); + } + } + + Future _showInboundFormDialog({ + InboundRecord? initial, + }) async { + final processedAt = ValueNotifier( + initial?.processedAt ?? DateTime.now(), + ); + final warehouseController = TextEditingController( + text: initial?.warehouse ?? _warehouseOptions.first, + ); + final statusValue = ValueNotifier( + initial?.status ?? _statusOptions.first, + ); + final writerController = TextEditingController( + text: initial?.writer ?? '홍길동', + ); + final remarkController = TextEditingController(text: initial?.remark ?? ''); + + final drafts = + initial?.items + .map((item) => _LineItemDraft.fromItem(item)) + .toList() + .cast<_LineItemDraft>() ?? + [_LineItemDraft.empty()]; + + InboundRecord? result; + + await showDialog( + context: context, + builder: (dialogContext) { + final theme = ShadTheme.of(dialogContext); + return StatefulBuilder( + builder: (context, setState) { + return Dialog( + insetPadding: const EdgeInsets.all(24), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 860, + maxHeight: 720, + ), + child: ShadCard( + title: Text( + initial == null ? '입고 등록' : '입고 수정', + style: theme.textTheme.h3, + ), + description: Text( + '입고 기본정보와 품목 라인을 입력하세요.', + style: theme.textTheme.muted, + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('취소'), + ), + const SizedBox(width: 12), + ShadButton( + onPressed: () { + if (drafts.any( + (draft) => draft.product.text.isEmpty, + )) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('품목 정보를 입력하세요.')), + ); + return; + } + final items = drafts + .map( + (draft) => InboundLineItem( + product: draft.product.text, + manufacturer: draft.manufacturer.text, + unit: draft.unit.text, + quantity: + int.tryParse(draft.quantity.text) ?? 0, + price: + double.tryParse( + draft.price.text.replaceAll(',', ''), + ) ?? + 0, + remark: draft.remark.text, + ), + ) + .toList(); + final record = InboundRecord( + number: + initial?.number ?? + _generateInboundNumber(processedAt.value), + transactionNumber: + initial?.transactionNumber ?? + _generateTransactionNumber(processedAt.value), + processedAt: processedAt.value, + warehouse: warehouseController.text, + status: statusValue.value, + writer: writerController.text, + remark: remarkController.text, + items: items, + ); + result = record; + Navigator.of(dialogContext).pop(); + }, + child: const Text('저장'), + ), + ], + ), + child: SingleChildScrollView( + padding: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + SizedBox( + width: 240, + child: _FormFieldLabel( + label: '처리일자', + child: ShadButton.outline( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: processedAt.value, + firstDate: DateTime(2020), + lastDate: DateTime(2030), + ); + if (picked != null) { + processedAt.value = picked; + setState(() {}); + } + }, + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + _dateFormatter.format( + processedAt.value, + ), + ), + const Icon( + LucideIcons.calendar, + size: 16, + ), + ], + ), + ), + ), + ), + SizedBox( + width: 240, + child: _FormFieldLabel( + label: '창고', + child: ShadSelect( + initialValue: warehouseController.text, + selectedOptionBuilder: (context, value) => + Text(value), + onChanged: (value) { + if (value != null) { + warehouseController.text = value; + setState(() {}); + } + }, + options: _warehouseOptions + .map( + (option) => ShadOption( + value: option, + child: Text(option), + ), + ) + .toList(), + ), + ), + ), + SizedBox( + width: 240, + child: _FormFieldLabel( + label: '상태', + child: ShadSelect( + initialValue: statusValue.value, + selectedOptionBuilder: (context, value) => + Text(value), + onChanged: (value) { + if (value != null) { + statusValue.value = value; + setState(() {}); + } + }, + options: _statusOptions + .map( + (status) => ShadOption( + value: status, + child: Text(status), + ), + ) + .toList(), + ), + ), + ), + SizedBox( + width: 240, + child: _FormFieldLabel( + label: '작성자', + child: ShadInput(controller: writerController), + ), + ), + SizedBox( + width: 500, + child: _FormFieldLabel( + label: '비고', + child: ShadInput( + controller: remarkController, + maxLines: 2, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('라인 품목', style: theme.textTheme.h4), + const SizedBox(height: 4), + Text( + '제품, 제조사, 단위, 수량, 단가 정보를 입력하세요.', + style: theme.textTheme.muted, + ), + ], + ), + ShadButton.outline( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: () => setState(() { + drafts.add(_LineItemDraft.empty()); + }), + child: const Text('품목 추가'), + ), + ], + ), + const SizedBox(height: 16), + Column( + children: [ + for (final draft in drafts) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _LineItemRow( + draft: draft, + onRemove: drafts.length == 1 + ? null + : () => setState(() { + draft.dispose(); + drafts.remove(draft); + }), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + }, + ); + }, + ); + + for (final draft in drafts) { + draft.dispose(); + } + warehouseController.dispose(); + statusValue.dispose(); + writerController.dispose(); + remarkController.dispose(); + processedAt.dispose(); + + return result; + } + + static String _generateInboundNumber(DateTime date) { + final stamp = DateFormat('yyyyMMdd-HHmmss').format(date); + return 'IN-$stamp'; + } + + static String _generateTransactionNumber(DateTime date) { + final stamp = DateFormat('yyyyMMdd-HHmmss').format(date); + return 'TX-$stamp'; + } + + static const _tableHeaders = [ + '번호', + '처리일자', + '창고', + '트랜잭션번호', + '제품', + '제조사', + '단위', + '수량', + '단가', + '상태', + '작성자', + '품목수', + '총수량', + '비고', + ]; +} + +class _DetailCard extends StatelessWidget { + const _DetailCard({ + required this.record, + required this.dateFormatter, + required this.currencyFormatter, + required this.onEdit, + }); + + final InboundRecord record; + final DateFormat dateFormatter; + final NumberFormat currencyFormatter; + final VoidCallback onEdit; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('선택된 입고 상세', style: theme.textTheme.h3), + ShadButton.outline( + leading: const Icon(LucideIcons.pencil, size: 16), + onPressed: onEdit, + child: const Text('수정'), + ), + ], + ), + description: Text( + '트랜잭션번호 ${record.transactionNumber}', + style: theme.textTheme.muted, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _DetailChip( + label: '처리일자', + value: dateFormatter.format(record.processedAt), + ), + _DetailChip(label: '창고', value: record.warehouse), + _DetailChip(label: '상태', value: record.status), + _DetailChip(label: '작성자', value: record.writer), + _DetailChip(label: '품목 수', value: '${record.itemCount}'), + _DetailChip(label: '총 수량', value: '${record.totalQuantity}'), + _DetailChip( + label: '총 금액', + value: currencyFormatter.format(record.totalAmount), + ), + ], + ), + const SizedBox(height: 24), + Text('라인 품목', style: theme.textTheme.h4), + const SizedBox(height: 8), + SizedBox( + height: (record.items.length * 52).clamp(160, 260).toDouble(), + child: ShadTable.list( + header: const [ + ShadTableCell.header(child: Text('제품')), + ShadTableCell.header(child: Text('제조사')), + ShadTableCell.header(child: Text('단위')), + ShadTableCell.header(child: Text('수량')), + ShadTableCell.header(child: Text('단가')), + ShadTableCell.header(child: Text('비고')), + ], + children: [ + for (final item in record.items) + [ + ShadTableCell(child: Text(item.product)), + ShadTableCell(child: Text(item.manufacturer)), + ShadTableCell(child: Text(item.unit)), + ShadTableCell(child: Text('${item.quantity}')), + ShadTableCell( + child: Text(currencyFormatter.format(item.price)), + ), + ShadTableCell( + child: Text(item.remark.isEmpty ? '-' : item.remark), + ), + ], + ], + columnSpanExtent: (index) => const FixedTableSpanExtent(136), + rowSpanExtent: (index) => const FixedTableSpanExtent(52), + ), + ), + ], + ), + ); + } +} + +class _DetailChip extends StatelessWidget { + const _DetailChip({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return ShadBadge.outline( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 2), + Text(value, style: theme.textTheme.p), + ], + ), + ), + ); + } +} + +class _FormFieldLabel extends StatelessWidget { + const _FormFieldLabel({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, + ], + ); + } +} + +class _LineItemRow extends StatelessWidget { + const _LineItemRow({required this.draft, required this.onRemove}); + + final _LineItemDraft draft; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ShadInput( + controller: draft.product, + placeholder: const Text('제품명'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ShadInput( + controller: draft.manufacturer, + placeholder: const Text('제조사'), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 80, + child: ShadInput( + controller: draft.unit, + placeholder: const Text('단위'), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 100, + child: ShadInput( + controller: draft.quantity, + placeholder: const Text('수량'), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 120, + child: ShadInput( + controller: draft.price, + placeholder: const Text('단가'), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 12), + Expanded( + child: ShadInput( + controller: draft.remark, + placeholder: const Text('비고'), + ), + ), + const SizedBox(width: 12), + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onRemove, + child: const Icon(LucideIcons.trash2, size: 16), + ), + ], + ); + } +} + +class _LineItemDraft { + _LineItemDraft._({ + required this.product, + required this.manufacturer, + required this.unit, + required this.quantity, + required this.price, + required this.remark, + }); + + final TextEditingController product; + final TextEditingController manufacturer; + final TextEditingController unit; + final TextEditingController quantity; + final TextEditingController price; + final TextEditingController remark; + + factory _LineItemDraft.empty() { + return _LineItemDraft._( + product: TextEditingController(), + manufacturer: TextEditingController(), + unit: TextEditingController(text: 'EA'), + quantity: TextEditingController(text: '0'), + price: TextEditingController(text: '0'), + remark: TextEditingController(), + ); + } + + factory _LineItemDraft.fromItem(InboundLineItem item) { + return _LineItemDraft._( + product: TextEditingController(text: item.product), + manufacturer: TextEditingController(text: item.manufacturer), + unit: TextEditingController(text: item.unit), + quantity: TextEditingController(text: '${item.quantity}'), + price: TextEditingController(text: item.price.toStringAsFixed(0)), + remark: TextEditingController(text: item.remark), + ); + } + + void dispose() { + product.dispose(); + manufacturer.dispose(); + unit.dispose(); + quantity.dispose(); + price.dispose(); + remark.dispose(); + } +} + +class InboundRecord { + InboundRecord({ + required this.number, + required this.transactionNumber, + required this.processedAt, + required this.warehouse, + required this.status, + required this.writer, + required this.remark, + required this.items, + }); + + final String number; + final String transactionNumber; + final DateTime processedAt; + final String warehouse; + final String status; + final String writer; + final String remark; + final List items; + + int get itemCount => items.length; + int get totalQuantity => + items.fold(0, (sum, item) => sum + item.quantity); + double get totalAmount => + items.fold(0, (sum, item) => sum + (item.price * item.quantity)); +} + +class InboundLineItem { + InboundLineItem({ + required this.product, + required this.manufacturer, + required this.unit, + required this.quantity, + required this.price, + required this.remark, + }); + + final String product; + final String manufacturer; + final String unit; + final int quantity; + final double price; + final String remark; +} + +final List _mockRecords = [ + InboundRecord( + number: 'IN-20240301-001', + transactionNumber: 'TX-20240301-001', + processedAt: DateTime(2024, 3, 1), + warehouse: '서울 1창고', + status: '작성중', + writer: '홍길동', + remark: '-', + items: [ + InboundLineItem( + product: 'XR-5000', + manufacturer: '슈퍼벤더', + unit: 'EA', + quantity: 40, + price: 120000, + remark: '', + ), + InboundLineItem( + product: 'XR-5001', + manufacturer: '슈퍼벤더', + unit: 'EA', + quantity: 60, + price: 98000, + remark: '', + ), + ], + ), + InboundRecord( + number: 'IN-20240305-002', + transactionNumber: 'TX-20240305-010', + processedAt: DateTime(2024, 3, 5), + warehouse: '부산 센터', + status: '승인대기', + writer: '김담당', + remark: '긴급 입고', + items: [ + InboundLineItem( + product: 'Eco-200', + manufacturer: '그린텍', + unit: 'EA', + quantity: 25, + price: 145000, + remark: 'QC 필요', + ), + InboundLineItem( + product: 'Eco-200B', + manufacturer: '그린텍', + unit: 'EA', + quantity: 10, + price: 160000, + remark: '', + ), + ], + ), + InboundRecord( + number: 'IN-20240310-003', + transactionNumber: 'TX-20240310-004', + processedAt: DateTime(2024, 3, 10), + warehouse: '대전 물류', + status: '승인완료', + writer: '최검수', + remark: '완료', + items: [ + InboundLineItem( + product: 'Delta-One', + manufacturer: '델타', + unit: 'SET', + quantity: 8, + price: 450000, + remark: '설치 일정 확인', + ), + ], + ), +]; diff --git a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart new file mode 100644 index 0000000..e43061d --- /dev/null +++ b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart @@ -0,0 +1,1037 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class OutboundPage extends StatefulWidget { + const OutboundPage({super.key}); + + @override + State createState() => _OutboundPageState(); +} + +class _OutboundPageState extends State { + final TextEditingController _searchController = TextEditingController(); + final DateFormat _dateFormatter = DateFormat('yyyy-MM-dd'); + final NumberFormat _currencyFormatter = NumberFormat.currency( + locale: 'ko_KR', + symbol: '₩', + decimalDigits: 0, + ); + + DateTimeRange? _dateRange; + final List _records = _mockOutboundRecords; + OutboundRecord? _selectedRecord; + + static const _statusOptions = ['작성중', '출고대기', '출고완료']; + static const _warehouseOptions = ['서울 1창고', '부산 센터', '대전 물류']; + static const _customerOptions = [ + '슈퍼포트 파트너', + '그린에너지', + '테크솔루션', + '에이치솔루션', + '블루하이드', + ]; + + @override + void initState() { + super.initState(); + if (_records.isNotEmpty) { + _selectedRecord = _records.first; + } + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final filtered = _filteredRecords; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('출고 관리', style: theme.textTheme.h2), + const SizedBox(height: 6), + Text( + '출고 처리, 고객사 연결, 품목 라인을 실시간으로 확인합니다.', + style: theme.textTheme.muted, + ), + ], + ), + ), + Row( + children: [ + ShadButton( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: _handleCreate, + child: const Text('출고 등록'), + ), + const SizedBox(width: 12), + ShadButton.outline( + leading: const Icon(LucideIcons.pencil, size: 16), + onPressed: _selectedRecord == null + ? null + : () => _handleEdit(_selectedRecord!), + child: const Text('선택 항목 수정'), + ), + ], + ), + ], + ), + const SizedBox(height: 24), + ShadCard( + title: Text('검색 필터', style: theme.textTheme.h3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + placeholder: const Text('트랜잭션번호, 작성자, 제품, 고객사 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onChanged: (_) => setState(() {}), + ), + ), + SizedBox( + width: 220, + child: ShadButton.outline( + onPressed: _pickDateRange, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(LucideIcons.calendar, size: 16), + const SizedBox(width: 8), + Text( + _dateRange == null + ? '기간 선택' + : '${_dateFormatter.format(_dateRange!.start)} ~ ${_dateFormatter.format(_dateRange!.end)}', + ), + ], + ), + ), + ), + if (_dateRange != null) + ShadButton.ghost( + onPressed: () => setState(() => _dateRange = null), + child: const Text('기간 초기화'), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('출고 내역', style: theme.textTheme.h3), + Text('${filtered.length}건', style: theme.textTheme.muted), + ], + ), + child: SizedBox( + height: 420, + child: filtered.isEmpty + ? Center( + child: Text( + '조건에 맞는 출고 내역이 없습니다.', + style: theme.textTheme.muted, + ), + ) + : ShadTable.list( + header: _tableHeaders + .map( + (header) => + ShadTableCell.header(child: Text(header)), + ) + .toList(), + children: [ + for (final record in filtered) + _buildRecordRow(record).map( + (value) => ShadTableCell( + child: Text( + value, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + columnSpanExtent: (index) => + const FixedTableSpanExtent(140), + rowSpanExtent: (index) => const FixedTableSpanExtent(56), + onRowTap: (rowIndex) { + setState(() { + _selectedRecord = filtered[rowIndex]; + }); + }, + ), + ), + ), + if (_selectedRecord != null) ...[ + const SizedBox(height: 24), + _OutboundDetailCard( + record: _selectedRecord!, + dateFormatter: _dateFormatter, + currencyFormatter: _currencyFormatter, + onEdit: () => _handleEdit(_selectedRecord!), + ), + ], + ], + ), + ); + } + + List get _filteredRecords { + final query = _searchController.text.trim().toLowerCase(); + return _records.where((record) { + final matchesQuery = + query.isEmpty || + record.number.toLowerCase().contains(query) || + record.transactionNumber.toLowerCase().contains(query) || + record.writer.toLowerCase().contains(query) || + record.customers.any( + (customer) => customer.toLowerCase().contains(query), + ) || + record.items.any( + (item) => item.product.toLowerCase().contains(query), + ); + final matchesRange = + _dateRange == null || + (!record.processedAt.isBefore(_dateRange!.start) && + !record.processedAt.isAfter(_dateRange!.end)); + return matchesQuery && matchesRange; + }).toList()..sort((a, b) => b.processedAt.compareTo(a.processedAt)); + } + + List _buildRecordRow(OutboundRecord record) { + final primaryItem = record.items.first; + return [ + record.number.split('-').last, + _dateFormatter.format(record.processedAt), + record.warehouse, + record.transactionNumber, + primaryItem.product, + primaryItem.manufacturer, + primaryItem.unit, + record.totalQuantity.toString(), + _currencyFormatter.format(primaryItem.price), + record.status, + record.writer, + record.customerCount.toString(), + record.itemCount.toString(), + record.totalQuantity.toString(), + record.remark.isEmpty ? '-' : record.remark, + ]; + } + + Future _pickDateRange() async { + final now = DateTime.now(); + final range = await showDateRangePicker( + context: context, + firstDate: DateTime(now.year - 5), + lastDate: DateTime(now.year + 5), + initialDateRange: _dateRange, + ); + if (range != null) { + setState(() => _dateRange = range); + } + } + + Future _handleCreate() async { + final record = await _showOutboundFormDialog(); + if (record != null) { + setState(() { + _records.insert(0, record); + _selectedRecord = record; + }); + } + } + + Future _handleEdit(OutboundRecord record) async { + final updated = await _showOutboundFormDialog(initial: record); + if (updated != null) { + setState(() { + final index = _records.indexWhere( + (element) => element.number == record.number, + ); + if (index != -1) { + _records[index] = updated; + _selectedRecord = updated; + } + }); + } + } + + Future _showOutboundFormDialog({ + OutboundRecord? initial, + }) async { + final processedAt = ValueNotifier( + initial?.processedAt ?? DateTime.now(), + ); + final warehouseController = TextEditingController( + text: initial?.warehouse ?? _warehouseOptions.first, + ); + final statusValue = ValueNotifier( + initial?.status ?? _statusOptions.first, + ); + final writerController = TextEditingController( + text: initial?.writer ?? '이영희', + ); + final remarkController = TextEditingController(text: initial?.remark ?? ''); + final customerController = ShadSelectController( + initialValue: initial?.customers.toSet() ?? {_customerOptions.first}, + ); + + final drafts = + initial?.items + .map((item) => _OutboundLineItemDraft.fromItem(item)) + .toList() + .cast<_OutboundLineItemDraft>() ?? + [_OutboundLineItemDraft.empty()]; + + OutboundRecord? result; + + await showDialog( + context: context, + builder: (dialogContext) { + final theme = ShadTheme.of(dialogContext); + return StatefulBuilder( + builder: (context, setState) { + return Dialog( + insetPadding: const EdgeInsets.all(24), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 880, + maxHeight: 720, + ), + child: ShadCard( + title: Text( + initial == null ? '출고 등록' : '출고 수정', + style: theme.textTheme.h3, + ), + description: Text( + '출고 기본정보와 품목 라인을 입력하세요.', + style: theme.textTheme.muted, + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('취소'), + ), + const SizedBox(width: 12), + ShadButton( + onPressed: () { + if (drafts.any( + (draft) => draft.product.text.isEmpty, + )) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('품목 정보를 입력하세요.')), + ); + return; + } + final items = drafts + .map( + (draft) => OutboundLineItem( + product: draft.product.text, + manufacturer: draft.manufacturer.text, + unit: draft.unit.text, + quantity: + int.tryParse(draft.quantity.text) ?? 0, + price: + double.tryParse( + draft.price.text.replaceAll(',', ''), + ) ?? + 0, + remark: draft.remark.text, + ), + ) + .toList(); + final record = OutboundRecord( + number: + initial?.number ?? + _generateOutboundNumber(processedAt.value), + transactionNumber: + initial?.transactionNumber ?? + _generateTransactionNumber(processedAt.value), + processedAt: processedAt.value, + warehouse: warehouseController.text, + status: statusValue.value, + writer: writerController.text, + remark: remarkController.text, + customers: customerController.value.toList(), + items: items, + ); + result = record; + Navigator.of(dialogContext).pop(); + }, + child: const Text('저장'), + ), + ], + ), + child: SingleChildScrollView( + padding: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + SizedBox( + width: 240, + child: _FormFieldLabel( + label: '처리일자', + child: ShadButton.outline( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: processedAt.value, + firstDate: DateTime(2020), + lastDate: DateTime(2030), + ); + if (picked != null) { + processedAt.value = picked; + setState(() {}); + } + }, + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + _dateFormatter.format( + processedAt.value, + ), + ), + const Icon( + LucideIcons.calendar, + size: 16, + ), + ], + ), + ), + ), + ), + SizedBox( + width: 240, + child: _FormFieldLabel( + label: '창고', + child: ShadSelect( + initialValue: warehouseController.text, + selectedOptionBuilder: (context, value) => + Text(value), + onChanged: (value) { + if (value != null) { + warehouseController.text = value; + setState(() {}); + } + }, + options: _warehouseOptions + .map( + (option) => ShadOption( + value: option, + child: Text(option), + ), + ) + .toList(), + ), + ), + ), + SizedBox( + width: 240, + child: _FormFieldLabel( + label: '상태', + child: ShadSelect( + initialValue: statusValue.value, + selectedOptionBuilder: (context, value) => + Text(value), + onChanged: (value) { + if (value != null) { + statusValue.value = value; + setState(() {}); + } + }, + options: _statusOptions + .map( + (status) => ShadOption( + value: status, + child: Text(status), + ), + ) + .toList(), + ), + ), + ), + SizedBox( + width: 240, + child: _FormFieldLabel( + label: '작성자', + child: ShadInput(controller: writerController), + ), + ), + SizedBox( + width: 360, + child: _FormFieldLabel( + label: '출고 고객사', + child: ShadSelect.multiple( + controller: customerController, + closeOnSelect: false, + placeholder: const Text('고객사 선택'), + selectedOptionsBuilder: (context, values) { + if (values.isEmpty) { + return const Text('선택된 고객사가 없습니다'); + } + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final value in values) + ShadBadge( + child: Padding( + padding: + const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Text(value), + ), + ), + ], + ); + }, + options: _customerOptions + .map( + (customer) => ShadOption( + value: customer, + child: Text(customer), + ), + ) + .toList(), + onChanged: (values) => setState(() {}), + ), + ), + ), + SizedBox( + width: 500, + child: _FormFieldLabel( + label: '비고', + child: ShadInput( + controller: remarkController, + maxLines: 2, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('라인 품목', style: theme.textTheme.h4), + const SizedBox(height: 4), + Text( + '제품, 제조사, 단위, 수량, 단가 정보를 입력하세요.', + style: theme.textTheme.muted, + ), + ], + ), + ShadButton.outline( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: () => setState(() { + drafts.add(_OutboundLineItemDraft.empty()); + }), + child: const Text('품목 추가'), + ), + ], + ), + const SizedBox(height: 16), + Column( + children: [ + for (final draft in drafts) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _OutboundLineItemRow( + draft: draft, + onRemove: drafts.length == 1 + ? null + : () => setState(() { + draft.dispose(); + drafts.remove(draft); + }), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + }, + ); + }, + ); + + for (final draft in drafts) { + draft.dispose(); + } + warehouseController.dispose(); + statusValue.dispose(); + writerController.dispose(); + remarkController.dispose(); + processedAt.dispose(); + customerController.dispose(); + + return result; + } + + static String _generateOutboundNumber(DateTime date) { + final stamp = DateFormat('yyyyMMdd-HHmmss').format(date); + return 'OUT-$stamp'; + } + + static String _generateTransactionNumber(DateTime date) { + final stamp = DateFormat('yyyyMMdd-HHmmss').format(date); + return 'TX-$stamp'; + } + + static const _tableHeaders = [ + '번호', + '처리일자', + '창고', + '트랜잭션번호', + '제품', + '제조사', + '단위', + '수량', + '단가', + '상태', + '작성자', + '고객수', + '품목수', + '총수량', + '비고', + ]; +} + +class _OutboundDetailCard extends StatelessWidget { + const _OutboundDetailCard({ + required this.record, + required this.dateFormatter, + required this.currencyFormatter, + required this.onEdit, + }); + + final OutboundRecord record; + final DateFormat dateFormatter; + final NumberFormat currencyFormatter; + final VoidCallback onEdit; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('선택된 출고 상세', style: theme.textTheme.h3), + ShadButton.outline( + leading: const Icon(LucideIcons.pencil, size: 16), + onPressed: onEdit, + child: const Text('수정'), + ), + ], + ), + description: Text( + '트랜잭션번호 ${record.transactionNumber}', + style: theme.textTheme.muted, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _DetailChip( + label: '처리일자', + value: dateFormatter.format(record.processedAt), + ), + _DetailChip(label: '창고', value: record.warehouse), + _DetailChip(label: '상태', value: record.status), + _DetailChip(label: '작성자', value: record.writer), + _DetailChip(label: '고객 수', value: '${record.customerCount}'), + _DetailChip(label: '품목 수', value: '${record.itemCount}'), + _DetailChip(label: '총 수량', value: '${record.totalQuantity}'), + _DetailChip( + label: '총 금액', + value: currencyFormatter.format(record.totalAmount), + ), + ], + ), + const SizedBox(height: 16), + Text('출고 고객사', style: theme.textTheme.h4), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final customer in record.customers) + ShadBadge( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + child: Text(customer), + ), + ), + ], + ), + const SizedBox(height: 24), + Text('라인 품목', style: theme.textTheme.h4), + const SizedBox(height: 8), + SizedBox( + height: (record.items.length * 52).clamp(160, 260).toDouble(), + child: ShadTable.list( + header: const [ + ShadTableCell.header(child: Text('제품')), + ShadTableCell.header(child: Text('제조사')), + ShadTableCell.header(child: Text('단위')), + ShadTableCell.header(child: Text('수량')), + ShadTableCell.header(child: Text('단가')), + ShadTableCell.header(child: Text('비고')), + ], + children: [ + for (final item in record.items) + [ + ShadTableCell(child: Text(item.product)), + ShadTableCell(child: Text(item.manufacturer)), + ShadTableCell(child: Text(item.unit)), + ShadTableCell(child: Text('${item.quantity}')), + ShadTableCell( + child: Text(currencyFormatter.format(item.price)), + ), + ShadTableCell( + child: Text(item.remark.isEmpty ? '-' : item.remark), + ), + ], + ], + columnSpanExtent: (index) => const FixedTableSpanExtent(136), + rowSpanExtent: (index) => const FixedTableSpanExtent(52), + ), + ), + ], + ), + ); + } +} + +class _FormFieldLabel extends StatelessWidget { + const _FormFieldLabel({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, + ], + ); + } +} + +class _OutboundLineItemRow extends StatelessWidget { + const _OutboundLineItemRow({required this.draft, required this.onRemove}); + + final _OutboundLineItemDraft draft; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ShadInput( + controller: draft.product, + placeholder: const Text('제품명'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ShadInput( + controller: draft.manufacturer, + placeholder: const Text('제조사'), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 80, + child: ShadInput( + controller: draft.unit, + placeholder: const Text('단위'), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 100, + child: ShadInput( + controller: draft.quantity, + placeholder: const Text('수량'), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 120, + child: ShadInput( + controller: draft.price, + placeholder: const Text('단가'), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 12), + Expanded( + child: ShadInput( + controller: draft.remark, + placeholder: const Text('비고'), + ), + ), + const SizedBox(width: 12), + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onRemove, + child: const Icon(LucideIcons.trash2, size: 16), + ), + ], + ); + } +} + +class _OutboundLineItemDraft { + _OutboundLineItemDraft._({ + required this.product, + required this.manufacturer, + required this.unit, + required this.quantity, + required this.price, + required this.remark, + }); + + final TextEditingController product; + final TextEditingController manufacturer; + final TextEditingController unit; + final TextEditingController quantity; + final TextEditingController price; + final TextEditingController remark; + + factory _OutboundLineItemDraft.empty() { + return _OutboundLineItemDraft._( + product: TextEditingController(), + manufacturer: TextEditingController(), + unit: TextEditingController(text: 'EA'), + quantity: TextEditingController(text: '0'), + price: TextEditingController(text: '0'), + remark: TextEditingController(), + ); + } + + factory _OutboundLineItemDraft.fromItem(OutboundLineItem item) { + return _OutboundLineItemDraft._( + product: TextEditingController(text: item.product), + manufacturer: TextEditingController(text: item.manufacturer), + unit: TextEditingController(text: item.unit), + quantity: TextEditingController(text: '${item.quantity}'), + price: TextEditingController(text: item.price.toStringAsFixed(0)), + remark: TextEditingController(text: item.remark), + ); + } + + void dispose() { + product.dispose(); + manufacturer.dispose(); + unit.dispose(); + quantity.dispose(); + price.dispose(); + remark.dispose(); + } +} + +class OutboundRecord { + OutboundRecord({ + required this.number, + required this.transactionNumber, + required this.processedAt, + required this.warehouse, + required this.status, + required this.writer, + required this.customers, + required this.remark, + required this.items, + }); + + final String number; + final String transactionNumber; + final DateTime processedAt; + final String warehouse; + final String status; + final String writer; + final List customers; + final String remark; + final List items; + + int get customerCount => customers.length; + int get itemCount => items.length; + int get totalQuantity => + items.fold(0, (sum, item) => sum + item.quantity); + double get totalAmount => + items.fold(0, (sum, item) => sum + (item.price * item.quantity)); +} + +class OutboundLineItem { + OutboundLineItem({ + required this.product, + required this.manufacturer, + required this.unit, + required this.quantity, + required this.price, + required this.remark, + }); + + final String product; + final String manufacturer; + final String unit; + final int quantity; + final double price; + final String remark; +} + +final List _mockOutboundRecords = [ + OutboundRecord( + number: 'OUT-20240302-001', + transactionNumber: 'TX-20240302-010', + processedAt: DateTime(2024, 3, 2), + warehouse: '서울 1창고', + status: '출고대기', + writer: '이영희', + customers: ['슈퍼포트 파트너', '그린에너지'], + remark: '-', + items: [ + OutboundLineItem( + product: 'XR-5000', + manufacturer: '슈퍼벤더', + unit: 'EA', + quantity: 30, + price: 130000, + remark: '긴급 출고', + ), + OutboundLineItem( + product: 'XR-5001', + manufacturer: '슈퍼벤더', + unit: 'EA', + quantity: 20, + price: 118000, + remark: '', + ), + ], + ), + OutboundRecord( + number: 'OUT-20240304-004', + transactionNumber: 'TX-20240304-005', + processedAt: DateTime(2024, 3, 4), + warehouse: '부산 센터', + status: '출고완료', + writer: '강물류', + customers: ['테크솔루션'], + remark: '완납', + items: [ + OutboundLineItem( + product: 'Eco-200', + manufacturer: '그린텍', + unit: 'EA', + quantity: 15, + price: 150000, + remark: '', + ), + ], + ), + OutboundRecord( + number: 'OUT-20240309-006', + transactionNumber: 'TX-20240309-012', + processedAt: DateTime(2024, 3, 9), + warehouse: '대전 물류', + status: '작성중', + writer: '최준비', + customers: ['에이치솔루션', '블루하이드'], + remark: '운송배차 예정', + items: [ + OutboundLineItem( + product: 'Delta-One', + manufacturer: '델타', + unit: 'SET', + quantity: 6, + price: 460000, + remark: '시연용', + ), + ], + ), +]; + +class _DetailChip extends StatelessWidget { + const _DetailChip({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return ShadBadge.outline( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 2), + Text(value, style: theme.textTheme.p), + ], + ), + ), + ); + } +} diff --git a/lib/features/inventory/rental/presentation/pages/rental_page.dart b/lib/features/inventory/rental/presentation/pages/rental_page.dart new file mode 100644 index 0000000..d57cece --- /dev/null +++ b/lib/features/inventory/rental/presentation/pages/rental_page.dart @@ -0,0 +1,1125 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class RentalPage extends StatefulWidget { + const RentalPage({super.key}); + + @override + State createState() => _RentalPageState(); +} + +class _RentalPageState extends State { + final TextEditingController _searchController = TextEditingController(); + final DateFormat _dateFormatter = DateFormat('yyyy-MM-dd'); + final NumberFormat _currencyFormatter = NumberFormat.currency( + locale: 'ko_KR', + symbol: '₩', + decimalDigits: 0, + ); + + DateTimeRange? _dateRange; + final List _records = _mockRentalRecords; + RentalRecord? _selectedRecord; + + static const _statusOptions = ['대여중', '반납대기', '완료']; + static const _warehouseOptions = ['서울 1창고', '부산 센터', '대전 물류']; + static const _rentalTypes = ['대여', '반납']; + static const _customerOptions = [ + '슈퍼포트 파트너', + '그린에너지', + '테크솔루션', + '에이치솔루션', + '블루하이드', + ]; + + @override + void initState() { + super.initState(); + if (_records.isNotEmpty) { + _selectedRecord = _records.first; + } + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final filtered = _filteredRecords; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('대여 관리', style: theme.textTheme.h2), + const SizedBox(height: 6), + Text( + '대여/반납 구분, 반납 예정일, 고객사 현황을 확인합니다.', + style: theme.textTheme.muted, + ), + ], + ), + ), + Row( + children: [ + ShadButton( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: _handleCreate, + child: const Text('대여 등록'), + ), + const SizedBox(width: 12), + ShadButton.outline( + leading: const Icon(LucideIcons.pencil, size: 16), + onPressed: _selectedRecord == null + ? null + : () => _handleEdit(_selectedRecord!), + child: const Text('선택 항목 수정'), + ), + ], + ), + ], + ), + const SizedBox(height: 24), + ShadCard( + title: Text('검색 필터', style: theme.textTheme.h3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + placeholder: const Text('트랜잭션번호, 작성자, 제품, 고객사 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onChanged: (_) => setState(() {}), + ), + ), + SizedBox( + width: 220, + child: ShadButton.outline( + onPressed: _pickDateRange, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(LucideIcons.calendar, size: 16), + const SizedBox(width: 8), + Text( + _dateRange == null + ? '기간 선택' + : '${_dateFormatter.format(_dateRange!.start)} ~ ${_dateFormatter.format(_dateRange!.end)}', + ), + ], + ), + ), + ), + if (_dateRange != null) + ShadButton.ghost( + onPressed: () => setState(() => _dateRange = null), + child: const Text('기간 초기화'), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('대여 내역', style: theme.textTheme.h3), + Text('${filtered.length}건', style: theme.textTheme.muted), + ], + ), + child: SizedBox( + height: 420, + child: filtered.isEmpty + ? Center( + child: Text( + '조건에 맞는 대여 내역이 없습니다.', + style: theme.textTheme.muted, + ), + ) + : ShadTable.list( + header: _tableHeaders + .map( + (header) => + ShadTableCell.header(child: Text(header)), + ) + .toList(), + children: [ + for (final record in filtered) + _buildRecordRow(record).map( + (value) => ShadTableCell( + child: Text( + value, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + columnSpanExtent: (index) => + const FixedTableSpanExtent(140), + rowSpanExtent: (index) => const FixedTableSpanExtent(56), + onRowTap: (rowIndex) { + setState(() { + _selectedRecord = filtered[rowIndex]; + }); + }, + ), + ), + ), + if (_selectedRecord != null) ...[ + const SizedBox(height: 24), + _RentalDetailCard( + record: _selectedRecord!, + dateFormatter: _dateFormatter, + currencyFormatter: _currencyFormatter, + onEdit: () => _handleEdit(_selectedRecord!), + ), + ], + ], + ), + ); + } + + List get _filteredRecords { + final query = _searchController.text.trim().toLowerCase(); + return _records.where((record) { + final matchesQuery = + query.isEmpty || + record.number.toLowerCase().contains(query) || + record.transactionNumber.toLowerCase().contains(query) || + record.writer.toLowerCase().contains(query) || + record.customers.any( + (customer) => customer.toLowerCase().contains(query), + ) || + record.items.any( + (item) => item.product.toLowerCase().contains(query), + ); + final matchesRange = + _dateRange == null || + (!record.processedAt.isBefore(_dateRange!.start) && + !record.processedAt.isAfter(_dateRange!.end)); + return matchesQuery && matchesRange; + }).toList()..sort((a, b) => b.processedAt.compareTo(a.processedAt)); + } + + List _buildRecordRow(RentalRecord record) { + final primaryItem = record.items.first; + return [ + record.number.split('-').last, + _dateFormatter.format(record.processedAt), + record.warehouse, + record.transactionNumber, + record.rentalType, + record.returnDueDate == null + ? '-' + : _dateFormatter.format(record.returnDueDate!), + primaryItem.product, + primaryItem.unit, + record.totalQuantity.toString(), + _currencyFormatter.format(primaryItem.price), + record.status, + record.customerCount.toString(), + record.itemCount.toString(), + record.totalQuantity.toString(), + record.remark.isEmpty ? '-' : record.remark, + ]; + } + + Future _pickDateRange() async { + final now = DateTime.now(); + final range = await showDateRangePicker( + context: context, + firstDate: DateTime(now.year - 5), + lastDate: DateTime(now.year + 5), + initialDateRange: _dateRange, + ); + if (range != null) { + setState(() => _dateRange = range); + } + } + + Future _handleCreate() async { + final record = await _showRentalFormDialog(); + if (record != null) { + setState(() { + _records.insert(0, record); + _selectedRecord = record; + }); + } + } + + Future _handleEdit(RentalRecord record) async { + final updated = await _showRentalFormDialog(initial: record); + if (updated != null) { + setState(() { + final index = _records.indexWhere( + (element) => element.number == record.number, + ); + if (index != -1) { + _records[index] = updated; + _selectedRecord = updated; + } + }); + } + } + + Future _showRentalFormDialog({RentalRecord? initial}) async { + final processedAt = ValueNotifier( + initial?.processedAt ?? DateTime.now(), + ); + final returnDue = ValueNotifier(initial?.returnDueDate); + final warehouseController = TextEditingController( + text: initial?.warehouse ?? _warehouseOptions.first, + ); + final statusValue = ValueNotifier( + initial?.status ?? _statusOptions.first, + ); + final rentalTypeValue = ValueNotifier( + initial?.rentalType ?? _rentalTypes.first, + ); + final writerController = TextEditingController( + text: initial?.writer ?? '박대여', + ); + final remarkController = TextEditingController(text: initial?.remark ?? ''); + final customerController = ShadSelectController( + initialValue: initial?.customers.toSet() ?? {_customerOptions.first}, + ); + + final drafts = + initial?.items + .map((item) => _RentalLineItemDraft.fromItem(item)) + .toList() + .cast<_RentalLineItemDraft>() ?? + [_RentalLineItemDraft.empty()]; + + RentalRecord? result; + + await showDialog( + context: context, + builder: (dialogContext) { + final theme = ShadTheme.of(dialogContext); + return StatefulBuilder( + builder: (context, setState) { + return Dialog( + insetPadding: const EdgeInsets.all(24), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 900, + maxHeight: 760, + ), + child: ShadCard( + title: Text( + initial == null ? '대여 등록' : '대여 수정', + style: theme.textTheme.h3, + ), + description: Text( + '대여 기본정보와 반납 예정일, 품목 라인을 입력하세요.', + style: theme.textTheme.muted, + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('취소'), + ), + const SizedBox(width: 12), + ShadButton( + onPressed: () { + if (drafts.any( + (draft) => draft.product.text.isEmpty, + )) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('품목 정보를 입력하세요.')), + ); + return; + } + final items = drafts + .map( + (draft) => RentalLineItem( + product: draft.product.text, + manufacturer: draft.manufacturer.text, + unit: draft.unit.text, + quantity: + int.tryParse(draft.quantity.text) ?? 0, + price: + double.tryParse( + draft.price.text.replaceAll(',', ''), + ) ?? + 0, + remark: draft.remark.text, + ), + ) + .toList(); + final record = RentalRecord( + number: + initial?.number ?? + _generateRentalNumber(processedAt.value), + transactionNumber: + initial?.transactionNumber ?? + _generateTransactionNumber(processedAt.value), + processedAt: processedAt.value, + warehouse: warehouseController.text, + status: statusValue.value, + rentalType: rentalTypeValue.value, + returnDueDate: returnDue.value, + writer: writerController.text, + remark: remarkController.text, + customers: customerController.value.toList(), + items: items, + ); + result = record; + Navigator.of(dialogContext).pop(); + }, + child: const Text('저장'), + ), + ], + ), + child: SingleChildScrollView( + padding: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + SizedBox( + width: 220, + child: _FormFieldLabel( + label: '처리일자', + child: ShadButton.outline( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: processedAt.value, + firstDate: DateTime(2020), + lastDate: DateTime(2030), + ); + if (picked != null) { + processedAt.value = picked; + setState(() {}); + } + }, + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + _dateFormatter.format( + processedAt.value, + ), + ), + const Icon( + LucideIcons.calendar, + size: 16, + ), + ], + ), + ), + ), + ), + SizedBox( + width: 220, + child: _FormFieldLabel( + label: '대여 구분', + child: ShadSelect( + initialValue: rentalTypeValue.value, + selectedOptionBuilder: (context, value) => + Text(value), + onChanged: (value) { + if (value != null) { + rentalTypeValue.value = value; + setState(() {}); + } + }, + options: _rentalTypes + .map( + (type) => ShadOption( + value: type, + child: Text(type), + ), + ) + .toList(), + ), + ), + ), + SizedBox( + width: 220, + child: _FormFieldLabel( + label: '창고', + child: ShadSelect( + initialValue: warehouseController.text, + selectedOptionBuilder: (context, value) => + Text(value), + onChanged: (value) { + if (value != null) { + warehouseController.text = value; + setState(() {}); + } + }, + options: _warehouseOptions + .map( + (option) => ShadOption( + value: option, + child: Text(option), + ), + ) + .toList(), + ), + ), + ), + SizedBox( + width: 240, + child: _FormFieldLabel( + label: '상태', + child: ShadSelect( + initialValue: statusValue.value, + selectedOptionBuilder: (context, value) => + Text(value), + onChanged: (value) { + if (value != null) { + statusValue.value = value; + setState(() {}); + } + }, + options: _statusOptions + .map( + (status) => ShadOption( + value: status, + child: Text(status), + ), + ) + .toList(), + ), + ), + ), + SizedBox( + width: 360, + child: _FormFieldLabel( + label: '대여 고객사', + child: ShadSelect.multiple( + controller: customerController, + closeOnSelect: false, + placeholder: const Text('고객사 선택'), + selectedOptionsBuilder: (context, values) { + if (values.isEmpty) { + return const Text('선택된 고객사가 없습니다'); + } + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final value in values) + ShadBadge( + child: Padding( + padding: + const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Text(value), + ), + ), + ], + ); + }, + options: _customerOptions + .map( + (customer) => ShadOption( + value: customer, + child: Text(customer), + ), + ) + .toList(), + onChanged: (values) => setState(() {}), + ), + ), + ), + SizedBox( + width: 220, + child: _FormFieldLabel( + label: '반납 예정일', + child: ShadButton.outline( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: + returnDue.value ?? processedAt.value, + firstDate: processedAt.value, + lastDate: DateTime(2030), + ); + if (picked != null) { + returnDue.value = picked; + setState(() {}); + } + }, + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + returnDue.value == null + ? '선택' + : _dateFormatter.format( + returnDue.value!, + ), + ), + const Icon( + LucideIcons.calendar, + size: 16, + ), + ], + ), + ), + ), + ), + SizedBox( + width: 240, + child: _FormFieldLabel( + label: '작성자', + child: ShadInput(controller: writerController), + ), + ), + SizedBox( + width: 500, + child: _FormFieldLabel( + label: '비고', + child: ShadInput( + controller: remarkController, + maxLines: 2, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('라인 품목', style: theme.textTheme.h4), + const SizedBox(height: 4), + Text( + '제품, 제조사, 단위, 수량, 단가 정보를 입력하세요.', + style: theme.textTheme.muted, + ), + ], + ), + ShadButton.outline( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: () => setState(() { + drafts.add(_RentalLineItemDraft.empty()); + }), + child: const Text('품목 추가'), + ), + ], + ), + const SizedBox(height: 16), + Column( + children: [ + for (final draft in drafts) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _RentalLineItemRow( + draft: draft, + onRemove: drafts.length == 1 + ? null + : () => setState(() { + draft.dispose(); + drafts.remove(draft); + }), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + }, + ); + }, + ); + + for (final draft in drafts) { + draft.dispose(); + } + warehouseController.dispose(); + statusValue.dispose(); + rentalTypeValue.dispose(); + writerController.dispose(); + remarkController.dispose(); + processedAt.dispose(); + returnDue.dispose(); + customerController.dispose(); + + return result; + } + + static String _generateRentalNumber(DateTime date) { + final stamp = DateFormat('yyyyMMdd-HHmmss').format(date); + return 'RENT-$stamp'; + } + + static String _generateTransactionNumber(DateTime date) { + final stamp = DateFormat('yyyyMMdd-HHmmss').format(date); + return 'TX-$stamp'; + } + + static const _tableHeaders = [ + '번호', + '처리일자', + '창고', + '트랜잭션번호', + '대여/반납', + '반납예정일', + '제품', + '단위', + '수량', + '단가', + '상태', + '고객수', + '품목수', + '총수량', + '비고', + ]; +} + +class _RentalDetailCard extends StatelessWidget { + const _RentalDetailCard({ + required this.record, + required this.dateFormatter, + required this.currencyFormatter, + required this.onEdit, + }); + + final RentalRecord record; + final DateFormat dateFormatter; + final NumberFormat currencyFormatter; + final VoidCallback onEdit; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('선택된 대여 상세', style: theme.textTheme.h3), + ShadButton.outline( + leading: const Icon(LucideIcons.pencil, size: 16), + onPressed: onEdit, + child: const Text('수정'), + ), + ], + ), + description: Text( + '트랜잭션번호 ${record.transactionNumber}', + style: theme.textTheme.muted, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _DetailChip( + label: '처리일자', + value: dateFormatter.format(record.processedAt), + ), + _DetailChip(label: '창고', value: record.warehouse), + _DetailChip(label: '대여 구분', value: record.rentalType), + _DetailChip(label: '상태', value: record.status), + _DetailChip(label: '작성자', value: record.writer), + _DetailChip( + label: '반납 예정일', + value: record.returnDueDate == null + ? '-' + : dateFormatter.format(record.returnDueDate!), + ), + _DetailChip(label: '고객 수', value: '${record.customerCount}'), + _DetailChip(label: '총 수량', value: '${record.totalQuantity}'), + _DetailChip( + label: '총 금액', + value: currencyFormatter.format(record.totalAmount), + ), + ], + ), + const SizedBox(height: 16), + Text('연결 고객사', style: theme.textTheme.h4), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final customer in record.customers) + ShadBadge( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + child: Text(customer), + ), + ), + ], + ), + const SizedBox(height: 24), + Text('라인 품목', style: theme.textTheme.h4), + const SizedBox(height: 8), + SizedBox( + height: (record.items.length * 52).clamp(160, 260).toDouble(), + child: ShadTable.list( + header: const [ + ShadTableCell.header(child: Text('제품')), + ShadTableCell.header(child: Text('제조사')), + ShadTableCell.header(child: Text('단위')), + ShadTableCell.header(child: Text('수량')), + ShadTableCell.header(child: Text('단가')), + ShadTableCell.header(child: Text('비고')), + ], + children: [ + for (final item in record.items) + [ + ShadTableCell(child: Text(item.product)), + ShadTableCell(child: Text(item.manufacturer)), + ShadTableCell(child: Text(item.unit)), + ShadTableCell(child: Text('${item.quantity}')), + ShadTableCell( + child: Text(currencyFormatter.format(item.price)), + ), + ShadTableCell( + child: Text(item.remark.isEmpty ? '-' : item.remark), + ), + ], + ], + columnSpanExtent: (index) => const FixedTableSpanExtent(136), + rowSpanExtent: (index) => const FixedTableSpanExtent(52), + ), + ), + ], + ), + ); + } +} + +class _FormFieldLabel extends StatelessWidget { + const _FormFieldLabel({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, + ], + ); + } +} + +class _RentalLineItemRow extends StatelessWidget { + const _RentalLineItemRow({required this.draft, required this.onRemove}); + + final _RentalLineItemDraft draft; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ShadInput( + controller: draft.product, + placeholder: const Text('제품명'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ShadInput( + controller: draft.manufacturer, + placeholder: const Text('제조사'), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 80, + child: ShadInput( + controller: draft.unit, + placeholder: const Text('단위'), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 100, + child: ShadInput( + controller: draft.quantity, + placeholder: const Text('수량'), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 120, + child: ShadInput( + controller: draft.price, + placeholder: const Text('단가'), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 12), + Expanded( + child: ShadInput( + controller: draft.remark, + placeholder: const Text('비고'), + ), + ), + const SizedBox(width: 12), + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onRemove, + child: const Icon(LucideIcons.trash2, size: 16), + ), + ], + ); + } +} + +class _RentalLineItemDraft { + _RentalLineItemDraft._({ + required this.product, + required this.manufacturer, + required this.unit, + required this.quantity, + required this.price, + required this.remark, + }); + + final TextEditingController product; + final TextEditingController manufacturer; + final TextEditingController unit; + final TextEditingController quantity; + final TextEditingController price; + final TextEditingController remark; + + factory _RentalLineItemDraft.empty() { + return _RentalLineItemDraft._( + product: TextEditingController(), + manufacturer: TextEditingController(), + unit: TextEditingController(text: 'EA'), + quantity: TextEditingController(text: '0'), + price: TextEditingController(text: '0'), + remark: TextEditingController(), + ); + } + + factory _RentalLineItemDraft.fromItem(RentalLineItem item) { + return _RentalLineItemDraft._( + product: TextEditingController(text: item.product), + manufacturer: TextEditingController(text: item.manufacturer), + unit: TextEditingController(text: item.unit), + quantity: TextEditingController(text: '${item.quantity}'), + price: TextEditingController(text: item.price.toStringAsFixed(0)), + remark: TextEditingController(text: item.remark), + ); + } + + void dispose() { + product.dispose(); + manufacturer.dispose(); + unit.dispose(); + quantity.dispose(); + price.dispose(); + remark.dispose(); + } +} + +class RentalRecord { + RentalRecord({ + required this.number, + required this.transactionNumber, + required this.processedAt, + required this.warehouse, + required this.status, + required this.rentalType, + required this.returnDueDate, + required this.writer, + required this.remark, + required this.customers, + required this.items, + }); + + final String number; + final String transactionNumber; + final DateTime processedAt; + final String warehouse; + final String status; + final String rentalType; + final DateTime? returnDueDate; + final String writer; + final String remark; + final List customers; + final List items; + + int get customerCount => customers.length; + int get itemCount => items.length; + int get totalQuantity => + items.fold(0, (sum, item) => sum + item.quantity); + double get totalAmount => + items.fold(0, (sum, item) => sum + (item.price * item.quantity)); +} + +class RentalLineItem { + RentalLineItem({ + required this.product, + required this.manufacturer, + required this.unit, + required this.quantity, + required this.price, + required this.remark, + }); + + final String product; + final String manufacturer; + final String unit; + final int quantity; + final double price; + final String remark; +} + +final List _mockRentalRecords = [ + RentalRecord( + number: 'RENT-20240305-001', + transactionNumber: 'TX-20240305-030', + processedAt: DateTime(2024, 3, 5), + warehouse: '서울 1창고', + status: '대여중', + rentalType: '대여', + returnDueDate: DateTime(2024, 3, 12), + writer: '박대여', + remark: '장기 대여', + customers: ['슈퍼포트 파트너'], + items: [ + RentalLineItem( + product: 'XR-5000', + manufacturer: '슈퍼벤더', + unit: 'EA', + quantity: 10, + price: 120000, + remark: '검수 예정', + ), + RentalLineItem( + product: 'XR-5002', + manufacturer: '슈퍼벤더', + unit: 'EA', + quantity: 5, + price: 110000, + remark: '', + ), + ], + ), + RentalRecord( + number: 'RENT-20240308-004', + transactionNumber: 'TX-20240308-014', + processedAt: DateTime(2024, 3, 8), + warehouse: '부산 센터', + status: '반납대기', + rentalType: '대여', + returnDueDate: DateTime(2024, 3, 15), + writer: '이반납', + remark: '-', + customers: ['그린에너지', '테크솔루션'], + items: [ + RentalLineItem( + product: 'Eco-200', + manufacturer: '그린텍', + unit: 'EA', + quantity: 8, + price: 145000, + remark: '', + ), + ], + ), + RentalRecord( + number: 'RENT-20240312-006', + transactionNumber: 'TX-20240312-021', + processedAt: DateTime(2024, 3, 12), + warehouse: '대전 물류', + status: '완료', + rentalType: '반납', + returnDueDate: DateTime(2024, 3, 12), + writer: '최관리', + remark: '정상 반납', + customers: ['에이치솔루션'], + items: [ + RentalLineItem( + product: 'Delta-One', + manufacturer: '델타', + unit: 'SET', + quantity: 4, + price: 480000, + remark: '', + ), + ], + ), +]; + +class _DetailChip extends StatelessWidget { + const _DetailChip({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return ShadBadge.outline( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 2), + Text(value, style: theme.textTheme.p), + ], + ), + ), + ); + } +} diff --git a/lib/features/login/presentation/pages/login_page.dart b/lib/features/login/presentation/pages/login_page.dart new file mode 100644 index 0000000..15799b1 --- /dev/null +++ b/lib/features/login/presentation/pages/login_page.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../core/constants/app_sections.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final idController = TextEditingController(); + final passwordController = TextEditingController(); + bool rememberMe = false; + + @override + void dispose() { + idController.dispose(); + passwordController.dispose(); + super.dispose(); + } + + void _handleSubmit() { + context.go(dashboardRoutePath); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return Scaffold( + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Padding( + padding: const EdgeInsets.all(24), + child: ShadCard( + title: Text('Superport v2 로그인', style: theme.textTheme.h3), + description: Text( + '사번 또는 이메일과 비밀번호를 입력하여 대시보드로 이동합니다.', + style: theme.textTheme.muted, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ShadInput( + controller: idController, + placeholder: const Text('사번 또는 이메일'), + autofillHints: const [AutofillHints.username], + leading: const Icon(LucideIcons.user), + ), + const SizedBox(height: 16), + ShadInput( + controller: passwordController, + placeholder: const Text('비밀번호'), + obscureText: true, + autofillHints: const [AutofillHints.password], + leading: const Icon(LucideIcons.lock), + ), + const SizedBox(height: 12), + Row( + children: [ + ShadSwitch( + value: rememberMe, + onChanged: (value) => + setState(() => rememberMe = value), + ), + const SizedBox(width: 12), + Text('자동 로그인', style: theme.textTheme.small), + ], + ), + const SizedBox(height: 24), + ShadButton( + onPressed: _handleSubmit, + child: const Text('로그인'), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/masters/customer/presentation/pages/customer_page.dart b/lib/features/masters/customer/presentation/pages/customer_page.dart new file mode 100644 index 0000000..fc9cb05 --- /dev/null +++ b/lib/features/masters/customer/presentation/pages/customer_page.dart @@ -0,0 +1,66 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../../widgets/spec_page.dart'; + +class CustomerPage extends StatelessWidget { + const CustomerPage({super.key}); + + @override + Widget build(BuildContext context) { + return const SpecPage( + title: '회사(고객사) 관리', + summary: '고객사 기본 정보와 연락처, 주소를 관리합니다.', + sections: [ + SpecSection( + title: '입력 폼', + items: [ + '고객사코드 [Text]', + '고객사명 [Text]', + '유형 (파트너/일반) [Dropdown]', + '이메일 [Text]', + '연락처 [Text]', + '우편번호 [검색 연동], 상세주소 [Text]', + '사용여부 [Switch]', + '비고 [Text]', + ], + ), + SpecSection( + title: '수정 폼', + items: ['고객사코드 [ReadOnly]', '생성일시 [ReadOnly]'], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: [ + '번호', + '고객사코드', + '고객사명', + '유형', + '이메일', + '연락처', + '우편번호', + '상세주소', + '사용여부', + '비고', + ], + rows: [ + [ + '1', + 'C-001', + '슈퍼포트 파트너', + '파트너', + 'partner@superport.com', + '02-1234-5678', + '04532', + '서울시 중구 을지로 100', + 'Y', + '-', + ], + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/masters/group/presentation/pages/group_page.dart b/lib/features/masters/group/presentation/pages/group_page.dart new file mode 100644 index 0000000..5dd0885 --- /dev/null +++ b/lib/features/masters/group/presentation/pages/group_page.dart @@ -0,0 +1,41 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../../widgets/spec_page.dart'; + +class GroupPage extends StatelessWidget { + const GroupPage({super.key}); + + @override + Widget build(BuildContext context) { + return const SpecPage( + title: '그룹 관리', + summary: '권한 그룹 정의와 기본여부 설정을 제공합니다.', + sections: [ + SpecSection( + title: '입력 폼', + items: [ + '그룹명 [Text]', + '그룹설명 [Text]', + '기본여부 [Switch]', + '사용여부 [Switch]', + '비고 [Text]', + ], + ), + SpecSection( + title: '수정 폼', + items: ['그룹명 [ReadOnly]', '생성일시 [ReadOnly]'], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: ['번호', '그룹명', '설명', '기본여부', '사용여부', '비고', '변경일시'], + rows: [ + ['1', '관리자', '시스템 전체 권한', 'Y', 'Y', '-', '2024-03-01 10:00'], + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart b/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart new file mode 100644 index 0000000..3ab4305 --- /dev/null +++ b/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../../widgets/spec_page.dart'; + +class GroupPermissionPage extends StatelessWidget { + const GroupPermissionPage({super.key}); + + @override + Widget build(BuildContext context) { + return const SpecPage( + title: '그룹 메뉴 권한 관리', + summary: '그룹별 메뉴 접근과 CRUD 권한을 설정합니다.', + sections: [ + SpecSection( + title: '입력 폼', + items: [ + '그룹 [Dropdown]', + '메뉴 [Dropdown]', + '생성권한 [Checkbox]', + '조회권한 [Checkbox]', + '수정권한 [Checkbox]', + '삭제권한 [Checkbox]', + '사용여부 [Switch]', + ], + ), + SpecSection(title: '수정 폼', items: ['그룹 [ReadOnly]', '메뉴 [ReadOnly]']), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: [ + '번호', + '그룹명', + '메뉴명', + '생성', + '조회', + '수정', + '삭제', + '사용여부', + '변경일시', + ], + rows: [ + ['1', '관리자', '대시보드', 'Y', 'Y', 'Y', 'Y', 'Y', '2024-03-01 10:00'], + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/masters/menu/presentation/pages/menu_page.dart b/lib/features/masters/menu/presentation/pages/menu_page.dart new file mode 100644 index 0000000..2be2d54 --- /dev/null +++ b/lib/features/masters/menu/presentation/pages/menu_page.dart @@ -0,0 +1,52 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../../widgets/spec_page.dart'; + +class MenuPage extends StatelessWidget { + const MenuPage({super.key}); + + @override + Widget build(BuildContext context) { + return const SpecPage( + title: '메뉴 관리', + summary: '메뉴 계층, 경로, 노출 순서를 구성합니다.', + sections: [ + SpecSection( + title: '입력 폼', + items: [ + '메뉴코드 [Text]', + '메뉴명 [Text]', + '상위메뉴 [Dropdown]', + '경로 [Text]', + '표시순서 [Number]', + '사용여부 [Switch]', + '비고 [Text]', + ], + ), + SpecSection( + title: '수정 폼', + items: ['메뉴코드 [ReadOnly]', '생성일시 [ReadOnly]'], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: ['번호', '메뉴코드', '메뉴명', '상위메뉴', '경로', '사용여부', '비고', '변경일시'], + rows: [ + [ + '1', + 'MN-001', + '대시보드', + '-', + '/dashboard', + 'Y', + '-', + '2024-03-01 10:00', + ], + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/masters/product/presentation/pages/product_page.dart b/lib/features/masters/product/presentation/pages/product_page.dart new file mode 100644 index 0000000..439c6ec --- /dev/null +++ b/lib/features/masters/product/presentation/pages/product_page.dart @@ -0,0 +1,51 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../../widgets/spec_page.dart'; + +class ProductPage extends StatelessWidget { + const ProductPage({super.key}); + + @override + Widget build(BuildContext context) { + return const SpecPage( + title: '장비 모델(제품) 관리', + summary: '제품 코드, 제조사, 단위 정보를 유지하여 재고 라인과 연계합니다.', + sections: [ + SpecSection( + title: '입력 폼', + items: [ + '제품코드 [Text]', + '제품명 [Text]', + '제조사 [Dropdown]', + '단위 [Dropdown]', + '사용여부 [Switch]', + '비고 [Text]', + ], + ), + SpecSection( + title: '수정 폼', + items: ['제품코드 [ReadOnly]', '생성일시 [ReadOnly]'], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: ['번호', '제품코드', '제품명', '제조사', '단위', '사용여부', '비고', '변경일시'], + rows: [ + [ + '1', + 'P-100', + 'XR-5000', + '슈퍼벤더', + 'EA', + 'Y', + '-', + '2024-03-01 10:00', + ], + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/masters/user/presentation/pages/user_page.dart b/lib/features/masters/user/presentation/pages/user_page.dart new file mode 100644 index 0000000..f2dd819 --- /dev/null +++ b/lib/features/masters/user/presentation/pages/user_page.dart @@ -0,0 +1,60 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../../widgets/spec_page.dart'; + +class UserPage extends StatelessWidget { + const UserPage({super.key}); + + @override + Widget build(BuildContext context) { + return const SpecPage( + title: '사용자(사원) 관리', + summary: '사번 기반 계정과 그룹, 사용 상태를 관리합니다.', + sections: [ + SpecSection( + title: '입력 폼', + items: [ + '사번 [Text]', + '성명 [Text]', + '이메일 [Text]', + '연락처 [Text]', + '그룹 [Dropdown]', + '사용여부 [Switch]', + '비고 [Text]', + ], + ), + SpecSection(title: '수정 폼', items: ['사번 [ReadOnly]', '생성일시 [ReadOnly]']), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: [ + '번호', + '사번', + '성명', + '이메일', + '연락처', + '그룹', + '사용여부', + '비고', + '변경일시', + ], + rows: [ + [ + '1', + 'A0001', + '김철수', + 'kim@superport.com', + '010-1111-2222', + '관리자', + 'Y', + '-', + '2024-03-01 10:00', + ], + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/masters/vendor/data/dtos/vendor_dto.dart b/lib/features/masters/vendor/data/dtos/vendor_dto.dart new file mode 100644 index 0000000..71cba96 --- /dev/null +++ b/lib/features/masters/vendor/data/dtos/vendor_dto.dart @@ -0,0 +1,80 @@ +import '../../domain/entities/vendor.dart'; + +/// 벤더 DTO (JSON 직렬화/역직렬화) +class VendorDto { + VendorDto({ + this.id, + required this.vendorCode, + required this.vendorName, + this.isActive = true, + this.isDeleted = false, + this.note, + this.createdAt, + this.updatedAt, + }); + + final int? id; + final String vendorCode; + final String vendorName; + final bool isActive; + final bool isDeleted; + final String? note; + final DateTime? createdAt; + final DateTime? updatedAt; + + factory VendorDto.fromJson(Map json) { + return VendorDto( + id: json['id'] as int?, + vendorCode: json['vendor_code'] as String, + vendorName: json['vendor_name'] as String, + isActive: (json['is_active'] as bool?) ?? true, + isDeleted: (json['is_deleted'] as bool?) ?? false, + note: json['note'] as String?, + createdAt: _parseDate(json['created_at']), + updatedAt: _parseDate(json['updated_at']), + ); + } + + Map toJson() { + return { + if (id != null) 'id': id, + 'vendor_code': vendorCode, + 'vendor_name': vendorName, + 'is_active': isActive, + 'is_deleted': isDeleted, + 'note': note, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } + + Vendor toEntity() => Vendor( + id: id, + vendorCode: vendorCode, + vendorName: vendorName, + isActive: isActive, + isDeleted: isDeleted, + note: note, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + static VendorDto fromEntity(Vendor entity) => VendorDto( + id: entity.id, + vendorCode: entity.vendorCode, + vendorName: entity.vendorName, + isActive: entity.isActive, + isDeleted: entity.isDeleted, + note: entity.note, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + ); +} + +DateTime? _parseDate(Object? value) { + if (value == null) return null; + if (value is DateTime) return value; + if (value is String) return DateTime.tryParse(value); + return null; +} + diff --git a/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart b/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart new file mode 100644 index 0000000..19645cb --- /dev/null +++ b/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart @@ -0,0 +1,70 @@ +import 'package:dio/dio.dart'; + +import '../../domain/entities/vendor.dart'; +import '../../domain/repositories/vendor_repository.dart'; +import '../dtos/vendor_dto.dart'; +import '../../../../../core/network/api_client.dart'; + +/// 원격 구현체: 공통 ApiClient(Dio) 사용 +class VendorRepositoryRemote implements VendorRepository { + VendorRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; + + final ApiClient _api; + + static const _basePath = '/vendors'; // TODO: 백엔드 경로 확정 시 수정 + + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + bool includeInactive = true, + }) async { + final response = await _api.get>( + _basePath, + query: { + 'page': page, + 'page_size': pageSize, + if (query != null && query.isNotEmpty) 'q': query, + if (includeInactive) 'include': 'inactive', + }, + options: Options(responseType: ResponseType.json), + ); + final data = response.data ?? []; + return data + .whereType>() + .map((e) => VendorDto.fromJson(e).toEntity()) + .toList(); + } + + @override + Future create(Vendor vendor) async { + final dto = VendorDto.fromEntity(vendor); + final response = await _api.post>( + _basePath, + data: dto.toJson(), + options: Options(responseType: ResponseType.json), + ); + return VendorDto.fromJson(response.data ?? {}).toEntity(); + } + + @override + Future update(Vendor vendor) async { + if (vendor.id == null) { + throw ArgumentError('id가 없는 엔티티는 수정할 수 없습니다.'); + } + final dto = VendorDto.fromEntity(vendor); + final response = await _api.patch>( + '$_basePath/${vendor.id}', + data: dto.toJson(), + options: Options(responseType: ResponseType.json), + ); + return VendorDto.fromJson(response.data ?? {}).toEntity(); + } + + @override + Future delete(int id) async { + await _api.delete('$_basePath/$id'); + } +} + diff --git a/lib/features/masters/vendor/domain/entities/vendor.dart b/lib/features/masters/vendor/domain/entities/vendor.dart new file mode 100644 index 0000000..b5570a0 --- /dev/null +++ b/lib/features/masters/vendor/domain/entities/vendor.dart @@ -0,0 +1,61 @@ +/// 벤더(제조사) 도메인 엔티티 +/// +/// - SRP: 벤더의 속성 표현만 담당 +/// - data/presentation 레이어에 의존하지 않음 +class Vendor { + Vendor({ + this.id, + required this.vendorCode, + required this.vendorName, + this.isActive = true, + this.isDeleted = false, + this.note, + this.createdAt, + this.updatedAt, + }); + + /// PK (DB bigint), 신규 생성 시 null + final int? id; + + /// 벤더코드 (부분유니크: is_deleted=false) + final String vendorCode; + + /// 벤더명 + final String vendorName; + + /// 사용 여부 + final bool isActive; + + /// 소프트 삭제 여부 + final bool isDeleted; + + /// 비고 + final String? note; + + /// 생성/변경 일시 (선택) + final DateTime? createdAt; + final DateTime? updatedAt; + + Vendor copyWith({ + int? id, + String? vendorCode, + String? vendorName, + bool? isActive, + bool? isDeleted, + String? note, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return Vendor( + id: id ?? this.id, + vendorCode: vendorCode ?? this.vendorCode, + vendorName: vendorName ?? this.vendorName, + isActive: isActive ?? this.isActive, + isDeleted: isDeleted ?? this.isDeleted, + note: note ?? this.note, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} + diff --git a/lib/features/masters/vendor/domain/repositories/vendor_repository.dart b/lib/features/masters/vendor/domain/repositories/vendor_repository.dart new file mode 100644 index 0000000..7035a74 --- /dev/null +++ b/lib/features/masters/vendor/domain/repositories/vendor_repository.dart @@ -0,0 +1,27 @@ +import '../entities/vendor.dart'; + +/// 벤더 리포지토리 인터페이스 +/// +/// - presentation → domain → data 방향을 보장하기 위해 domain에 위치 +/// - 실제 구현은 data 레이어에서 제공한다. +abstract class VendorRepository { + /// 벤더 목록 조회 + /// + /// - 표준 쿼리 파라미터: page, page_size, q, include + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + bool includeInactive = true, + }); + + /// 벤더 생성 + Future create(Vendor vendor); + + /// 벤더 수정 (부분 업데이트 포함) + Future update(Vendor vendor); + + /// 벤더 소프트 삭제 + Future delete(int id); +} + diff --git a/lib/features/masters/vendor/presentation/pages/vendor_page.dart b/lib/features/masters/vendor/presentation/pages/vendor_page.dart new file mode 100644 index 0000000..e5d840f --- /dev/null +++ b/lib/features/masters/vendor/presentation/pages/vendor_page.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../../core/config/environment.dart'; +import '../../../../../widgets/spec_page.dart'; +import '../../../vendor/domain/entities/vendor.dart'; +import '../../../vendor/domain/repositories/vendor_repository.dart'; + +class VendorPage extends StatelessWidget { + const VendorPage({super.key}); + + @override + Widget build(BuildContext context) { + final enabled = Environment.flag('FEATURE_VENDORS_ENABLED'); + if (!enabled) { + return SpecPage( + title: '제조사(벤더) 관리', + summary: '벤더 기본 정보를 등록하고 사용여부를 제어합니다.', + trailing: ShadBadge( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(LucideIcons.info, size: 14), + const SizedBox(width: 6), + Text('비활성화 (백엔드 준비 중)'), + ], + ), + ), + ), + sections: const [ + SpecSection( + title: '입력 폼', + items: ['벤더코드 [Text]', '벤더명 [Text]', '사용여부 [Switch]', '비고 [Text]'], + ), + SpecSection( + title: '수정 폼', + items: ['벤더코드 [ReadOnly]', '생성일시 [ReadOnly]', '수정일시 [ReadOnly]'], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: ['번호', '벤더코드', '벤더명', '사용여부', '비고', '변경일시'], + rows: [ + ['1', 'V-001', '슈퍼벤더', 'Y', '-', '2024-03-01 10:00'], + ], + ), + ), + ], + ); + } + + return const _VendorEnabledPage(); + } +} + +class _VendorEnabledPage extends StatefulWidget { + const _VendorEnabledPage(); + + @override + State<_VendorEnabledPage> createState() => _VendorEnabledPageState(); +} + +class _VendorEnabledPageState extends State<_VendorEnabledPage> { + final _repo = GetIt.I(); + final _loading = ValueNotifier(false); + final _vendors = ValueNotifier>([]); + + @override + void dispose() { + _loading.dispose(); + _vendors.dispose(); + super.dispose(); + } + + Future _load() async { + _loading.value = true; + try { + final list = await _repo.list(page: 1, pageSize: 50); + _vendors.value = list; + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('벤더 조회 실패: $e')), + ); + } + } finally { + _loading.value = false; + } + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('제조사(벤더) 관리', style: theme.textTheme.h2), + const SizedBox(height: 6), + Text('벤더코드, 명칭, 사용여부 관리', style: theme.textTheme.muted), + ], + ), + Row( + children: [ + ValueListenableBuilder( + valueListenable: _loading, + builder: (_, loading, __) { + return ShadButton( + onPressed: loading ? null : _load, + child: Text(loading ? '로딩 중...' : '데이터 조회'), + ); + }, + ), + ], + ), + ], + ), + const SizedBox(height: 16), + ShadCard( + title: Text('벤더 목록', style: theme.textTheme.h3), + child: ValueListenableBuilder>( + valueListenable: _vendors, + builder: (_, vendors, __) { + if (vendors.isEmpty) { + return Padding( + padding: const EdgeInsets.all(24), + child: Text('데이터가 없습니다. 상단의 "데이터 조회"를 눌러주세요.', + style: theme.textTheme.muted), + ); + } + return SizedBox( + height: 56.0 * (vendors.length + 1), + child: ShadTable.list( + header: const [ + 'ID', + '벤더코드', + '벤더명', + '사용', + '비고', + '변경일시', + ].map((h) => ShadTableCell.header(child: Text(h))).toList(), + children: vendors + .map( + (v) => [ + '${v.id ?? '-'}', + v.vendorCode, + v.vendorName, + v.isActive ? 'Y' : 'N', + v.note ?? '-', + v.updatedAt?.toIso8601String() ?? '-', + ].map((c) => ShadTableCell(child: Text(c))).toList(), + ) + .toList(), + columnSpanExtent: (index) => const FixedTableSpanExtent(160), + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart new file mode 100644 index 0000000..72f6e57 --- /dev/null +++ b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart @@ -0,0 +1,60 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../../widgets/spec_page.dart'; + +class WarehousePage extends StatelessWidget { + const WarehousePage({super.key}); + + @override + Widget build(BuildContext context) { + return const SpecPage( + title: '입고지(창고) 관리', + summary: '창고 주소와 사용 여부를 구성합니다.', + sections: [ + SpecSection( + title: '입력 폼', + items: [ + '창고코드 [Text]', + '창고명 [Text]', + '우편번호 [검색 연동]', + '상세주소 [Text]', + '사용여부 [Switch]', + '비고 [Text]', + ], + ), + SpecSection( + title: '수정 폼', + items: ['창고코드 [ReadOnly]', '생성일시 [ReadOnly]'], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: [ + '번호', + '창고코드', + '창고명', + '우편번호', + '상세주소', + '사용여부', + '비고', + '변경일시', + ], + rows: [ + [ + '1', + 'WH-01', + '서울 1창고', + '04532', + '서울시 중구 을지로 100', + 'Y', + '-', + '2024-03-01 10:00', + ], + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/reporting/presentation/pages/reporting_page.dart b/lib/features/reporting/presentation/pages/reporting_page.dart new file mode 100644 index 0000000..cbfcb5b --- /dev/null +++ b/lib/features/reporting/presentation/pages/reporting_page.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../widgets/spec_page.dart'; + +class ReportingPage extends StatelessWidget { + const ReportingPage({super.key}); + + @override + Widget build(BuildContext context) { + return const SpecPage( + title: '보고서', + summary: '기간, 유형, 창고, 상태 조건으로 보고서를 조회하고 내보냅니다.', + sections: [ + SpecSection( + title: '조건 입력', + items: [ + '기간 [Date Range]', + '유형 [Dropdown]', + '창고 [Dropdown]', + '상태 [Dropdown]', + ], + ), + SpecSection( + title: '출력 옵션', + items: ['XLSX 다운로드 [Button]', 'PDF 다운로드 [Button]'], + ), + ], + ); + } +} diff --git a/lib/features/util/postal_search/presentation/pages/postal_search_page.dart b/lib/features/util/postal_search/presentation/pages/postal_search_page.dart new file mode 100644 index 0000000..a13e1c7 --- /dev/null +++ b/lib/features/util/postal_search/presentation/pages/postal_search_page.dart @@ -0,0 +1,25 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../../widgets/spec_page.dart'; + +class PostalSearchPage extends StatelessWidget { + const PostalSearchPage({super.key}); + + @override + Widget build(BuildContext context) { + return const SpecPage( + title: '우편번호 검색', + summary: '모달 기반 우편번호 검색 UI 구성을 정의합니다.', + sections: [ + SpecSection( + title: '모달 구성', + items: [ + '검색어 [Text] 입력 필드', + '결과 리스트: 우편번호 | 시도 | 시군구 | 도로명 | 건물번호', + '선택 시 호출 화면에 우편번호/주소 전달', + ], + ), + ], + ); + } +} diff --git a/lib/injection_container.dart b/lib/injection_container.dart new file mode 100644 index 0000000..df8c82a --- /dev/null +++ b/lib/injection_container.dart @@ -0,0 +1,42 @@ +// ignore_for_file: public_member_api_docs + +import 'package:dio/dio.dart'; +import 'package:get_it/get_it.dart'; + +import 'core/network/api_client.dart'; +import 'core/network/interceptors/auth_interceptor.dart'; +import 'features/masters/vendor/data/repositories/vendor_repository_remote.dart'; +import 'features/masters/vendor/domain/repositories/vendor_repository.dart'; + +/// 전역 DI 컨테이너 +final GetIt sl = GetIt.instance; + +/// 의존성 등록(스켈레톤) +/// - Environment.initialize() 이후 호출하여 baseUrl/타임아웃 등을 주입한다. +Future initInjection({required String baseUrl, Duration? connectTimeout, Duration? receiveTimeout}) async { + // Dio 기본 옵션 설정 + final options = BaseOptions( + baseUrl: baseUrl, + connectTimeout: connectTimeout ?? const Duration(seconds: 15), + receiveTimeout: receiveTimeout ?? const Duration(seconds: 30), + headers: const { + 'Accept': 'application/json', + }, + ); + + final dio = Dio(options); + + // 인터셉터 등록 (Auth 등) + dio.interceptors.add(AuthInterceptor()); + + // 개발용 로거는 필요 시 추가 (pretty_dio_logger 등) + // if (!kReleaseMode) { dio.interceptors.add(PrettyDioLogger(...)); } + + // ApiClient 등록 + sl.registerLazySingleton(() => ApiClient(dio: dio)); + + // 리포지토리 등록 (예: 벤더) + sl.registerLazySingleton( + () => VendorRepositoryRemote(apiClient: sl()), + ); +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..b9c82c6 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'core/config/environment.dart'; +import 'core/routing/app_router.dart'; +import 'injection_container.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Environment.initialize(); + await initInjection(baseUrl: Environment.baseUrl); + runApp(const SuperportApp()); +} + +class SuperportApp extends StatelessWidget { + const SuperportApp({super.key}); + + @override + Widget build(BuildContext context) { + return ShadApp.router( + title: 'Superport v2', + routerConfig: appRouter, + debugShowCheckedModeBanner: false, + supportedLocales: const [Locale('ko', 'KR'), Locale('en', 'US')], + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + theme: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + darkTheme: ShadThemeData( + colorScheme: const ShadSlateColorScheme.dark(), + brightness: Brightness.dark, + ), + ); + } +} diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart new file mode 100644 index 0000000..b8d45ee --- /dev/null +++ b/lib/widgets/app_shell.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../core/constants/app_sections.dart'; + +class AppShell extends StatelessWidget { + const AppShell({ + super.key, + required this.child, + required this.currentLocation, + }); + + final Widget child; + final String currentLocation; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= 960; + if (isWide) { + return Scaffold( + appBar: AppBar( + title: const Text('Superport v2'), + actions: [ + IconButton( + tooltip: '로그아웃', + icon: const Icon(LucideIcons.logOut), + onPressed: () => context.go(loginRoutePath), + ), + ], + ), + body: Row( + children: [ + _NavigationRail(currentLocation: currentLocation), + const VerticalDivider(width: 1), + Expanded(child: child), + ], + ), + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text('Superport v2'), + actions: [ + IconButton( + tooltip: '로그아웃', + icon: const Icon(LucideIcons.logOut), + onPressed: () => context.go(loginRoutePath), + ), + ], + ), + drawer: Drawer( + child: SafeArea( + child: _NavigationList( + currentLocation: currentLocation, + onTap: (path) { + Navigator.of(context).pop(); + context.go(path); + }, + ), + ), + ), + body: child, + ); + }, + ); + } +} + +class _NavigationRail extends StatelessWidget { + const _NavigationRail({required this.currentLocation}); + + final String currentLocation; + + @override + Widget build(BuildContext context) { + final pages = allAppPages; + final selectedIndex = _selectedIndex(currentLocation, pages); + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + width: 104, + decoration: BoxDecoration( + border: Border(right: BorderSide(color: colorScheme.outlineVariant)), + ), + child: Column( + children: [ + const SizedBox(height: 24), + const FlutterLogo(size: 48), + const SizedBox(height: 24), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: pages.length, + itemBuilder: (context, index) { + final page = pages[index]; + final isSelected = index == selectedIndex; + final textStyle = theme.textTheme.labelSmall?.copyWith( + color: isSelected + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Material( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.12) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + if (page.path != currentLocation) { + context.go(page.path); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + page.icon, + size: 22, + color: isSelected + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 6), + Text( + page.label, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textStyle, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _NavigationList extends StatelessWidget { + const _NavigationList({required this.currentLocation, required this.onTap}); + + final String currentLocation; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) { + final pages = allAppPages; + final selectedIndex = _selectedIndex(currentLocation, pages); + + return ListView.builder( + itemCount: pages.length, + itemBuilder: (context, index) { + final page = pages[index]; + final selected = index == selectedIndex; + return ListTile( + leading: Icon(page.icon), + title: Text(page.label), + subtitle: Text( + page.summary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + selected: selected, + selectedColor: Theme.of(context).colorScheme.primary, + onTap: () => onTap(page.path), + ); + }, + ); + } +} + +int _selectedIndex(String location, List pages) { + final normalized = location.toLowerCase(); + final exact = pages.indexWhere( + (page) => normalized == page.path.toLowerCase(), + ); + if (exact != -1) { + return exact; + } + + final prefix = pages.indexWhere( + (page) => normalized.startsWith(page.path.toLowerCase()), + ); + return prefix == -1 ? 0 : prefix; +} diff --git a/lib/widgets/spec_page.dart b/lib/widgets/spec_page.dart new file mode 100644 index 0000000..44c50e9 --- /dev/null +++ b/lib/widgets/spec_page.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class SpecTable { + const SpecTable({ + required this.columns, + required this.rows, + this.columnWidth, + }); + + final List columns; + final List> rows; + final double? columnWidth; +} + +class SpecSection { + const SpecSection({ + required this.title, + this.items = const [], + this.description, + this.table, + }); + + final String title; + final List items; + final String? description; + final SpecTable? table; +} + +class SpecPage extends StatelessWidget { + const SpecPage({ + super.key, + required this.title, + required this.summary, + required this.sections, + this.trailing, + }); + + final String title; + final String summary; + final List sections; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return SelectionArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1200), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.h2), + const SizedBox(height: 12), + Text(summary, style: theme.textTheme.lead), + ], + ), + ), + if (trailing != null) ...[ + const SizedBox(width: 24), + trailing!, + ], + ], + ), + const SizedBox(height: 32), + ...sections.map( + (section) => Padding( + padding: const EdgeInsets.only(bottom: 24), + child: ShadCard( + title: Text( + section.title, + style: theme.textTheme.h3.copyWith( + color: theme.colorScheme.foreground, + ), + ), + description: section.description == null + ? null + : Text( + section.description!, + style: theme.textTheme.muted, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (section.items.isNotEmpty) ...[ + for (final item in section.items) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 6), + child: Icon( + LucideIcons.dot, + size: 10, + color: + theme.colorScheme.mutedForeground, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + item, + style: theme.textTheme.p, + ), + ), + ], + ), + ), + ], + if (section.table != null) ...[ + if (section.items.isNotEmpty) + const SizedBox(height: 16), + _SpecTableView(table: section.table!), + ], + ], + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _SpecTableView extends StatelessWidget { + const _SpecTableView({required this.table}); + + final SpecTable table; + + @override + Widget build(BuildContext context) { + final headerCells = table.columns + .map((column) => ShadTableCell.header(child: Text(column))) + .toList(growable: false); + final rowCells = table.rows + .map( + (row) => row + .map((cell) => ShadTableCell(child: Text(cell))) + .toList(growable: false), + ) + .toList(growable: false); + + final rowCount = table.rows.length; + final baseHeight = 56.0; // default row height with some breathing room + final height = (rowCount + 1) * baseHeight; + + return SizedBox( + height: height, + child: ShadTable.list( + header: headerCells, + children: rowCells, + columnSpanExtent: (index) => + FixedTableSpanExtent(table.columnWidth ?? 160), + ), + ); + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..f8d7301 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "superport_v2") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.superport_v2") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..e71a16d --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..2e1de87 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..de8d667 --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,144 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView *view) +{ + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "superport_v2"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "superport_v2"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..e777c67 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,12 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import path_provider_foundation + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..3f2935b --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* superport_v2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "superport_v2.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* superport_v2.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* superport_v2.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.superportV2.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/superport_v2.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/superport_v2"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.superportV2.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/superport_v2.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/superport_v2"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.superportV2.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/superport_v2.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/superport_v2"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..219e1fa --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..d5e32b5 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = superport_v2 + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.superportV2 + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..e64a4ad --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,567 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + boxy: + dependency: transitive + description: + name: boxy + sha256: "71af0cd1bf7889c09787f26219a345aa4f38ccb98384c8ec24189e4d8e746005" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dio: + dependency: "direct main" + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.dev" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + extended_image: + dependency: transitive + description: + name: extended_image + sha256: f6cbb1d798f51262ed1a3d93b4f1f2aa0d76128df39af18ecb77fa740f88b2e0 + url: "https://pub.dev" + source: hosted + version: "10.0.1" + extended_image_library: + dependency: transitive + description: + name: extended_image_library + sha256: "1f9a24d3a00c2633891c6a7b5cab2807999eb2d5b597e5133b63f49d113811fe" + url: "https://pub.dev" + source: hosted + version: "5.0.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_animate: + dependency: transitive + description: + name: flutter_animate + sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5" + url: "https://pub.dev" + source: hosted + version: "4.5.2" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b + url: "https://pub.dev" + source: hosted + version: "5.2.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + get_it: + dependency: "direct main" + description: + name: get_it + sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1 + url: "https://pub.dev" + source: hosted + version: "7.7.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: b1488741c9ce37b72e026377c69a59c47378493156fc38efb5a54f6def3f92a3 + url: "https://pub.dev" + source: hosted + version: "16.2.2" + http: + dependency: transitive + description: + name: http + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + url: "https://pub.dev" + source: hosted + version: "1.5.0" + http_client_helper: + dependency: transitive + description: + name: http_client_helper + sha256: "8a9127650734da86b5c73760de2b404494c968a3fd55602045ffec789dac3cb1" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + lucide_icons_flutter: + dependency: "direct main" + description: + name: lucide_icons_flutter + sha256: "68d4082b26ea1a89f4431eb44642a47748a5931cdf88323100064180482a1f51" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db" + url: "https://pub.dev" + source: hosted + version: "2.2.18" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pretty_dio_logger: + dependency: "direct dev" + description: + name: pretty_dio_logger + sha256: "36f2101299786d567869493e2f5731de61ce130faa14679473b26905a92b6407" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + shadcn_ui: + dependency: "direct main" + description: + name: shadcn_ui + sha256: "11c33f47634641fb9ab71aeb31402d8f513440c4b61b8e4873f15ace7d3ad559" + url: "https://pub.dev" + source: hosted + version: "0.31.7" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + two_dimensional_scrollables: + dependency: "direct main" + description: + name: two_dimensional_scrollables + sha256: "0f77ecb96596f2f82eec2b0a8e60d9305c58315557da9fa3b610c7dbf5ded621" + url: "https://pub.dev" + source: hosted + version: "0.3.7" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_image: + dependency: transitive + description: + name: universal_image + sha256: ef47a4a002158cf0b36ed3b7605af132d2476cc42703e41b8067d3603705c40d + url: "https://pub.dev" + source: hosted + version: "1.0.11" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" +sdks: + dart: ">=3.9.2 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..b8aa5e2 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,100 @@ +name: superport_v2 +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.9.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + go_router: ^16.2.2 + shadcn_ui: ^0.31.7 + intl: ^0.20.2 + two_dimensional_scrollables: ^0.3.7 + lucide_icons_flutter: ^3.1.0 + dio: ^5.5.0+1 + get_it: ^7.7.0 + flutter_dotenv: ^5.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + pretty_dio_logger: ^1.3.1 + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..f615a23 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:superport_v2/main.dart'; + +void main() { + testWidgets('로그인 버튼을 누르면 대시보드로 이동한다', (tester) async { + await tester.pumpWidget(const SuperportApp()); + await tester.pumpAndSettle(); + + expect(find.text('Superport v2 로그인'), findsOneWidget); + + await tester.tap(find.text('로그인')); + await tester.pumpAndSettle(); + + expect(find.text('대시보드'), findsOneWidget); + }); +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..6326971 --- /dev/null +++ b/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + superport_v2 + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..fb3601f --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "superport_v2", + "short_name": "superport_v2", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..202df2c --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(superport_v2 LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "superport_v2") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..8b6d468 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..b93c4c3 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..cbf92f7 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "superport_v2" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "superport_v2" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "superport_v2.exe" "\0" + VALUE "ProductName", "superport_v2" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..dcb3b6c --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"superport_v2", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_