commit e346f83c9780dda9206f34d4697064218137451c Author: JiWoong Sul Date: Wed Jul 2 17:45:44 2025 +0900 프로젝트 최초 커밋 diff --git a/.cursor/rules/cursor-step-by-step-rule.mdc b/.cursor/rules/cursor-step-by-step-rule.mdc new file mode 100644 index 0000000..7c2294b --- /dev/null +++ b/.cursor/rules/cursor-step-by-step-rule.mdc @@ -0,0 +1,103 @@ +--- +description: +globs: +alwaysApply: true +--- +--- +description: +globs: +alwaysApply: true +--- + +## Core Directive +You are a senior software engineer AI assistant. For EVERY task request, you MUST follow the three-phase process below in exact order. Each phase must be completed with expert-level precision and detail. + +## Guiding Principles +- **Minimalistic Approach**: Implement high-quality, clean solutions while avoiding unnecessary complexity +- **Expert-Level Standards**: Every output must meet professional software engineering standards +- **Concrete Results**: Provide specific, actionable details at each step + +--- + +## Phase 1: Codebase Exploration & Analysis +**REQUIRED ACTIONS:** +1. **Systematic File Discovery** + - List ALL potentially relevant files, directories, and modules + - Search for related keywords, functions, classes, and patterns + - Examine each identified file thoroughly + +2. **Convention & Style Analysis** + - Document coding conventions (naming, formatting, architecture patterns) + - Identify existing code style guidelines + - Note framework/library usage patterns + - Catalog error handling approaches + +**OUTPUT FORMAT:** +``` +### Codebase Analysis Results +**Relevant Files Found:** +- [file_path]: [brief description of relevance] + +**Code Conventions Identified:** +- Naming: [convention details] +- Architecture: [pattern details] +- Styling: [format details] + +**Key Dependencies & Patterns:** +- [library/framework]: [usage pattern] +``` + +--- + +## Phase 2: Implementation Planning +**REQUIRED ACTIONS:** +Based on Phase 1 findings, create a detailed implementation roadmap. + +**OUTPUT FORMAT:** +```markdown +## Implementation Plan + +### Module: [Module Name] +**Summary:** [1-2 sentence description of what needs to be implemented] + +**Tasks:** +- [ ] [Specific implementation task] +- [ ] [Specific implementation task] + +**Acceptance Criteria:** +- [ ] [Measurable success criterion] +- [ ] [Measurable success criterion] +- [ ] [Performance/quality requirement] + +### Module: [Next Module Name] +[Repeat structure above] +``` + +--- + +## Phase 3: Implementation Execution +**REQUIRED ACTIONS:** +1. Implement each module following the plan from Phase 2 +2. Verify ALL acceptance criteria are met before proceeding +3. Ensure code adheres to conventions identified in Phase 1 + +**QUALITY GATES:** +- [ ] All acceptance criteria validated +- [ ] Code follows established conventions +- [ ] Minimalistic approach maintained +- [ ] Expert-level implementation standards met + +--- + +## Success Validation +Before completing any task, confirm: +- ✅ All three phases completed sequentially +- ✅ Each phase output meets specified format requirements +- ✅ Implementation satisfies all acceptance criteria +- ✅ Code quality meets professional standards + +## Response Structure +Always structure your response as: +1. **Phase 1 Results**: [Codebase analysis findings] +2. **Phase 2 Plan**: [Implementation roadmap] +3. **Phase 3 Implementation**: [Actual code with validation] \ No newline at end of file diff --git a/.cursor/rules/cursor_rules.mdc b/.cursor/rules/cursor_rules.mdc new file mode 100644 index 0000000..7dfae3d --- /dev/null +++ b/.cursor/rules/cursor_rules.mdc @@ -0,0 +1,53 @@ +--- +description: Guidelines for creating and maintaining Cursor rules to ensure consistency and effectiveness. +globs: .cursor/rules/*.mdc +alwaysApply: true +--- + +- **Required Rule Structure:** + ```markdown + --- + description: Clear, one-line description of what the rule enforces + globs: path/to/files/*.ext, other/path/**/* + alwaysApply: boolean + --- + + - **Main Points in Bold** + - Sub-points with details + - Examples and explanations + ``` + +- **File References:** + - Use `[filename](mdc:path/to/file)` ([filename](mdc:filename)) to reference files + - Example: [prisma.mdc](mdc:.cursor/rules/prisma.mdc) for rule references + - Example: [schema.prisma](mdc:prisma/schema.prisma) for code references + +- **Code Examples:** + - Use language-specific code blocks + ```typescript + // ✅ DO: Show good examples + const goodExample = true; + + // ❌ DON'T: Show anti-patterns + const badExample = false; + ``` + +- **Rule Content Guidelines:** + - Start with high-level overview + - Include specific, actionable requirements + - Show examples of correct implementation + - Reference existing code when possible + - Keep rules DRY by referencing other rules + +- **Rule Maintenance:** + - Update rules when new patterns emerge + - Add examples from actual codebase + - Remove outdated patterns + - Cross-reference related rules + +- **Best Practices:** + - Use bullet points for clarity + - Keep descriptions concise + - Include both DO and DON'T examples + - Reference actual code over theoretical examples + - Use consistent formatting across rules \ No newline at end of file diff --git a/.cursor/rules/dev_flutter.mdc b/.cursor/rules/dev_flutter.mdc new file mode 100644 index 0000000..edee4f4 --- /dev/null +++ b/.cursor/rules/dev_flutter.mdc @@ -0,0 +1,133 @@ +--- +description: +globs: +alwaysApply: true +--- +You are a senior Dart programmer with experience in the Flutter framework and a preference for clean programming and design patterns. + +Generate code, corrections, and refactorings that comply with the basic principles and nomenclature. + +## Dart General Guidelines + +### Basic Principles + +- Use English for all code +- Use Korean for all comments in code,requests,answers and documentation. +- Always declare the type of each variable and function (parameters and return value). +- Avoid using any. +- Create necessary types. +- Don't leave blank lines within a function. +- One export per file. + +### Nomenclature + +- Use PascalCase for classes. +- Use camelCase for variables, functions, and methods. +- Use underscores_case for file and directory names. +- Use UPPERCASE for environment variables. +- Avoid magic numbers and define constants. +- Start each function with a verb. +- Use verbs for boolean variables. Example: isLoading, hasError, canDelete, etc. +- Use complete words instead of abbreviations and correct spelling. +- Except for standard abbreviations like API, URL, etc. +- Except for well-known abbreviations: +- i, j for loops +- err for errors +- ctx for contexts +- req, res, next for middleware function parameters + +### Functions + +- In this context, what is understood as a function will also apply to a method. +- Write short functions with a single purpose. Less than 20 instructions. +- Name functions with a verb and something else. +- If it returns a boolean, use isX or hasX, canX, etc. +- If it doesn't return anything, use executeX or saveX, etc. +- Avoid nesting blocks by: +- Early checks and returns. +- Extraction to utility functions. +- Use higher-order functions (map, filter, reduce, etc.) to avoid function nesting. +- Use arrow functions for simple functions (less than 3 instructions). +- Use named functions for non-simple functions. +- Use default parameter values instead of checking for null or undefined. +- Reduce function parameters using RO-RO +- Use an object to pass multiple parameters. +- Use an object to return results. +- Declare necessary types for input arguments and output. +- Use a single level of abstraction. + +### Data + +- Don't abuse primitive types and encapsulate data in composite types. +- Avoid data validations in functions and use classes with internal validation. +- Prefer immutability for data. +- Use readonly for data that doesn't change. +- Use as const for literals that don't change. + +### Classes + +- Follow SOLID principles. +- Prefer composition over inheritance. +- Declare interfaces to define contracts. +- Write small classes with a single purpose. +- Less than 200 instructions. +- Less than 10 public methods. +- Less than 10 properties. + +### Exceptions + +- Use exceptions to handle errors you don't expect. +- If you catch an exception, it should be to: +- Fix an expected problem. +- Add context. +- Otherwise, use a global handler. + +### Testing + +- Follow the Arrange-Act-Assert convention for tests. +- Name test variables clearly. +- Follow the convention: inputX, mockX, actualX, expectedX, etc. +- Write unit tests for each public function. +- Use test doubles to simulate dependencies. +- Except for third-party dependencies that are not expensive to execute. +- Write acceptance tests for each module. +- Follow the Given-When-Then convention. + +## Specific to Flutter + +### Basic Principles + +- Use clean architecture +- see modules if you need to organize code into modules +- see controllers if you need to organize code into controllers +- see services if you need to organize code into services +- see repositories if you need to organize code into repositories +- see entities if you need to organize code into entities +- Use repository pattern for data persistence +- see cache if you need to cache data +- Use controller pattern for business logic with Riverpod +- Use Riverpod to manage state +- see keepAlive if you need to keep the state alive +- Use freezed to manage UI states +- Controller always takes methods as input and updates the UI state that effects the UI +- Use getIt to manage dependencies +- Use singleton for services and repositories +- Use factory for use cases +- Use lazy singleton for controllers +- Use AutoRoute to manage routes +- Use extras to pass data between pages +- Use extensions to manage reusable code +- Use ThemeData to manage themes +- Use AppLocalizations to manage translations +- Use constants to manage constants values +- When a widget tree becomes too deep, it can lead to longer build times and increased memory usage. Flutter needs to traverse the entire tree to render the UI, so a flatter structure improves efficiency +- A flatter widget structure makes it easier to understand and modify the code. Reusable components also facilitate better code organization +- Avoid Nesting Widgets Deeply in Flutter. Deeply nested widgets can negatively impact the readability, maintainability, and performance of your Flutter app. Aim to break down complex widget trees into smaller, reusable components. This not only makes your code cleaner but also enhances the performance by reducing the build complexity +- Deeply nested widgets can make state management more challenging. By keeping the tree shallow, it becomes easier to manage state and pass data between widgets +- Break down large widgets into smaller, focused widgets +- Utilize const constructors wherever possible to reduce rebuilds + +### Testing + +- Use the standard widget testing for flutter +- Use integration tests for each api module. \ No newline at end of file diff --git a/.cursor/rules/self_improve.mdc b/.cursor/rules/self_improve.mdc new file mode 100644 index 0000000..a7ea8f2 --- /dev/null +++ b/.cursor/rules/self_improve.mdc @@ -0,0 +1,73 @@ +--- +description: Guidelines for continuously improving Cursor rules based on emerging code patterns and best practices. +globs: **/* +alwaysApply: true +--- + +- **Rule Improvement Triggers:** + - New code patterns not covered by existing rules + - Repeated similar implementations across files + - Common error patterns that could be prevented + - New libraries or tools being used consistently + - Emerging best practices in the codebase + +- **Analysis Process:** + - Compare new code with existing rules + - Identify patterns that should be standardized + - Look for references to external documentation + - Check for consistent error handling patterns + - Monitor test patterns and coverage + +- **Rule Updates:** + - **Add New Rules When:** + - A new technology/pattern is used in 3+ files + - Common bugs could be prevented by a rule + - Code reviews repeatedly mention the same feedback + - New security or performance patterns emerge + + - **Modify Existing Rules When:** + - Better examples exist in the codebase + - Additional edge cases are discovered + - Related rules have been updated + - Implementation details have changed + +- **Example Pattern Recognition:** + ```typescript + // If you see repeated patterns like: + const data = await prisma.user.findMany({ + select: { id: true, email: true }, + where: { status: 'ACTIVE' } + }); + + // Consider adding to [prisma.mdc](mdc:.cursor/rules/prisma.mdc): + // - Standard select fields + // - Common where conditions + // - Performance optimization patterns + ``` + +- **Rule Quality Checks:** + - Rules should be actionable and specific + - Examples should come from actual code + - References should be up to date + - Patterns should be consistently enforced + +- **Continuous Improvement:** + - Monitor code review comments + - Track common development questions + - Update rules after major refactors + - Add links to relevant documentation + - Cross-reference related rules + +- **Rule Deprecation:** + - Mark outdated patterns as deprecated + - Remove rules that no longer apply + - Update references to deprecated rules + - Document migration paths for old patterns + +- **Documentation Updates:** + - Keep examples synchronized with code + - Update references to external docs + - Maintain links between related rules + - Document breaking changes + +Follow [cursor_rules.mdc](mdc:.cursor/rules/cursor_rules.mdc) for proper rule formatting and structure. \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79c113f --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# 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 +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# 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 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..d77a4e0 --- /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: "c23637390482d4cf9598c3ce3f2be31aa7332daf" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: android + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: ios + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: linux + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: macos + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: web + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: windows + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + + # 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/README.md b/README.md new file mode 100644 index 0000000..42878bc --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# superport + +A new Flutter project. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml 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..4f3444e --- /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" + 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" + // 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..0c155de --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/superport/MainActivity.kt b/android/app/src/main/kotlin/com/example/superport/MainActivity.kt new file mode 100644 index 0000000..fc85904 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/superport/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.superport + +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..89176ef --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,21 @@ +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..afa1e8e --- /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.10.2-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..a439442 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,25 @@ +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.7.0" apply false + id("org.jetbrains.kotlin.android") version "1.8.22" apply false +} + +include(":app") diff --git a/doc/development_log.md b/doc/development_log.md new file mode 100644 index 0000000..5b2f32b --- /dev/null +++ b/doc/development_log.md @@ -0,0 +1,327 @@ +# supERPort ERP 개발일지 + +## 대화 1: 프로젝트 분석 및 계획 + +- **날짜**: 2025년 04월 16일 +- **내용**: + - supERPort ERP 시스템의 요구사항 문서(PRD) 검토 + - Flutter 기반 프론트엔드 구현 계획 수립 + - 주요 기능 리뷰: 장비 입고/출고, 회사/사용자/라이센스 등록 관리 + - Metronic Admin Template 스타일 가이드라인 확인 + - 추천 디렉토리 구조 검토 + +## 대화 2: 프로젝트 디렉토리 구조 생성 + +- **날짜**: 2025년 04월 16일 +- **내용**: + - PRD에 명시된 디렉토리 구조 구현 + - 모델, 화면, 서비스, 유틸리티 디렉토리 생성 + - 각 기능별 파일 구조 설계 + +## 대화 3: 모델 클래스 구현 + +- **날짜**: 2025년 04월 16일 +- **내용**: + - 장비 관련 모델 구현: + - `EquipmentModel`: 공통 장비 정보 + - `EquipmentInModel`: 장비 입고 정보 + - `EquipmentOutModel`: 장비 출고 정보 + - 회사 관련 모델 구현: + - `CompanyModel`: 회사 및 지점 정보 + - 사용자 관련 모델 구현: + - `UserModel`: 사용자 정보 및 권한 + - 라이센스 관련 모델 구현: + - `LicenseModel`: 유지보수 라이센스 정보 + +## 대화 4: 테마 및 공통 위젯 구현 + +- **날짜**: 2025년 04월 16일 +- **내용**: + - 앱 테마 구현 (`AppTheme` 클래스) + - Metronic Admin 스타일 적용 + - 색상 팔레트, 텍스트 스타일, 버튼 스타일 정의 + - 공통 위젯 구현: + - `PageTitle`: 화면 상단 제목 및 추가 버튼 + - `DataTableCard`: 데이터 테이블을 감싸는 카드 위젯 + - `FormFieldWrapper`: 폼 필드 레이블 및 래퍼 + - `DatePickerField`: 날짜 선택 필드 + - `CategorySelectionField`: 대분류/중분류/소분류 선택 필드 + +## 대화 5: 모의 데이터 서비스 구현 + +- **날짜**: 2025년 04월 16일 +- **내용**: + - `MockDataService` 클래스 구현 + - 모의 데이터 생성 및 관리 기능: + - 장비 입고/출고 데이터 관리 + - 회사 데이터 관리 + - 사용자 데이터 관리 + - 라이센스 데이터 관리 + - CRUD 작업 지원 (생성, 조회, 업데이트, 삭제) + +## 대화 6: 장비 입고 화면 구현 + +- **날짜**: 2025년 04월 16일 +- **내용**: + - 장비 입고 목록 화면 구현: + - 데이터 테이블로 입고 장비 목록 표시 + - 추가, 수정, 삭제 기능 구현 + - 장비 입고 폼 화면 구현: + - 제조사명, 장비명, 분류 정보 입력 + - 시리얼 넘버, 바코드, 물품 수량 입력 + - 입고일 선택 + - 폼 유효성 검사 + +## 대화 7: 장비 출고 화면 구현 + +- **날짜**: 2025년 04월 16일 +- **내용**: + - 장비 출고 목록 화면 구현: + - 데이터 테이블로 출고 장비 목록 표시 + - 추가, 수정, 삭제 기능 구현 + - 장비 출고 폼 화면 구현: + - 장비명, 분류 정보 선택 + - 시리얼 넘버, 바코드 입력 + - 출고 수량, 출고일 입력 + - 폼 유효성 검사 + +## 대화 8: 메인 화면 및 라우팅 구현 + +- **날짜**: 2025년 04월 16일 +- **내용**: + - 메인 애플리케이션 파일(`main.dart`) 구현 + - 홈 화면 구현: + - 주요 기능 바로가기 카드 메뉴 (그리드 레이아웃) + - 라우팅 설정: + - 각 화면에 대한 라우트 정의 + - 인자 전달 처리 (수정 화면용 ID 등) + +## 대화 9: 상수 및 유효성 검사 유틸리티 구현 + +- **날짜**: 2025년 04월 16일 +- **내용**: + - 상수 관리 파일 구현: + - 라우트 경로 상수 + - 장비 상태 상수 + - 사용자 역할 상수 + - 유효성 검사 함수 구현: + - 필수 입력값 검사 + - 숫자 입력값 검사 + - 전화번호 형식 검사 + +## 대화 10: 회사 관리 화면 구현 + +- **날짜**: 2025년 04월 16일 +- **내용**: + - 회사 목록 화면 구현: + - 데이터 테이블로 회사 목록 표시 + - 회사명, 주소, 지점 수 표시 + - 추가, 수정, 삭제 기능 구현 + - 회사 등록/수정 폼 구현: + - 회사명, 주소 입력 + - 지점 정보 관리 (추가, 수정, 삭제) + - 지점 정보 입력 (지점명, 주소, 전화번호) + - 폼 유효성 검사 + +## 대화 11: 사용자 관리 화면 구현 + +- **날짜**: 2025년 04월 16일 +- **내용**: + - 사용자 목록 화면 구현: + - 데이터 테이블로 사용자 목록 표시 + - 이름, 소속 회사, 권한 정보 표시 + - 추가, 수정, 삭제 기능 구현 + - 사용자 등록/수정 폼 구현: + - 이름 입력 + - 소속 회사 선택 (드롭다운) + - 권한 선택 (라디오 버튼: 관리자/일반 사용자) + - 폼 유효성 검사 + +## 대화 12: 라이센스 관리 화면 구현 + +- **날짜**: 2025년 04월 16일 +- **내용**: + - 라이센스 목록 화면 구현: + - 데이터 테이블로 라이센스 목록 표시 + - 라이센스명, 회사, 기간, 방문 주기 표시 + - 추가, 수정, 삭제 기능 구현 + - 라이센스 등록/수정 폼 구현: + - 라이센스명 입력 + - 회사 선택 (드롭다운) + - 라이센스 기간, 방문 주기 입력 + - 폼 유효성 검사 + +## 대화 13: 카테고리 선택 위젯 수정 + +- **날짜**: 2025년 04월 16일 +- **내용**: + - `CategorySelectionField` 위젯의 오류 수정 + - 타입 처리 개선 및 조건문 명확화 + - 카테고리/서브카테고리 선택 로직 개선 + +## 대화 14: 프로젝트 완성 및 최종 점검 + +- **날짜**: 2025년 04월 16일 +- **내용**: + - 모든 화면 구현 완료 확인: + - 장비 입고/출고 관리 + - 회사 관리 + - 사용자 관리 + - 라이센스 관리 + - 기능 점검: + - 각 화면 간 이동 및 데이터 전달 + - 등록/수정/삭제 기능 + - 유효성 검사 + - UI/UX 최종 점검: + - 메트로닉 스타일 적용 확인 + - 반응형 레이아웃 확인 + - 사용성 검토 + +## 대화 15: Metronic 테일윈드 디자인 적용 + +- **날짜**: 2025년 04월 17일 +- **내용**: + - Metronic Admin 테일윈드 버전 (데모6) 디자인 분석 + - 테일윈드 CSS 기반으로 테마 시스템 재구성 + - 새로운 테마 파일 생성: `theme_tailwind.dart` + - 기존 Material 테마에서 테일윈드 스타일로 변경 + - Metronic의 색상 팔레트와 그림자 효과 적용 + +## 대화 16: 공통 레이아웃 컴포넌트 개발 + +- **날짜**: 2025년 04월 17일 +- **내용**: + - Metronic 스타일의 레이아웃 컴포넌트 구현: `layout_components.dart` + - 공통 UI 컴포넌트 개발: + - `MetronicPageContainer`: 페이지 기본 레이아웃 + - `MetronicCard`: 카드 컴포넌트 + - `MetronicStatsCard`: 통계 카드 컴포넌트 + - `MetronicPageTitle`: 페이지 제목 컴포넌트 + - `MetronicDataTable`: 데이터 테이블 컴포넌트 + - `MetronicFormField`: 폼 필드 래퍼 컴포넌트 + - `MetronicTabContainer`: 탭 컨테이너 컴포넌트 + - 전체 UI 일관성 향상을 위한 컴포넌트 표준화 + +## 대화 17: 오버뷰 대시보드 화면 구현 + +- **날짜**: 2025년 04월 17일 +- **내용**: + - 오버뷰 화면 개발: `overview_screen.dart` + - 기능 구현: + - 환영 카드 섹션 개발 + - 통계 카드 그리드 레이아웃 구현 + - 시스템 활동 차트 영역 개발 (차트 플레이스홀더) + - 최근 활동, 알림, 예정된 작업 탭 구현 + - 장비 입출고 통계 및 추이 표시 + - Metronic 데모6 스타일 적용 + - 홈 화면을 오버뷰 대시보드로 변경 + +## 대화 18: 메인 파일 업데이트 및 테마 적용 + +- **날짜**: 2025년 04월 17일 +- **내용**: + - `main.dart` 파일 수정 + - 테일윈드 테마 적용으로 변경: `AppThemeTailwind.lightTheme` + - 홈 화면을 기존 메뉴 그리드에서 오버뷰 대시보드로 변경 + - 불필요한 HomeScreen 클래스 제거 + - 라우트 설정 업데이트 + - 모든 화면이 통일된 디자인 시스템 적용 + +## 대화 19: 좌측 사이드바 메뉴 구현 및 레이아웃 개선 + +- **날짜**: 2025년 04월 18일 +- **내용**: + - Metronic 테일윈드 데모6 스타일의 사이드바 메뉴 구현 + - 좌측에 메인 메뉴를 배치하는 레이아웃 구조 변경 + - `SidebarMenu` 클래스 개발: + - 메뉴 계층 구조 지원 (접는/펼치는 기능) + - 활성 메뉴 시각적 표시 + - 메뉴 항목별 아이콘 및 스타일 적용 + - `MainLayout` 컴포넌트 구현: + - 좌측 사이드바와 우측 컨텐츠 영역 구성 + - 커스텀 앱바 디자인 적용 + - 모든 화면에 일관된 레이아웃 제공 + - 오버뷰 화면을 새 레이아웃에 통합 + - 메뉴 항목: + - 대시보드 + - 장비 관리 (하위 메뉴: 장비 입고, 장비 출고) + - 회사 관리 + - 사용자 관리 + - 라이센스 관리 + +## 대화 20: 대시보드 인터페이스 개선 + +- **날짜**: 2025년 04월 19일 +- **내용**: + - 대시보드 화면 UI 개선: + - 불필요한 환영 메시지 컴포넌트 제거 + - 통계 카드 디자인 최적화 + - MetronicStatsCard 컴포넌트 높이 축소 (여백 및 폰트 크기 조정) + - 통계 카드 레이아웃 비율 조정 (childAspectRatio 적용) + - 헤더 인터페이스 일관성 향상: + - 사이드바 헤더와 메인 헤더의 높이 일치 (72px로 통일) + - 브랜드 로고 및 앱 타이틀 정렬 개선 + - 헤더 패딩 및 여백 최적화 + - 전체적인 레이아웃 조화 개선 + - 상단 네비게이션 영역 수직 정렬 통일 + - 컴포넌트 간 일관된 간격 적용 + +## 대화 21: 대시보드 레이아웃 균형 개선 + +- **날짜**: 2025년 04월 19일 +- **내용**: + - 대시보드 레이아웃의 일관성 향상: + - '시스템 활동'과 '최근 활동' 위젯 간의 간격을 표준화 (24px로 통일) + - 최근 활동 섹션의 불필요한 중첩 레이아웃 제거 및 단순화 + - 위젯 간 상하 간격 일관성 확보로 시각적 조화 개선 + - UI 요소 정리 및 최적화 + - SizedBox 고정 높이 제한 제거로 컨텐츠에 따른 자연스러운 크기 조정 + - 중복 컨테이너 래핑 최소화로 레이아웃 성능 향상 + +## 대화 22: 대시보드 UI 간격 표준화 + +- **날짜**: 2025년 04월 19일 +- **내용**: + - 대시보드 화면의 모든 위젯 간 상하 간격 표준화: + - 주요 섹션 간 간격: 24px로 통일 (통계 그리드, 시스템 활동, 최근 활동) + - 섹션 내부 요소 간 간격: 16px로 통일 + - 활동 차트 내부, 활동 레전드, 최근 활동 리스트 등의 간격 조정 + - 컴포넌트 레이아웃 개선: + - 시스템 활동 카드에 하단 여백 추가 + - 최근 활동 섹션에 상하 여백 추가 + - 마지막 아이템 이후 불필요한 구분선 제거 + - 전체적인 시각적 일관성 향상 + - 모든 간격을 8px 단위의 배수로 설정 (8, 16, 24) + - 컴포넌트 내부 구성요소 간 관계성 강화 + +## 대화 23: 대시보드 UI 간격 미세 조정 + +- **날짜**: 2025년 04월 19일 +- **내용**: + - 대시보드 화면의 동일한 위젯 간 간격 문제 해결: + - MetronicCard 컴포넌트에 내장된 하단 마진(bottom: 16) 제거 + - 마진 속성을 컴포넌트 매개변수로 제공하여 외부에서 조정 가능하도록 개선 + - 각 카드의 시각적 간격 정확히 통일 (24px) + - 컴포넌트 아키텍처 개선: + - 중첩 스타일 속성을 통일하여 일관된 시각적 경험 제공 + - 레이아웃 컴포넌트의 유연성 향상 + - 다양한 화면에서 재사용 가능한 컴포넌트 설계 + +## 대화 24: 일관된 인터페이스를 위한 사이드바 메뉴 통합 + +- **날짜**: 2025년 04월 20일 +- **내용**: + - 모든 주요 화면에 사이드바 메뉴 적용: + - 장비 입고 관리 화면에 MainLayout 적용 + - 장비 출고 관리 화면에 MainLayout 적용 + - 회사 관리 화면에 MainLayout 적용 + - 사용자 관리 화면에 MainLayout 적용 + - 라이센스 관리 화면에 MainLayout 적용 + - 레이아웃 구조 개선: + - 모든 화면에 일관된 MainLayout 컴포넌트 적용 + - 페이지별 적절한 currentRoute 값 설정으로 사이드바 메뉴 활성화 상태 관리 + - 기존 Scaffold와 AppBar를 MainLayout으로 대체 + - 사용자 경험 향상: + - 모든 화면에서 일관된 내비게이션 경험 제공 + - 새로고침 및 추가 기능 버튼 통일 + - 플로팅 액션 버튼을 통한 추가 기능 접근성 개선 \ No newline at end of file diff --git a/doc/refac.md b/doc/refac.md new file mode 100644 index 0000000..e69de29 diff --git a/doc/superportPRD.md b/doc/superportPRD.md new file mode 100644 index 0000000..8ed99cd --- /dev/null +++ b/doc/superportPRD.md @@ -0,0 +1,340 @@ +**아래 내용 전체를 복사해서** `superportPRD.md` **파일로 저장하시면 됩니다.** + +--- + +# supERPort ERP - 초기 프론트엔드 PRD (Flutter) + +본 문서는 Flutter로 제작할 ERP 웹/앱 프론트엔드의 최소 기능 요구사항과 디렉터리 구조, 스타일 가이드, 그리고 AI 코드 생성에 활용 가능한 프롬프트를 포함하고 있습니다. 현재 계획은 확장 가능하며, 추후 대화와 요구사항 변경에 따라 자동으로 업데이트할 수 있습니다. + +## 1. 개요 + +- **이름**: supERPort + +- **기술 스택**: Flutter + +- **주요 화면**: + + 1. 장비 입고 + + 2. 장비 출고 + + 3. 회사 등록 + + 4. 사용자 등록 + + 5. 라이센스 등록 + + +## 2. 공통 스타일 가이드 + +- **디자인 레퍼런스**: [Metronic Admin Template](https://themeforest.net/item/metronic-responsive-admin-dashboard-template/4021469) + + - **디자인 레퍼런스를 철저히 검토 및 적용**: Metronic의 Layout, 컬러 팔레트, 컴포넌트, 인터랙션 가이드라인을 꼼꼼하게 분석하여, Flutter 및 Material Icons와 조화롭게 적용해야 합니다. + - 하나의 화면에 중복되는 정보를 제공하지 않아야 합니다. + +- **아이콘**: Material Icons + +- **UI 기조**: 모던하고 직관적인 플랫 디자인, 적절한 그림자(Elevation)와 여백 사용 + +- **메인 컬러 예시**: + + - Primary: #5867dd (Metronic 기본색) + + - Secondary: #34bfa3 + + - Background: #f7f8fa + +- **타이포그래피**: 가독성 높은 산세리프 폰트(예: Roboto, Noto Sans) + +- **반응형 고려**: Web & Mobile 겸용, Responsive Layout 구성 + + +## 3. 기능 요구사항 + +### 3.1 장비 입고 화면 + +- **등록 후 리스트로 관리** + + - 새로운 장비 입고 기록을 추가 (Form) + + - 등록된 목록을 리스트(테이블)로 보여주고, 항목별 Editing 기능 제공 + +- **필수 데이터** + + 1. **제조사명** (varchar 필수) + + 2. **장비명** (varchar 필수) + + 3. **대분류 / 중분류 / 소분류** (varchar, Email 선택 UI 유사 방식) + + 4. **시리얼 넘버** (varchar) + + 5. **바코드 입력** (varchar, 차후에 바코드 스캔 기능과 연동 예정) + + 6. **물품 수** (int 필수) + + - 시리얼 번호가 존재할 경우 1 고정 및 수정 불가 + + - 시리얼 번호가 없으면 직접 입력 + + 7. **입고일** (datetime) + + - 당일 날짜를 기본값으로 설정 + + - 캘린더에서 과거 날짜만 선택 가능 + + 8. (DB 연계 시) **장비(장비이력) 테이블**과 매핑 + + - **입출고**는 “I”(입고)로 기록 + + - **발생시간**: 입고 시점(= 입고일) + + +### 3.2 장비 출고 화면 + +- **등록 후 리스트로 관리** + + - 새 출고 정보 등록 (Form) + + - 출고 기록 목록 표시 및 편집 기능 + +- **필수 데이터** + + 1. **장비명** (varchar 필수) + + 2. **대분류 / 중분류 / 소분류** (출고 시점에 불러오고 수정 가능 여부는 정책 결정) + + 3. **시리얼 넘버** (varchar) + + 4. **바코드** (varchar, 바코드 스캔 기능 연동 예정) + + 5. **출고 수량** (int 필수) + + 6. **출고일** (datetime) + + 7. (DB 연계 시) **장비(장비이력) 테이블**과 매핑 + + - **입출고**는 “O”(출고)로 기록 + + - **발생시간**: 출고 시점(= 출고일) + + +### 3.3 회사 등록 화면 + +- **등록 후 리스트로 관리** + + - 새 회사 정보 등록 (Form) + + - 회사 목록 조회 및 편집 + +- **필수 데이터** + + 1. **회사명** (varchar 필수) + + 2. **주소** (varchar) + + 3. (확장) **지점 정보** (Optional / 별도 화면 구성 가능) + + - 지점명 (varchar) + + - 대표전화번호 (varchar) + + - 주소 (varchar) + + 4. (DB 연계 시) **고객(회사) / 고객(지점)** 테이블 매핑 + + - 회사 ID, 지점 ID 등 Primary Key, FK 관계 + + +### 3.4 사용자 등록 화면 + +- **등록 후 리스트로 관리** + + - 사용자(직원) 가입/등록 Form + + - 사용자 목록 조회 및 편집 + +- **필수 데이터** + + 1. **이름** (varchar 필수) + + 2. **소속 회사ID** (int) + + 3. **관리등급** (char) – 예) S(관리자), M(멤버) + + 4. (DB 연계 시) **서비스(직원)** 테이블 매핑 + + +### 3.5 라이센스 등록 화면 + +- **등록 후 리스트로 관리** + + - 유지보수 라이센스 정보 등록 (Form) + + - 등록된 라이센스 목록 표시 및 편집 + +- **필수 데이터** + + 1. **서비스(회사)ID** (int) – 라이센스가 적용될 회사/서비스 정보 + + 2. **라이센스명** (varchar, 예: “1년 유지보수”) + + 3. **라이센스기간** (int, 월 단위) + + 4. **방문주기** (int, 유지보수 방문 주기) + + 5. (DB 연계 시) **유지(라이센스)** 테이블과 매핑 + + +## 4. 추천 디렉터리 구조 + +동일한 기능을 담은 모듈(파일)이 300라인을 초과하게 될 경우, **하위 파일로 분리**해 관리하는 것을 권장합니다. +장비(Equipment) 모델과 장비 입고(In) / 출고(Out) 모델을 **분리**하여, 공통 필드와 입·출고 전용 필드를 구분해 유지보수를 용이하게 합니다. + +lib/ +┣ models/ +┃ ┣ equipment_model.dart (장비(Equipment) 공통 모델) +┃ ┣ equipment_in_model.dart (장비 입고 관련 모델, 장비 + 입고 필드) +┃ ┣ equipment_out_model.dart (장비 출고 관련 모델, 장비 + 출고 필드) +┃ ┣ company_model.dart (회사 정보 모델) +┃ ┣ user_model.dart (사용자 정보 모델) +┃ ┗ license_model.dart (유지보수 라이센스 정보 모델) +┣ screens/ +┃ ┣ equipment_in/ +┃ ┃ ┣ equipment_in_list.dart (장비 입고 리스트 화면) +┃ ┃ ┗ equipment_in_form.dart (장비 입고 등록/수정 폼) +┃ ┣ equipment_out/ +┃ ┃ ┣ equipment_out_list.dart (장비 출고 리스트 화면) +┃ ┃ ┗ equipment_out_form.dart (장비 출고 등록/수정 폼) +┃ ┣ company/ +┃ ┃ ┣ company_list.dart (회사 목록 화면) +┃ ┃ ┗ company_form.dart (회사 등록/수정 폼) +┃ ┣ user/ +┃ ┃ ┣ user_list.dart (사용자 목록 화면) +┃ ┃ ┗ user_form.dart (사용자 등록/수정 폼) +┃ ┣ license/ +┃ ┃ ┣ license_list.dart (라이센스 목록 화면) +┃ ┃ ┗ license_form.dart (라이센스 등록/수정 폼) +┃ ┗ common/ +┃ ┣ custom_widgets.dart (공통 위젯) +┃ ┗ theme.dart (스타일, 테마 정보) +┣ services/ +┃ ┗ mock_data_service.dart (서버 없는 샘플 데이터 관리) +┣ utils/ +┃ ┣ validators.dart (입력값 검증 함수) +┃ ┗ constants.dart (상수 관리, 예: 라우트명, 컬러코드) +┗ main.dart + +## 5. 데이터베이스 설계 + +아래는 장비, 회사(고객), 서비스(직원/결제), 유지보수 라이센스 등에 대한 **예시** 테이블입니다. 실제 구현 시에는 필요에 따라 테이블을 통합하거나 컬럼명을 조정할 수 있습니다. + +### 5.1 고객 관련 테이블 + +|테이블명|필드명|타입|예시|비고| +|---|---|---|---|---| +|고객(회사)|ID|Integer|1|Primary Key| +||회사명|Varchar|LG전자|고객 회사명| +||주소|Varchar|서울시 종로구|고객 회사 주소| +|고객(지점)|ID|Integer|10|Primary Key| +||회사ID|Integer|1|FK - 고객(회사)| +||지점명|Varchar|본사|고객 지점명| +||주소|Varchar|서울시 종로구|지점 주소| +||대표전화번호|Varchar|02-3403-2222|지점 연락처| + +### 5.2 서비스 관련 테이블 + +|테이블명|필드명|타입|예시|비고| +|---|---|---|---|---| +|서비스(직원)|ID|Integer|20|Primary Key| +||회사ID|Integer|1|FK - 서비스(회사) 또는 고객(회사)| +||이름|Varchar|홍길동|직원 이름| +||관리등급|Char|M|S(관리자)/M(멤버)| +|서비스(결제)|ID|Integer|30|Primary Key| +||서비스(직원)ID|Integer|20|FK - 서비스(직원)| +||결제여부|Char|A|승인(A)/반려(D)| +||결제일|Datetime|2025-03-04 11:00|결제 일시| + +### 5.3 장비 관련 테이블 + +|테이블명|필드명|타입|예시|비고| +|---|---|---|---|---| +|장비(장비정보)|ID|Integer|50|Primary Key| +||장비(회사명)ID|Integer|1|FK - 장비(회사명) or 고객(회사)| +||장비명|Varchar|라우터 123|장비 모델명| +|장비(장비이력)|ID|Integer|60|Primary Key| +||장비(장비바코드)ID|Integer|50|FK - 장비(장비바코드)| +||입출고|Varchar|I|입고(I)/출고(O)/임대(R) 등 구분| +||발생시간|Datetime|2025-03-04 11:00|이력 발생 시간| + +### 5.4 유지보수 라이센스 관련 테이블 + +|테이블명|필드명|타입|예시|비고| +|---|---|---|---|---| +|유지(라이센스)|ID|Integer|70|Primary Key| +||서비스(회사)ID|Integer|1|FK - 서비스(회사)| +||라이센스명|Varchar|1년 유지보수|유지보수 라이센스명| +||라이센스기간|Integer|12|월 단위| +||방문주기|Integer|1|유지보수 방문 주기| + +> 위 테이블들은 예시이며, 실제 구현 시에는 **API 요청/응답 형식**과 **화면 요구사항**에 따라 컬럼을 추가/수정/제거할 수 있습니다. + +## 6. 코드 생성용 AI 프롬프트 예시 + +(아래 텍스트는 코드 자동생성용 프롬프트 작성 예시일 뿐, 실제 환경에 맞춰 수정해서 사용하세요.) + +[System] +You are Claude, a large language model trained by Anthropic. + +[User] +read document "doc/doc name" at first. + +- 앱 이름: supERPort + +- 화면 개요: 장비 입고, 장비 출고, 회사 등록, 사용자 등록 + +- 참조 스타일: Metronic Admin + Material Icons + +- 필수 폴더 구조와 기능 + + +1. equipment_in (리스트 & 폼) + +2. equipment_out (리스트 & 폼) + +3. company (리스트 & 폼) + +4. user (리스트 & 폼) + + +Generate Flutter code with the above requirements. + +- Use a consistent coding style. + +- Provide minimal working code example for each screen. + +- Utilize Material Icons. + +- Implement basic validation in the forms. + + +## 7. 추후 업데이트 사항 + +- 장비 출고 시 필수 데이터 상세 + +- 회사 등록/사용자 등록 시 필수 데이터 구조 정의 (지점 정보, 연락처 등 확장) + +- Form 유효성 검사 규칙 세부화 + +- 권한(관리자/사용자)별 접근 제어 + +- 바코드 스캔 기능 연동 + +- API 연동 및 서버 연결 + +- **유지보수 라이센스** 관련 화면 및 기능 확대 (결제/계약 기간 연동 등) + + +--- + +본 문서는 ERP 플랫폼 “supERPort”의 Flutter 프론트엔드 개발에 필요한 **최소 요구사항**, **디렉터리 구조**, **스타일 가이드**, 그리고 **데이터베이스 설계** 정보를 담고 있습니다. 여기서 정의되지 않은 사항은 추후 대화에서 확정된 후 문서에 자동 반영될 예정입니다. 필요에 따라 Markdown 형식으로 다운로드해, 버전 관리 시스템에 추가하거나 직접 열람할 수 있습니다. \ No newline at end of file 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..7c56964 --- /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 + 12.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..e549ee2 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.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..14189fd --- /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 = 12.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.superport; + 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.superport.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.superport.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.superport.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 = 12.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 = 12.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.superport; + 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.superport; + 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..15cada4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..cc96aeb --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Superport + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + superport + 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.zip b/lib.zip new file mode 100644 index 0000000..16e3833 Binary files /dev/null and b/lib.zip differ diff --git a/lib/assets/fonts/NotoSansKR-VariableFont_wght.ttf b/lib/assets/fonts/NotoSansKR-VariableFont_wght.ttf new file mode 100644 index 0000000..57016c2 Binary files /dev/null and b/lib/assets/fonts/NotoSansKR-VariableFont_wght.ttf differ diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..3d6425b --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/equipment_unified_model.dart'; +import 'package:superport/screens/common/app_layout.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/screens/company/company_form.dart'; +import 'package:superport/screens/equipment/equipment_in_form.dart'; +import 'package:superport/screens/equipment/equipment_out_form.dart'; +import 'package:superport/screens/license/license_form.dart'; // MaintenanceFormScreen으로 사용 +import 'package:superport/screens/user/user_form.dart'; +import 'package:superport/screens/warehouse_location/warehouse_location_form.dart'; +import 'package:superport/utils/constants.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:superport/screens/login/login_screen.dart'; + +void main() { + // MockDataService는 싱글톤으로 자동 초기화됨 + runApp(const SuperportApp()); +} + +class SuperportApp extends StatelessWidget { + const SuperportApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'supERPort', + theme: AppThemeTailwind.lightTheme, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('ko', 'KR'), Locale('en', 'US')], + locale: const Locale('ko', 'KR'), + initialRoute: '/login', + onGenerateRoute: (settings) { + // 로그인 라우트 처리 + if (settings.name == '/login') { + return MaterialPageRoute(builder: (context) => const LoginScreen()); + } + // 기본 AppLayout으로 라우팅할 경로 (홈, 목록 화면들) + if (settings.name == Routes.home || + settings.name == Routes.equipment || + settings.name == Routes.equipmentInList || + settings.name == Routes.equipmentOutList || + settings.name == Routes.equipmentRentList || + settings.name == Routes.company || + settings.name == Routes.user || + settings.name == Routes.license) { + return MaterialPageRoute( + builder: (context) => AppLayout(initialRoute: settings.name!), + ); + } + + // 기존 라우팅 처리 (폼 화면들) + switch (settings.name) { + // 장비 입고 관련 라우트 + case Routes.equipmentInAdd: + return MaterialPageRoute( + builder: (context) => const EquipmentInFormScreen(), + ); + case Routes.equipmentInEdit: + final id = settings.arguments as int; + return MaterialPageRoute( + builder: (context) => EquipmentInFormScreen(equipmentInId: id), + ); + + // 장비 출고 관련 라우트 + case Routes.equipmentOutAdd: + // 선택된 장비 정보와 입고 ID가 전달되었는지 확인 + final args = settings.arguments; + Equipment? equipment; + int? equipmentInId; + List>? selectedEquipments; + + // 인자 처리 + if (args is Map) { + // 다중 선택 장비 처리 + if (args.containsKey('selectedEquipments')) { + selectedEquipments = + args['selectedEquipments'] as List>; + debugPrint('선택된 장비 목록: ${selectedEquipments.length}개'); + } else { + // 단일 장비 선택 (기존 방식) + equipment = args['equipment'] as Equipment?; + equipmentInId = args['equipmentInId'] as int?; + debugPrint('단일 장비 선택'); + } + } else if (args is List>) { + // 직접 리스트가 전달된 경우 + selectedEquipments = args; + debugPrint('직접 리스트로 전달된 장비 목록: ${selectedEquipments.length}개'); + } else if (args is Equipment) { + equipment = args; // 기존 방식 대응 (하위 호환) + debugPrint('단일 Equipment 객체 전달'); + } else { + debugPrint('알 수 없는 인자 타입: ${args.runtimeType}'); + } + + return MaterialPageRoute( + builder: + (context) => EquipmentOutFormScreen( + selectedEquipment: equipment, + selectedEquipmentInId: equipmentInId, + selectedEquipments: selectedEquipments, + ), + ); + case Routes.equipmentOutEdit: + final id = settings.arguments as int; + return MaterialPageRoute( + builder: (context) => EquipmentOutFormScreen(equipmentOutId: id), + ); + + // 회사 관련 라우트 + case Routes.companyAdd: + return MaterialPageRoute( + builder: (context) => const CompanyFormScreen(), + ); + case Routes.companyEdit: + final args = settings.arguments; + if (args is Map) { + return MaterialPageRoute( + builder: (context) => CompanyFormScreen(args: args), + ); + } else if (args is int) { + // 하위 호환: int만 넘어오는 경우 + return MaterialPageRoute( + builder: + (context) => CompanyFormScreen(args: {'companyId': args}), + ); + } else { + return MaterialPageRoute( + builder: (context) => CompanyFormScreen(), + ); + } + + // 사용자 관련 라우트 + case Routes.userAdd: + return MaterialPageRoute( + builder: (context) => const UserFormScreen(), + ); + case Routes.userEdit: + final id = settings.arguments as int; + return MaterialPageRoute( + builder: (context) => UserFormScreen(userId: id), + ); + + // 라이센스 관련 라우트 + case Routes.licenseAdd: + return MaterialPageRoute( + builder: (context) => const MaintenanceFormScreen(), + ); + case Routes.licenseEdit: + final id = settings.arguments as int; + return MaterialPageRoute( + builder: (context) => MaintenanceFormScreen(maintenanceId: id), + ); + + // 입고지 관련 라우트 + case Routes.warehouseLocationAdd: + return MaterialPageRoute( + builder: (context) => const WarehouseLocationFormScreen(), + ); + case Routes.warehouseLocationEdit: + final id = settings.arguments as int; + return MaterialPageRoute( + builder: (context) => WarehouseLocationFormScreen(id: id), + ); + + default: + return MaterialPageRoute( + builder: (context) => AppLayout(initialRoute: Routes.home), + ); + } + }, + ); + } +} diff --git a/lib/models/address_model.dart b/lib/models/address_model.dart new file mode 100644 index 0000000..89e2459 --- /dev/null +++ b/lib/models/address_model.dart @@ -0,0 +1,81 @@ +/// 주소 모델 +/// +/// 우편번호, 시/도, 상세주소로 구성된 주소 정보를 관리합니다. +/// 회사 및 지점의 주소 정보를 일관되게 처리하기 위한 모델입니다. +class Address { + /// 우편번호 + final String zipCode; + + /// 시/도 (서울특별시, 경기도 등) + final String region; + + /// 상세 주소 + final String detailAddress; + + /// 생성자 + const Address({this.zipCode = '', this.region = '', this.detailAddress = ''}); + + /// 주소를 문자열로 반환합니다. (전체 주소) + /// + /// 예시: "12345 서울특별시 강남구 테헤란로 123" + @override + String toString() { + final List parts = []; + + if (zipCode.isNotEmpty) { + parts.add(zipCode); + } + + if (region.isNotEmpty) { + parts.add(region); + } + + if (detailAddress.isNotEmpty) { + parts.add(detailAddress); + } + + return parts.join(' '); + } + + /// 전체 주소에서 Address 객체를 생성합니다. + /// + /// 현재는 우편번호, 시/도, 상세주소를 분리하지 않고 전체를 detailAddress로 저장합니다. + /// 기존 데이터 마이그레이션을 위한 메서드입니다. + factory Address.fromFullAddress(String fullAddress) { + return Address(detailAddress: fullAddress); + } + + /// JSON에서 Address 객체를 생성합니다. + factory Address.fromJson(Map json) { + return Address( + zipCode: json['zipCode'] ?? '', + region: json['region'] ?? '', + detailAddress: json['detailAddress'] ?? '', + ); + } + + /// Address 객체를 JSON으로 변환합니다. + Map toJson() { + return { + 'zipCode': zipCode, + 'region': region, + 'detailAddress': detailAddress, + }; + } + + /// 주소가 비어 있는지 확인합니다. + bool get isEmpty => + zipCode.isEmpty && region.isEmpty && detailAddress.isEmpty; + + /// 주소가 비어 있지 않은지 확인합니다. + bool get isNotEmpty => !isEmpty; + + /// 복사본을 생성하고 일부 필드를 업데이트합니다. + Address copyWith({String? zipCode, String? region, String? detailAddress}) { + return Address( + zipCode: zipCode ?? this.zipCode, + region: region ?? this.region, + detailAddress: detailAddress ?? this.detailAddress, + ); + } +} diff --git a/lib/models/company_model.dart b/lib/models/company_model.dart new file mode 100644 index 0000000..7fca11b --- /dev/null +++ b/lib/models/company_model.dart @@ -0,0 +1,259 @@ +import 'package:superport/models/address_model.dart'; + +/// 회사 유형 열거형 +/// - 고객사: 서비스를 이용하는 회사 +/// - 파트너사: 서비스를 제공하는 회사 +enum CompanyType { + customer, // 고객사 + partner, // 파트너사 +} + +/// 회사 유형을 문자열로 변환 (복수 지원) +String companyTypeToString(CompanyType type) { + switch (type) { + case CompanyType.customer: + return '고객사'; + case CompanyType.partner: + return '파트너사'; + } +} + +/// 문자열에서 회사 유형으로 변환 (단일) +CompanyType stringToCompanyType(String type) { + switch (type) { + case '고객사': + return CompanyType.customer; + case '파트너사': + return CompanyType.partner; + default: + return CompanyType.customer; // 기본값은 고객사 + } +} + +/// 문자열 리스트에서 회사 유형 리스트로 변환 +List stringListToCompanyTypeList(List types) { + // 문자열 또는 enum 문자열이 섞여 있을 수 있음 + return types.map((e) { + if (e is CompanyType) return e; + if (e is String) { + if (e.contains('partner')) return CompanyType.partner; + return CompanyType.customer; + } + return CompanyType.customer; + }).toList(); +} + +/// 회사 유형 리스트를 문자열 리스트로 변환 +List companyTypeListToStringList(List types) { + return types.map((e) => companyTypeToString(e)).toList(); +} + +class Branch { + final int? id; + final int companyId; + final String name; + final Address address; // 주소 모델 사용 + final String? contactName; // 담당자 이름 + final String? contactPosition; // 담당자 직책 + final String? contactPhone; // 담당자 전화번호 + final String? contactEmail; // 담당자 이메일 + final String? remark; // 비고 + + Branch({ + this.id, + required this.companyId, + required this.name, + Address? address, // 옵셔널 파라미터로 변경 + this.contactName, + this.contactPosition, + this.contactPhone, + this.contactEmail, + this.remark, + }) : address = address ?? const Address(); // 기본값 제공 + + Map toJson() { + return { + 'id': id, + 'companyId': companyId, + 'name': name, + 'address': address.toString(), // 하위 호환성을 위해 문자열로 변환 + 'addressData': address.toJson(), // 새로운 형식으로 저장 + 'contactName': contactName, + 'contactPosition': contactPosition, + 'contactPhone': contactPhone, + 'contactEmail': contactEmail, + 'remark': remark, + }; + } + + factory Branch.fromJson(Map json) { + // 주소 데이터가 새 형식으로 저장되어 있는지 확인 + Address addressData; + if (json.containsKey('addressData')) { + addressData = Address.fromJson(json['addressData']); + } else if (json.containsKey('address') && json['address'] != null) { + // 이전 버전 호환성 - 문자열 주소를 Address 객체로 변환 + addressData = Address.fromFullAddress(json['address']); + } else { + addressData = const Address(); + } + + return Branch( + id: json['id'], + companyId: json['companyId'], + name: json['name'], + address: addressData, + contactName: json['contactName'], + contactPosition: json['contactPosition'], + contactPhone: json['contactPhone'], + contactEmail: json['contactEmail'], + remark: json['remark'], + ); + } + + /// 복사본을 생성하고 일부 필드를 업데이트합니다. + Branch copyWith({ + int? id, + int? companyId, + String? name, + Address? address, + String? contactName, + String? contactPosition, + String? contactPhone, + String? contactEmail, + String? remark, + }) { + return Branch( + id: id ?? this.id, + companyId: companyId ?? this.companyId, + name: name ?? this.name, + address: address ?? this.address, + contactName: contactName ?? this.contactName, + contactPosition: contactPosition ?? this.contactPosition, + contactPhone: contactPhone ?? this.contactPhone, + contactEmail: contactEmail ?? this.contactEmail, + remark: remark ?? this.remark, + ); + } +} + +class Company { + final int? id; + final String name; + final Address address; // 주소 모델 사용 + final String? contactName; // 담당자 이름 + final String? contactPosition; // 담당자 직책 + final String? contactPhone; // 담당자 전화번호 + final String? contactEmail; // 담당자 이메일 + final List? branches; + final List companyTypes; // 회사 유형 (복수 가능) + final String? remark; // 비고 + + Company({ + this.id, + required this.name, + Address? address, // 옵셔널 파라미터로 변경 + this.contactName, + this.contactPosition, + this.contactPhone, + this.contactEmail, + this.branches, + this.companyTypes = const [CompanyType.customer], // 기본값은 고객사 + this.remark, + }) : address = address ?? const Address(); // 기본값 제공 + + Map toJson() { + return { + 'id': id, + 'name': name, + 'address': address.toString(), // 하위 호환성을 위해 문자열로 변환 + 'addressData': address.toJson(), // 새로운 형식으로 저장 + 'contactName': contactName, + 'contactPosition': contactPosition, + 'contactPhone': contactPhone, + 'contactEmail': contactEmail, + 'branches': branches?.map((branch) => branch.toJson()).toList(), + // 회사 유형을 문자열 리스트로 저장 + 'companyTypes': companyTypes.map((e) => e.toString()).toList(), + 'remark': remark, + }; + } + + factory Company.fromJson(Map json) { + List? branchList; + if (json['branches'] != null) { + branchList = + (json['branches'] as List) + .map((branchJson) => Branch.fromJson(branchJson)) + .toList(); + } + + // 주소 데이터가 새 형식으로 저장되어 있는지 확인 + Address addressData; + if (json.containsKey('addressData')) { + addressData = Address.fromJson(json['addressData']); + } else if (json.containsKey('address') && json['address'] != null) { + // 이전 버전 호환성 - 문자열 주소를 Address 객체로 변환 + addressData = Address.fromFullAddress(json['address']); + } else { + addressData = const Address(); + } + + // 회사 유형 파싱 (복수) + List types = [CompanyType.customer]; // 기본값 + if (json.containsKey('companyTypes')) { + final raw = json['companyTypes']; + if (raw is List) { + types = stringListToCompanyTypeList(raw); + } + } else if (json.containsKey('companyType')) { + // 이전 버전 호환성: 단일 값 + final raw = json['companyType']; + if (raw is String) { + types = [stringToCompanyType(raw)]; + } else if (raw is int) { + types = [CompanyType.values[raw]]; + } + } + + return Company( + id: json['id'], + name: json['name'], + address: addressData, + contactName: json['contactName'], + contactPosition: json['contactPosition'], + contactPhone: json['contactPhone'], + contactEmail: json['contactEmail'], + branches: branchList, + companyTypes: types, + remark: json['remark'], + ); + } + + /// 복사본을 생성하고 일부 필드를 업데이트합니다. + Company copyWith({ + int? id, + String? name, + Address? address, + String? contactName, + String? contactPosition, + String? contactPhone, + String? contactEmail, + List? branches, + List? companyTypes, + String? remark, + }) { + return Company( + id: id ?? this.id, + name: name ?? this.name, + address: address ?? this.address, + contactName: contactName ?? this.contactName, + contactPosition: contactPosition ?? this.contactPosition, + contactPhone: contactPhone ?? this.contactPhone, + contactEmail: contactEmail ?? this.contactEmail, + branches: branches ?? this.branches, + companyTypes: companyTypes ?? this.companyTypes, + remark: remark ?? this.remark, + ); + } +} diff --git a/lib/models/equipment_unified_model.dart b/lib/models/equipment_unified_model.dart new file mode 100644 index 0000000..128263f --- /dev/null +++ b/lib/models/equipment_unified_model.dart @@ -0,0 +1,278 @@ +import 'package:superport/utils/constants.dart'; + +// 장비 정보 모델 +class Equipment { + final int? id; + final String manufacturer; + final String name; + final String category; + final String subCategory; + final String subSubCategory; + final String? serialNumber; + final String? barcode; + final int quantity; + final DateTime? inDate; + final String? remark; // 비고 + final String? warrantyLicense; // 워런티 라이센스 명 + DateTime? warrantyStartDate; // 워런티 시작일(수정 가능) + DateTime? warrantyEndDate; // 워런티 종료일(수정 가능) + + Equipment({ + this.id, + required this.manufacturer, + required this.name, + required this.category, + required this.subCategory, + required this.subSubCategory, + this.serialNumber, + this.barcode, + required this.quantity, + this.inDate, + this.remark, + this.warrantyLicense, + this.warrantyStartDate, + this.warrantyEndDate, + }); + + Map toJson() { + return { + 'id': id, + 'manufacturer': manufacturer, + 'name': name, + 'category': category, + 'subCategory': subCategory, + 'subSubCategory': subSubCategory, + 'serialNumber': serialNumber, + 'barcode': barcode, + 'quantity': quantity, + 'inDate': inDate?.toIso8601String(), + 'remark': remark, + 'warrantyLicense': warrantyLicense, + 'warrantyStartDate': warrantyStartDate?.toIso8601String(), + 'warrantyEndDate': warrantyEndDate?.toIso8601String(), + }; + } + + factory Equipment.fromJson(Map json) { + return Equipment( + id: json['id'], + manufacturer: json['manufacturer'], + name: json['name'], + category: json['category'], + subCategory: json['subCategory'], + subSubCategory: json['subSubCategory'], + serialNumber: json['serialNumber'], + barcode: json['barcode'], + quantity: json['quantity'], + inDate: json['inDate'] != null ? DateTime.parse(json['inDate']) : null, + remark: json['remark'], + warrantyLicense: json['warrantyLicense'], + warrantyStartDate: + json['warrantyStartDate'] != null + ? DateTime.parse(json['warrantyStartDate']) + : null, + warrantyEndDate: + json['warrantyEndDate'] != null + ? DateTime.parse(json['warrantyEndDate']) + : null, + ); + } +} + +class EquipmentIn { + final int? id; + final Equipment equipment; + final DateTime inDate; + final String status; // I (입고) + final String type; // 장비 유형: '신제품', '중고', '계약' + final String? warehouseLocation; // 입고지 + final String? partnerCompany; // 파트너사 + final String? remark; // 비고 + + EquipmentIn({ + this.id, + required this.equipment, + required this.inDate, + this.status = 'I', + this.type = EquipmentType.new_, // 기본값은 '신제품'으로 설정 + this.warehouseLocation, + this.partnerCompany, + this.remark, + }); + + Map toJson() { + return { + 'id': id, + 'equipment': equipment.toJson(), + 'inDate': inDate.toIso8601String(), + 'status': status, + 'type': type, + 'warehouseLocation': warehouseLocation, + 'partnerCompany': partnerCompany, + 'remark': remark, + }; + } + + factory EquipmentIn.fromJson(Map json) { + return EquipmentIn( + id: json['id'], + equipment: Equipment.fromJson(json['equipment']), + inDate: DateTime.parse(json['inDate']), + status: json['status'], + type: json['type'] ?? EquipmentType.new_, + warehouseLocation: json['warehouseLocation'], + partnerCompany: json['partnerCompany'], + remark: json['remark'], + ); + } +} + +class EquipmentOut { + final int? id; + final Equipment equipment; + final DateTime outDate; + final String status; // O (출고), I (재입고), R (수리) + final String? company; // 출고 회사 + final String? manager; // 담당자 + final String? license; // 라이센스 + final DateTime? returnDate; // 재입고/수리 날짜 + final String? returnType; // 재입고/수리 유형 + final String? remark; // 비고 + + EquipmentOut({ + this.id, + required this.equipment, + required this.outDate, + this.status = 'O', + this.company, + this.manager, + this.license, + this.returnDate, + this.returnType, + this.remark, + }); + + Map toJson() { + return { + 'id': id, + 'equipment': equipment.toJson(), + 'outDate': outDate.toIso8601String(), + 'status': status, + 'company': company, + 'manager': manager, + 'license': license, + 'returnDate': returnDate?.toIso8601String(), + 'returnType': returnType, + 'remark': remark, + }; + } + + factory EquipmentOut.fromJson(Map json) { + return EquipmentOut( + id: json['id'], + equipment: Equipment.fromJson(json['equipment']), + outDate: DateTime.parse(json['outDate']), + status: json['status'], + company: json['company'], + manager: json['manager'], + license: json['license'], + returnDate: + json['returnDate'] != null + ? DateTime.parse(json['returnDate']) + : null, + returnType: json['returnType'], + remark: json['remark'], + ); + } +} + +class UnifiedEquipment { + final int? id; + final Equipment equipment; + final DateTime date; // 입고일 또는 출고일 + final String + status; // 상태 코드: 'I'(입고), 'O'(출고), 'R'(수리중), 'D'(손상), 'L'(분실), 'E'(기타) + final String? notes; // 추가 비고 + final String? _type; // 내부용: 입고 장비 유형 + + UnifiedEquipment({ + this.id, + required this.equipment, + required this.date, + required this.status, + this.notes, + String? type, + }) : _type = type; + + // 장비 유형 반환 (입고 장비만) + String? get type => status == 'I' ? _type : null; + + // 장비 상태 텍스트 변환 + String get statusText { + switch (status) { + case EquipmentStatus.in_: + return '입고'; + case EquipmentStatus.out: + return '출고'; + case EquipmentStatus.rent: + return '대여'; + case EquipmentStatus.repair: + return '수리중'; + case EquipmentStatus.damaged: + return '손상'; + case EquipmentStatus.lost: + return '분실'; + case EquipmentStatus.etc: + return '기타'; + default: + return '알 수 없음'; + } + } + + // EquipmentIn 모델에서 변환 + factory UnifiedEquipment.fromEquipmentIn( + id, + equipment, + inDate, + status, { + String? type, + }) { + return UnifiedEquipment( + id: id, + equipment: equipment, + date: inDate, + status: status, + type: type, + ); + } + + // EquipmentOut 모델에서 변환 + factory UnifiedEquipment.fromEquipmentOut(id, equipment, outDate, status) { + return UnifiedEquipment( + id: id, + equipment: equipment, + date: outDate, + status: status, + ); + } + + Map toJson() { + return { + 'id': id, + 'equipment': equipment.toJson(), + 'date': date.toIso8601String(), + 'status': status, + 'notes': notes, + }; + } + + factory UnifiedEquipment.fromJson(Map json) { + return UnifiedEquipment( + id: json['id'], + equipment: Equipment.fromJson(json['equipment']), + date: DateTime.parse(json['date']), + status: json['status'], + notes: json['notes'], + ); + } +} diff --git a/lib/models/license_model.dart b/lib/models/license_model.dart new file mode 100644 index 0000000..91a659c --- /dev/null +++ b/lib/models/license_model.dart @@ -0,0 +1,35 @@ +class License { + final int? id; + final int companyId; + final String name; + final int durationMonths; + final String visitCycle; // 방문주기(월, 격월, 분기 등) + + License({ + this.id, + required this.companyId, + required this.name, + required this.durationMonths, + required this.visitCycle, + }); + + Map toJson() { + return { + 'id': id, + 'companyId': companyId, + 'name': name, + 'durationMonths': durationMonths, + 'visitCycle': visitCycle, + }; + } + + factory License.fromJson(Map json) { + return License( + id: json['id'], + companyId: json['companyId'], + name: json['name'], + durationMonths: json['durationMonths'], + visitCycle: json['visitCycle'] as String, + ); + } +} diff --git a/lib/models/user_model.dart b/lib/models/user_model.dart new file mode 100644 index 0000000..7ede530 --- /dev/null +++ b/lib/models/user_model.dart @@ -0,0 +1,50 @@ +class User { + final int? id; + final int companyId; + final int? branchId; // 지점 ID + final String name; + final String role; // 관리등급: S(관리자), M(멤버) + final String? position; // 직급 + final String? email; // 이메일 + final List> phoneNumbers; // 전화번호 목록 (유형과 번호) + + User({ + this.id, + required this.companyId, + this.branchId, + required this.name, + required this.role, + this.position, + this.email, + this.phoneNumbers = const [], + }); + + Map toJson() { + return { + 'id': id, + 'companyId': companyId, + 'branchId': branchId, + 'name': name, + 'role': role, + 'position': position, + 'email': email, + 'phoneNumbers': phoneNumbers, + }; + } + + factory User.fromJson(Map json) { + return User( + id: json['id'], + companyId: json['companyId'], + branchId: json['branchId'], + name: json['name'], + role: json['role'], + position: json['position'], + email: json['email'], + phoneNumbers: + json['phoneNumbers'] != null + ? List>.from(json['phoneNumbers']) + : [], + ); + } +} diff --git a/lib/models/user_phone_field.dart b/lib/models/user_phone_field.dart new file mode 100644 index 0000000..3fa42fe --- /dev/null +++ b/lib/models/user_phone_field.dart @@ -0,0 +1,19 @@ +// 전화번호 입력 필드 관리를 위한 클래스 +// 타입 안정성 및 코드 간결성을 위해 사용 +import 'package:flutter/material.dart'; + +class UserPhoneField { + // 전화번호 종류(휴대폰, 사무실 등) + String type; + // 전화번호 입력 컨트롤러 + final TextEditingController controller; + + UserPhoneField({required this.type, String? initialValue}) + : controller = TextEditingController(text: initialValue); + + // 현재 입력된 전화번호 반환 + String get number => controller.text; + + // 컨트롤러 해제 + void dispose() => controller.dispose(); +} diff --git a/lib/models/warehouse_location_model.dart b/lib/models/warehouse_location_model.dart new file mode 100644 index 0000000..0153e36 --- /dev/null +++ b/lib/models/warehouse_location_model.dart @@ -0,0 +1,38 @@ +import 'address_model.dart'; + +/// 입고지 정보를 나타내는 모델 클래스 +class WarehouseLocation { + /// 입고지 고유 번호 + final int id; + + /// 입고지명 + final String name; + + /// 입고지 주소 + final Address address; + + /// 비고 + final String? remark; + + WarehouseLocation({ + required this.id, + required this.name, + required this.address, + this.remark, + }); + + /// 복사본 생성 (불변성 유지) + WarehouseLocation copyWith({ + int? id, + String? name, + Address? address, + String? remark, + }) { + return WarehouseLocation( + id: id ?? this.id, + name: name ?? this.name, + address: address ?? this.address, + remark: remark ?? this.remark, + ); + } +} diff --git a/lib/screens/common/app_layout.dart b/lib/screens/common/app_layout.dart new file mode 100644 index 0000000..e1b6cd0 --- /dev/null +++ b/lib/screens/common/app_layout.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/sidebar/sidebar_screen.dart'; +import 'package:superport/screens/overview/overview_screen.dart'; +import 'package:superport/screens/equipment/equipment_list.dart'; +import 'package:superport/screens/company/company_list.dart'; +import 'package:superport/screens/user/user_list.dart'; +import 'package:superport/screens/license/license_list.dart'; +import 'package:superport/screens/warehouse_location/warehouse_location_list.dart'; +import 'package:superport/screens/goods/goods_list.dart'; +import 'package:superport/utils/constants.dart'; + +/// SPA 스타일의 앱 레이아웃 클래스 +/// 사이드바는 고정되고 내용만 변경되는 구조를 제공 +class AppLayout extends StatefulWidget { + final String initialRoute; + + const AppLayout({Key? key, this.initialRoute = Routes.home}) + : super(key: key); + + @override + _AppLayoutState createState() => _AppLayoutState(); +} + +class _AppLayoutState extends State { + late String _currentRoute; + + @override + void initState() { + super.initState(); + _currentRoute = widget.initialRoute; + } + + /// 현재 경로에 따라 적절한 컨텐츠 섹션을 반환 + Widget _getContentForRoute(String route) { + switch (route) { + case Routes.home: + return const OverviewScreen(); + case Routes.equipment: + case Routes.equipmentInList: + case Routes.equipmentOutList: + case Routes.equipmentRentList: + // 장비 목록 화면에 현재 라우트 정보를 전달 + return EquipmentListScreen(currentRoute: route); + case Routes.goods: + return const GoodsListScreen(); + case Routes.company: + return const CompanyListScreen(); + case Routes.license: + return const MaintenanceListScreen(); + case Routes.warehouseLocation: + return const WarehouseLocationListScreen(); + default: + return const OverviewScreen(); + } + } + + /// 경로 변경 메서드 + void _navigateTo(String route) { + setState(() { + _currentRoute = route; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Row( + children: [ + // 왼쪽 사이드바 + SizedBox( + width: 280, + child: SidebarMenu( + currentRoute: _currentRoute, + onRouteChanged: _navigateTo, + ), + ), + // 오른쪽 컨텐츠 영역 + Expanded(child: _getContentForRoute(_currentRoute)), + ], + ), + ); + } +} diff --git a/lib/screens/common/custom_widgets.dart b/lib/screens/common/custom_widgets.dart new file mode 100644 index 0000000..e87c80b --- /dev/null +++ b/lib/screens/common/custom_widgets.dart @@ -0,0 +1,8 @@ +export 'custom_widgets/page_title.dart'; +export 'custom_widgets/data_table_card.dart'; +export 'custom_widgets/form_field_wrapper.dart'; +export 'custom_widgets/date_picker_field.dart'; +export 'custom_widgets/highlight_text.dart'; +export 'custom_widgets/autocomplete_dropdown.dart'; +export 'custom_widgets/category_selection_field.dart'; +export 'custom_widgets/category_data.dart'; diff --git a/lib/screens/common/custom_widgets/autocomplete_dropdown.dart b/lib/screens/common/custom_widgets/autocomplete_dropdown.dart new file mode 100644 index 0000000..6b2ef4b --- /dev/null +++ b/lib/screens/common/custom_widgets/autocomplete_dropdown.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'highlight_text.dart'; + +// 자동완성 드롭다운 공통 위젯 +class AutocompleteDropdown extends StatelessWidget { + // 드롭다운에 표시할 항목 리스트 + final List items; + // 현재 입력된 텍스트(하이라이트 기준) + final String inputText; + // 항목 선택 시 콜백 + final void Function(String) onSelect; + // 드롭다운 표시 여부 + final bool showDropdown; + // 최대 높이(항목 개수에 따라 자동 조절) + final double maxHeight; + // 드롭다운이 비었을 때 표시할 위젯 + final Widget emptyWidget; + + const AutocompleteDropdown({ + Key? key, + required this.items, + required this.inputText, + required this.onSelect, + required this.showDropdown, + this.maxHeight = 200, + this.emptyWidget = const Padding( + padding: EdgeInsets.all(12.0), + child: Text('검색 결과가 없습니다'), + ), + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: + showDropdown + ? (items.length > 4 ? maxHeight : items.length * 50.0) + : 0, + margin: EdgeInsets.only(top: showDropdown ? 4 : 0), + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: GestureDetector( + onTap: () {}, // 이벤트 버블링 방지 + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.grey.shade300), + boxShadow: [ + BoxShadow( + color: Colors.grey.withAlpha(77), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + child: + items.isEmpty + ? emptyWidget + : ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: items.length, + separatorBuilder: + (context, index) => + Divider(height: 1, color: Colors.grey.shade200), + itemBuilder: (context, index) { + final String item = items[index]; + return ListTile( + dense: true, + title: HighlightText( + text: item, + highlight: inputText, + highlightColor: Theme.of(context).primaryColor, + ), + onTap: () => onSelect(item), + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/common/custom_widgets/category_data.dart b/lib/screens/common/custom_widgets/category_data.dart new file mode 100644 index 0000000..82a0fd1 --- /dev/null +++ b/lib/screens/common/custom_widgets/category_data.dart @@ -0,0 +1,18 @@ +// 카테고리 데이터 (예시) +final Map>> categoryData = { + '컴퓨터': { + '데스크탑': ['사무용', '게이밍', '워크스테이션'], + '노트북': ['사무용', '게이밍', '울트라북'], + '태블릿': ['안드로이드', 'iOS', '윈도우'], + }, + '네트워크': { + '라우터': ['가정용', '기업용', '산업용'], + '스위치': ['관리형', '비관리형'], + '액세스 포인트': ['실내용', '실외용'], + }, + '주변기기': { + '모니터': ['LCD', 'LED', 'OLED'], + '키보드': ['유선', '무선', '기계식'], + '마우스': ['유선', '무선', '트랙볼'], + }, +}; diff --git a/lib/screens/common/custom_widgets/category_selection_field.dart b/lib/screens/common/custom_widgets/category_selection_field.dart new file mode 100644 index 0000000..a0a9280 --- /dev/null +++ b/lib/screens/common/custom_widgets/category_selection_field.dart @@ -0,0 +1,562 @@ +import 'package:flutter/material.dart'; +import 'autocomplete_dropdown.dart'; +import 'form_field_wrapper.dart'; +import 'category_data.dart'; + +// 카테고리 선택 필드 (대분류/중분류/소분류) +class CategorySelectionField extends StatefulWidget { + final String category; + final String subCategory; + final String subSubCategory; + final Function(String, String, String) onCategoryChanged; + final bool isRequired; + + const CategorySelectionField({ + Key? key, + required this.category, + required this.subCategory, + required this.subSubCategory, + required this.onCategoryChanged, + this.isRequired = false, + }) : super(key: key); + + @override + State createState() => _CategorySelectionFieldState(); +} + +class _CategorySelectionFieldState extends State { + // 검색 관련 컨트롤러 및 상태 변수 + final TextEditingController _categoryController = TextEditingController(); + final FocusNode _categoryFocusNode = FocusNode(); + bool _showCategoryDropdown = false; + List _filteredCategories = []; + + // 중분류 관련 변수 + final TextEditingController _subCategoryController = TextEditingController(); + final FocusNode _subCategoryFocusNode = FocusNode(); + bool _showSubCategoryDropdown = false; + List _filteredSubCategories = []; + + // 소분류 관련 변수 + final TextEditingController _subSubCategoryController = + TextEditingController(); + final FocusNode _subSubCategoryFocusNode = FocusNode(); + bool _showSubSubCategoryDropdown = false; + List _filteredSubSubCategories = []; + + List _allCategories = []; + String _selectedCategory = ''; + String _selectedSubCategory = ''; + String _selectedSubSubCategory = ''; + + @override + void initState() { + super.initState(); + _selectedCategory = widget.category; + _selectedSubCategory = widget.subCategory; + _selectedSubSubCategory = widget.subSubCategory; + _categoryController.text = _selectedCategory; + _subCategoryController.text = _selectedSubCategory; + _subSubCategoryController.text = _selectedSubSubCategory; + + // 모든 카테고리 목록 초기화 + _allCategories = categoryData.keys.toList(); + _filteredCategories = List.from(_allCategories); + + // 중분류 목록 초기화 + _updateSubCategories(); + + // 소분류 목록 초기화 + _updateSubSubCategories(); + + // 대분류 컨트롤러 리스너 설정 + _categoryController.addListener(_onCategoryTextChanged); + _categoryFocusNode.addListener(() { + setState(() { + if (_categoryFocusNode.hasFocus) { + _showCategoryDropdown = _filteredCategories.isNotEmpty; + } else { + _showCategoryDropdown = false; + } + }); + }); + + // 중분류 컨트롤러 리스너 설정 + _subCategoryController.addListener(_onSubCategoryTextChanged); + _subCategoryFocusNode.addListener(() { + setState(() { + if (_subCategoryFocusNode.hasFocus) { + _showSubCategoryDropdown = _filteredSubCategories.isNotEmpty; + } else { + _showSubCategoryDropdown = false; + } + }); + }); + + // 소분류 컨트롤러 리스너 설정 + _subSubCategoryController.addListener(_onSubSubCategoryTextChanged); + _subSubCategoryFocusNode.addListener(() { + setState(() { + if (_subSubCategoryFocusNode.hasFocus) { + _showSubSubCategoryDropdown = _filteredSubSubCategories.isNotEmpty; + } else { + _showSubSubCategoryDropdown = false; + } + }); + }); + } + + @override + void dispose() { + _categoryController.dispose(); + _categoryFocusNode.dispose(); + _subCategoryController.dispose(); + _subCategoryFocusNode.dispose(); + _subSubCategoryController.dispose(); + _subSubCategoryFocusNode.dispose(); + super.dispose(); + } + + // 중분류 목록 업데이트 + void _updateSubCategories() { + if (_selectedCategory.isNotEmpty) { + final subCategories = + categoryData[_selectedCategory]?.keys.toList() ?? []; + _filteredSubCategories = List.from(subCategories); + } else { + _filteredSubCategories = []; + } + } + + // 소분류 목록 업데이트 + void _updateSubSubCategories() { + if (_selectedCategory.isNotEmpty && _selectedSubCategory.isNotEmpty) { + final subSubCategories = + categoryData[_selectedCategory]?[_selectedSubCategory] ?? []; + _filteredSubSubCategories = List.from(subSubCategories); + } else { + _filteredSubSubCategories = []; + } + } + + void _onCategoryTextChanged() { + final text = _categoryController.text; + setState(() { + _selectedCategory = text; + + if (text.isEmpty) { + _filteredCategories = List.from(_allCategories); + } else { + _filteredCategories = + _allCategories + .where( + (item) => item.toLowerCase().contains(text.toLowerCase()), + ) + .toList(); + + // 시작 부분이 일치하는 항목 우선 정렬 + _filteredCategories.sort((a, b) { + bool aStartsWith = a.toLowerCase().startsWith(text.toLowerCase()); + bool bStartsWith = b.toLowerCase().startsWith(text.toLowerCase()); + + if (aStartsWith && !bStartsWith) return -1; + if (!aStartsWith && bStartsWith) return 1; + return a.compareTo(b); + }); + } + + _showCategoryDropdown = + _filteredCategories.isNotEmpty && _categoryFocusNode.hasFocus; + + // 카테고리가 변경되면 하위 카테고리 초기화 + if (_selectedCategory != widget.category) { + _selectedSubCategory = ''; + _subCategoryController.text = ''; + _selectedSubSubCategory = ''; + _subSubCategoryController.text = ''; + _updateSubCategories(); + _updateSubSubCategories(); + } + + // 콜백 호출 + widget.onCategoryChanged( + _selectedCategory, + _selectedSubCategory, + _selectedSubSubCategory, + ); + }); + } + + // 중분류 텍스트 변경 핸들러 + void _onSubCategoryTextChanged() { + final text = _subCategoryController.text; + setState(() { + _selectedSubCategory = text; + + if (_selectedCategory.isNotEmpty) { + final subCategories = + categoryData[_selectedCategory]?.keys.toList() ?? []; + + if (text.isEmpty) { + _filteredSubCategories = List.from(subCategories); + } else { + _filteredSubCategories = + subCategories + .where( + (item) => item.toLowerCase().contains(text.toLowerCase()), + ) + .toList(); + + // 시작 부분이 일치하는 항목 우선 정렬 + _filteredSubCategories.sort((a, b) { + bool aStartsWith = a.toLowerCase().startsWith(text.toLowerCase()); + bool bStartsWith = b.toLowerCase().startsWith(text.toLowerCase()); + + if (aStartsWith && !bStartsWith) return -1; + if (!aStartsWith && bStartsWith) return 1; + return a.compareTo(b); + }); + } + } else { + _filteredSubCategories = []; + } + + _showSubCategoryDropdown = + _filteredSubCategories.isNotEmpty && _subCategoryFocusNode.hasFocus; + + // 중분류가 변경되면 소분류 초기화 + if (_selectedSubCategory != widget.subCategory) { + _selectedSubSubCategory = ''; + _subSubCategoryController.text = ''; + _updateSubSubCategories(); + } + + // 콜백 호출 + widget.onCategoryChanged( + _selectedCategory, + _selectedSubCategory, + _selectedSubSubCategory, + ); + }); + } + + // 소분류 텍스트 변경 핸들러 + void _onSubSubCategoryTextChanged() { + final text = _subSubCategoryController.text; + setState(() { + _selectedSubSubCategory = text; + + if (_selectedCategory.isNotEmpty && _selectedSubCategory.isNotEmpty) { + final subSubCategories = + categoryData[_selectedCategory]?[_selectedSubCategory] ?? []; + + if (text.isEmpty) { + _filteredSubSubCategories = List.from(subSubCategories); + } else { + _filteredSubSubCategories = + subSubCategories + .where( + (item) => item.toLowerCase().contains(text.toLowerCase()), + ) + .toList(); + + // 시작 부분이 일치하는 항목 우선 정렬 + _filteredSubSubCategories.sort((a, b) { + bool aStartsWith = a.toLowerCase().startsWith(text.toLowerCase()); + bool bStartsWith = b.toLowerCase().startsWith(text.toLowerCase()); + + if (aStartsWith && !bStartsWith) return -1; + if (!aStartsWith && bStartsWith) return 1; + return a.compareTo(b); + }); + } + } else { + _filteredSubSubCategories = []; + } + + _showSubSubCategoryDropdown = + _filteredSubSubCategories.isNotEmpty && + _subSubCategoryFocusNode.hasFocus; + + // 콜백 호출 + widget.onCategoryChanged( + _selectedCategory, + _selectedSubCategory, + _selectedSubSubCategory, + ); + }); + } + + void _selectCategory(String category) { + setState(() { + _selectedCategory = category; + _categoryController.text = category; + _showCategoryDropdown = false; + _selectedSubCategory = ''; + _subCategoryController.text = ''; + _selectedSubSubCategory = ''; + _subSubCategoryController.text = ''; + _updateSubCategories(); + _updateSubSubCategories(); + widget.onCategoryChanged( + _selectedCategory, + _selectedSubCategory, + _selectedSubSubCategory, + ); + }); + } + + // 중분류 선택 핸들러 + void _selectSubCategory(String subCategory) { + setState(() { + _selectedSubCategory = subCategory; + _subCategoryController.text = subCategory; + _showSubCategoryDropdown = false; + _selectedSubSubCategory = ''; + _subSubCategoryController.text = ''; + _updateSubSubCategories(); + widget.onCategoryChanged( + _selectedCategory, + _selectedSubCategory, + _selectedSubSubCategory, + ); + }); + } + + // 소분류 선택 핸들러 + void _selectSubSubCategory(String subSubCategory) { + setState(() { + _selectedSubSubCategory = subSubCategory; + _subSubCategoryController.text = subSubCategory; + _showSubSubCategoryDropdown = false; + widget.onCategoryChanged( + _selectedCategory, + _selectedSubCategory, + _selectedSubSubCategory, + ); + }); + } + + @override + Widget build(BuildContext context) { + return FormFieldWrapper( + label: '카테고리', + isRequired: widget.isRequired, + child: Column( + children: [ + // 대분류 입력 필드 (자동완성) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _categoryController, + focusNode: _categoryFocusNode, + decoration: InputDecoration( + hintText: '대분류', + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + suffixIcon: + _categoryController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + setState(() { + _categoryController.clear(); + _selectedCategory = ''; + _selectedSubCategory = ''; + _selectedSubSubCategory = ''; + _subCategoryController.clear(); + _subSubCategoryController.clear(); + _filteredCategories = List.from(_allCategories); + _filteredSubCategories = []; + _filteredSubSubCategories = []; + _showCategoryDropdown = + _categoryFocusNode.hasFocus; + widget.onCategoryChanged('', '', ''); + }); + }, + ) + : IconButton( + icon: const Icon(Icons.arrow_drop_down), + onPressed: () { + setState(() { + _showCategoryDropdown = !_showCategoryDropdown; + }); + }, + ), + ), + validator: (value) { + if (widget.isRequired && (value == null || value.isEmpty)) { + return '대분류를 선택해주세요'; + } + return null; + }, + onTap: () { + setState(() { + if (!_showCategoryDropdown) { + _showCategoryDropdown = true; + } + }); + }, + ), + + // 대분류 자동완성 드롭다운 + AutocompleteDropdown( + items: _filteredCategories, + inputText: _categoryController.text, + onSelect: _selectCategory, + showDropdown: _showCategoryDropdown, + ), + ], + ), + + const SizedBox(height: 12), + + // 중분류 및 소분류 선택 행 + Row( + children: [ + // 중분류 입력 필드 (자동완성) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _subCategoryController, + focusNode: _subCategoryFocusNode, + decoration: InputDecoration( + hintText: '중분류', + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + suffixIcon: + _subCategoryController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + setState(() { + _subCategoryController.clear(); + _selectedSubCategory = ''; + _selectedSubSubCategory = ''; + _subSubCategoryController.clear(); + _updateSubCategories(); + _updateSubSubCategories(); + _showSubCategoryDropdown = + _subCategoryFocusNode.hasFocus; + widget.onCategoryChanged( + _selectedCategory, + '', + '', + ); + }); + }, + ) + : IconButton( + icon: const Icon(Icons.arrow_drop_down), + onPressed: () { + setState(() { + _showSubCategoryDropdown = + !_showSubCategoryDropdown; + }); + }, + ), + ), + enabled: _selectedCategory.isNotEmpty, + onTap: () { + setState(() { + if (!_showSubCategoryDropdown && + _filteredSubCategories.isNotEmpty) { + _showSubCategoryDropdown = true; + } + }); + }, + ), + + // 중분류 자동완성 드롭다운 + AutocompleteDropdown( + items: _filteredSubCategories, + inputText: _subCategoryController.text, + onSelect: _selectSubCategory, + showDropdown: _showSubCategoryDropdown, + ), + ], + ), + ), + + const SizedBox(width: 12), + + // 소분류 입력 필드 (자동완성) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _subSubCategoryController, + focusNode: _subSubCategoryFocusNode, + decoration: InputDecoration( + hintText: '소분류', + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + suffixIcon: + _subSubCategoryController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + setState(() { + _subSubCategoryController.clear(); + _selectedSubSubCategory = ''; + _updateSubSubCategories(); + _showSubSubCategoryDropdown = + _subSubCategoryFocusNode.hasFocus; + widget.onCategoryChanged( + _selectedCategory, + _selectedSubCategory, + '', + ); + }); + }, + ) + : IconButton( + icon: const Icon(Icons.arrow_drop_down), + onPressed: () { + setState(() { + _showSubSubCategoryDropdown = + !_showSubSubCategoryDropdown; + }); + }, + ), + ), + enabled: + _selectedCategory.isNotEmpty && + _selectedSubCategory.isNotEmpty, + onTap: () { + setState(() { + if (!_showSubSubCategoryDropdown && + _filteredSubSubCategories.isNotEmpty) { + _showSubSubCategoryDropdown = true; + } + }); + }, + ), + + // 소분류 자동완성 드롭다운 + AutocompleteDropdown( + items: _filteredSubSubCategories, + inputText: _subSubCategoryController.text, + onSelect: _selectSubSubCategory, + showDropdown: _showSubSubCategoryDropdown, + ), + ], + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/screens/common/custom_widgets/data_table_card.dart b/lib/screens/common/custom_widgets/data_table_card.dart new file mode 100644 index 0000000..004f375 --- /dev/null +++ b/lib/screens/common/custom_widgets/data_table_card.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; + +// 데이터 테이블 카드 +class DataTableCard extends StatelessWidget { + final Widget child; + final String? title; + final double? width; + + const DataTableCard({Key? key, required this.child, this.title, this.width}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + width: width, + decoration: AppThemeTailwind.cardDecoration, + margin: const EdgeInsets.only(bottom: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) + Padding( + padding: const EdgeInsets.all(16.0), + child: Text(title!, style: AppThemeTailwind.subheadingStyle), + ), + Padding(padding: const EdgeInsets.all(16.0), child: child), + ], + ), + ); + } +} diff --git a/lib/screens/common/custom_widgets/date_picker_field.dart b/lib/screens/common/custom_widgets/date_picker_field.dart new file mode 100644 index 0000000..872dd42 --- /dev/null +++ b/lib/screens/common/custom_widgets/date_picker_field.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'form_field_wrapper.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; + +// 날짜 선택 필드 +class DatePickerField extends StatelessWidget { + final DateTime selectedDate; + final Function(DateTime) onDateChanged; + final bool allowFutureDate; + final bool isRequired; + + const DatePickerField({ + Key? key, + required this.selectedDate, + required this.onDateChanged, + this.allowFutureDate = false, + this.isRequired = false, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: selectedDate, + firstDate: DateTime(2000), + lastDate: allowFutureDate ? DateTime(2100) : DateTime.now(), + ); + if (picked != null && picked != selectedDate) { + onDateChanged(picked); + } + }, + child: FormFieldWrapper( + label: '날짜', + isRequired: isRequired, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 15), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${selectedDate.year}-${selectedDate.month.toString().padLeft(2, '0')}-${selectedDate.day.toString().padLeft(2, '0')}', + style: AppThemeTailwind.bodyStyle, + ), + const Icon(Icons.calendar_today, size: 20), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/common/custom_widgets/form_field_wrapper.dart b/lib/screens/common/custom_widgets/form_field_wrapper.dart new file mode 100644 index 0000000..17b9021 --- /dev/null +++ b/lib/screens/common/custom_widgets/form_field_wrapper.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +// 폼 필드 래퍼 +class FormFieldWrapper extends StatelessWidget { + final String label; + final Widget child; + final bool isRequired; + + const FormFieldWrapper({ + Key? key, + required this.label, + required this.child, + this.isRequired = false, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + label, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + if (isRequired) + const Text( + ' *', + style: TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8.0), + child, + ], + ), + ); + } +} diff --git a/lib/screens/common/custom_widgets/highlight_text.dart b/lib/screens/common/custom_widgets/highlight_text.dart new file mode 100644 index 0000000..53cea6f --- /dev/null +++ b/lib/screens/common/custom_widgets/highlight_text.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +// 자동완성 드롭다운에서 텍스트 하이라이트를 위한 위젯 +class HighlightText extends StatelessWidget { + // 전체 텍스트 + final String text; + // 하이라이트할 부분 + final String highlight; + // 하이라이트 색상 + final Color highlightColor; + // 텍스트 스타일 + final TextStyle? style; + + const HighlightText({ + Key? key, + required this.text, + required this.highlight, + this.highlightColor = Colors.blue, + this.style, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (highlight.isEmpty) { + // 하이라이트가 없으면 전체 텍스트 반환 + return Text(text, style: style); + } + final String lowerText = text.toLowerCase(); + final String lowerHighlight = highlight.toLowerCase(); + final int start = lowerText.indexOf(lowerHighlight); + if (start < 0) { + // 일치하는 부분이 없으면 전체 텍스트 반환 + return Text(text, style: style); + } + final int end = start + highlight.length; + return RichText( + text: TextSpan( + style: style ?? DefaultTextStyle.of(context).style, + children: [ + if (start > 0) TextSpan(text: text.substring(0, start)), + TextSpan( + text: text.substring(start, end), + style: TextStyle( + fontWeight: FontWeight.bold, + color: highlightColor, + ), + ), + if (end < text.length) TextSpan(text: text.substring(end)), + ], + ), + ); + } +} diff --git a/lib/screens/common/custom_widgets/page_title.dart b/lib/screens/common/custom_widgets/page_title.dart new file mode 100644 index 0000000..867437d --- /dev/null +++ b/lib/screens/common/custom_widgets/page_title.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; + +// 페이지 타이틀 위젯 +class PageTitle extends StatelessWidget { + final String title; + final Widget? rightWidget; + final double? width; + + const PageTitle({Key? key, required this.title, this.rightWidget, this.width}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + width: width, + margin: const EdgeInsets.only(bottom: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: AppThemeTailwind.headingStyle), + if (rightWidget != null) rightWidget!, + ], + ), + ); + } +} diff --git a/lib/screens/common/layout_components.dart b/lib/screens/common/layout_components.dart new file mode 100644 index 0000000..35974c2 --- /dev/null +++ b/lib/screens/common/layout_components.dart @@ -0,0 +1,9 @@ +/// 메트로닉 스타일 공통 레이아웃 컴포넌트 barrel 파일 +/// 각 위젯은 SRP에 따라 별도 파일로 분리되어 있습니다. +export 'metronic_page_container.dart'; +export 'metronic_card.dart'; +export 'metronic_stats_card.dart'; +export 'metronic_page_title.dart'; +export 'metronic_data_table.dart'; +export 'metronic_form_field.dart'; +export 'metronic_tab_container.dart'; diff --git a/lib/screens/common/main_layout.dart b/lib/screens/common/main_layout.dart new file mode 100644 index 0000000..72ba947 --- /dev/null +++ b/lib/screens/common/main_layout.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; + +class MainLayout extends StatelessWidget { + final String title; + final Widget child; + final String currentRoute; + final List? actions; + final bool showBackButton; + final Widget? floatingActionButton; + + const MainLayout({ + Key? key, + required this.title, + required this.child, + required this.currentRoute, + this.actions, + this.showBackButton = false, + this.floatingActionButton, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + // MetronicCloud 스타일: 상단부 플랫, 여백 넓게, 타이틀/경로/버튼 스타일링 + return Scaffold( + backgroundColor: AppThemeTailwind.surface, + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 상단 앱바 + _buildAppBar(context), + // 컨텐츠 + Expanded(child: child), + ], + ), + floatingActionButton: floatingActionButton, + ); + } + + Widget _buildAppBar(BuildContext context) { + // 상단 앱바: 경로 텍스트가 수직 중앙에 오도록 조정, 배경색/글자색 변경 + return Container( + height: 88, + padding: const EdgeInsets.symmetric(horizontal: 40), + decoration: BoxDecoration( + color: AppThemeTailwind.surface, // 회색 배경 + border: const Border( + bottom: BorderSide(color: Color(0xFFF3F6F9), width: 1), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, // Row 내에서 수직 중앙 정렬 + children: [ + // 경로 및 타이틀 영역 (수직 중앙 정렬) + Column( + mainAxisAlignment: MainAxisAlignment.center, // Column 내에서 수직 중앙 정렬 + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 경로 텍스트 (폰트 사이즈 2배, 검은색 글자) + Text( + _getBreadcrumb(currentRoute), + style: TextStyle( + fontSize: 26, + color: AppThemeTailwind.dark, + ), // 검은색 글자 + ), + // 타이틀이 있을 때만 표시 + if (title.isNotEmpty) + Text( + title, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppThemeTailwind.dark, + ), + ), + ], + ), + const Spacer(), + if (actions != null) + Row( + children: + actions! + .map( + (w) => Padding( + padding: const EdgeInsets.only(left: 8), + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: AppThemeTailwind.muted, + width: 1, + ), + color: const Color(0xFFF7F8FA), + borderRadius: BorderRadius.circular(8), + ), + child: w, + ), + ), + ) + .toList(), + ), + ], + ), + ); + } + + // 현재 라우트에 따라 경로 문자열을 반환하는 함수 + String _getBreadcrumb(String route) { + // 실제 라우트에 따라 경로를 한글로 변환 (예시) + switch (route) { + case '/': + case '/home': + return '홈 / 대시보드'; + case '/equipment': + return '홈 / 장비 관리'; + case '/company': + return '홈 / 회사 관리'; + case '/maintenance': + return '홈 / 유지보수 관리'; + case '/item': + return '홈 / 물품 관리'; + default: + return '홈'; + } + } +} diff --git a/lib/screens/common/metronic_card.dart b/lib/screens/common/metronic_card.dart new file mode 100644 index 0000000..985c393 --- /dev/null +++ b/lib/screens/common/metronic_card.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; + +/// 메트로닉 스타일 카드 위젯 (SRP 분리) +class MetronicCard extends StatelessWidget { + final String? title; + final Widget child; + final List? actions; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + + const MetronicCard({ + Key? key, + this.title, + required this.child, + this.actions, + this.padding, + this.margin, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + decoration: AppThemeTailwind.cardDecoration, + margin: margin, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null || actions != null) + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (title != null) + Text(title!, style: AppThemeTailwind.subheadingStyle), + if (actions != null) Row(children: actions!), + ], + ), + ), + Padding(padding: padding ?? const EdgeInsets.all(16), child: child), + ], + ), + ); + } +} diff --git a/lib/screens/common/metronic_data_table.dart b/lib/screens/common/metronic_data_table.dart new file mode 100644 index 0000000..de16d01 --- /dev/null +++ b/lib/screens/common/metronic_data_table.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/screens/common/metronic_card.dart'; + +/// 메트로닉 스타일 데이터 테이블 카드 위젯 (SRP 분리) +class MetronicDataTable extends StatelessWidget { + final List columns; + final List rows; + final String? title; + final bool isLoading; + final String? emptyMessage; + + const MetronicDataTable({ + Key? key, + required this.columns, + required this.rows, + this.title, + this.isLoading = false, + this.emptyMessage, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return MetronicCard( + title: title, + child: + isLoading + ? const Center(child: CircularProgressIndicator()) + : rows.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Text( + emptyMessage ?? '데이터가 없습니다.', + style: AppThemeTailwind.bodyStyle, + ), + ), + ) + : SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: DataTable( + columns: columns, + rows: rows, + headingRowColor: MaterialStateProperty.all( + AppThemeTailwind.light, + ), + dataRowMaxHeight: 60, + columnSpacing: 24, + horizontalMargin: 16, + ), + ), + ), + ); + } +} diff --git a/lib/screens/common/metronic_form_field.dart b/lib/screens/common/metronic_form_field.dart new file mode 100644 index 0000000..13e35a6 --- /dev/null +++ b/lib/screens/common/metronic_form_field.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; + +/// 메트로닉 스타일 폼 필드 래퍼 위젯 (SRP 분리) +class MetronicFormField extends StatelessWidget { + final String label; + final Widget child; + final bool isRequired; + final String? helperText; + + const MetronicFormField({ + Key? key, + required this.label, + required this.child, + this.isRequired = false, + this.helperText, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + label, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: AppThemeTailwind.dark, + ), + ), + if (isRequired) + const Text( + ' *', + style: TextStyle( + color: AppThemeTailwind.danger, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + child, + if (helperText != null) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text(helperText!, style: AppThemeTailwind.smallText), + ), + ], + ), + ); + } +} diff --git a/lib/screens/common/metronic_page_container.dart b/lib/screens/common/metronic_page_container.dart new file mode 100644 index 0000000..6170a44 --- /dev/null +++ b/lib/screens/common/metronic_page_container.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; + +/// 메트로닉 스타일 페이지 컨테이너 위젯 (SRP 분리) +class MetronicPageContainer extends StatelessWidget { + final String title; + final Widget child; + final List? actions; + final bool showBackButton; + + const MetronicPageContainer({ + Key? key, + required this.title, + required this.child, + this.actions, + this.showBackButton = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(title), + automaticallyImplyLeading: showBackButton, + actions: actions, + elevation: 0, + ), + body: Container( + color: AppThemeTailwind.surface, + padding: const EdgeInsets.all(16), + child: child, + ), + ); + } +} diff --git a/lib/screens/common/metronic_page_title.dart b/lib/screens/common/metronic_page_title.dart new file mode 100644 index 0000000..2e02300 --- /dev/null +++ b/lib/screens/common/metronic_page_title.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; + +/// 메트로닉 스타일 페이지 타이틀 위젯 (SRP 분리) +class MetronicPageTitle extends StatelessWidget { + final String title; + final VoidCallback? onAddPressed; + final String? addButtonLabel; + + const MetronicPageTitle({ + Key? key, + required this.title, + this.onAddPressed, + this.addButtonLabel, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: AppThemeTailwind.headingStyle), + if (onAddPressed != null) + ElevatedButton.icon( + onPressed: onAddPressed, + icon: const Icon(Icons.add), + label: Text(addButtonLabel ?? '추가'), + style: AppThemeTailwind.primaryButtonStyle, + ), + ], + ), + ); + } +} diff --git a/lib/screens/common/metronic_stats_card.dart b/lib/screens/common/metronic_stats_card.dart new file mode 100644 index 0000000..dd1d32f --- /dev/null +++ b/lib/screens/common/metronic_stats_card.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; + +/// 메트로닉 스타일 통계 카드 위젯 (SRP 분리) +class MetronicStatsCard extends StatelessWidget { + final String title; + final String value; + final String? subtitle; + final IconData? icon; + final Color? iconBackgroundColor; + final bool showTrend; + final double? trendPercentage; + final bool isPositiveTrend; + + const MetronicStatsCard({ + Key? key, + required this.title, + required this.value, + this.subtitle, + this.icon, + this.iconBackgroundColor, + this.showTrend = false, + this.trendPercentage, + this.isPositiveTrend = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + decoration: AppThemeTailwind.cardDecoration, + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: AppThemeTailwind.bodyStyle.copyWith( + color: AppThemeTailwind.muted, + fontSize: 12, + ), + ), + if (icon != null) + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: iconBackgroundColor ?? AppThemeTailwind.light, + shape: BoxShape.circle, + ), + child: Icon( + icon, + color: + iconBackgroundColor != null + ? Colors.white + : AppThemeTailwind.primary, + size: 16, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + value, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppThemeTailwind.dark, + ), + ), + if (subtitle != null || showTrend) const SizedBox(height: 4), + if (subtitle != null) + Text(subtitle!, style: AppThemeTailwind.smallText), + if (showTrend && trendPercentage != null) + Row( + children: [ + Icon( + isPositiveTrend ? Icons.arrow_upward : Icons.arrow_downward, + color: + isPositiveTrend + ? AppThemeTailwind.success + : AppThemeTailwind.danger, + size: 12, + ), + const SizedBox(width: 4), + Text( + '${trendPercentage!.toStringAsFixed(1)}%', + style: TextStyle( + color: + isPositiveTrend + ? AppThemeTailwind.success + : AppThemeTailwind.danger, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/screens/common/metronic_tab_container.dart b/lib/screens/common/metronic_tab_container.dart new file mode 100644 index 0000000..d3d03e9 --- /dev/null +++ b/lib/screens/common/metronic_tab_container.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; + +/// 메트로닉 스타일 탭 컨테이너 위젯 (SRP 분리) +class MetronicTabContainer extends StatelessWidget { + final List tabs; + final List tabViews; + final int initialIndex; + + const MetronicTabContainer({ + Key? key, + required this.tabs, + required this.tabViews, + this.initialIndex = 0, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: tabs.length, + initialIndex: initialIndex, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Color(0xFFE5E7EB), width: 1), + ), + ), + child: TabBar( + tabs: tabs.map((tab) => Tab(text: tab)).toList(), + labelColor: AppThemeTailwind.primary, + unselectedLabelColor: AppThemeTailwind.muted, + labelStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + indicatorColor: AppThemeTailwind.primary, + indicatorWeight: 2, + ), + ), + Expanded(child: TabBarView(children: tabViews)), + ], + ), + ); + } +} diff --git a/lib/screens/common/theme_tailwind.dart b/lib/screens/common/theme_tailwind.dart new file mode 100644 index 0000000..6beef1c --- /dev/null +++ b/lib/screens/common/theme_tailwind.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; + +/// Metronic Admin 테일윈드 테마 (데모6 스타일) +class AppThemeTailwind { + // 메인 컬러 팔레트 + static const Color primary = Color(0xFF5867DD); + static const Color secondary = Color(0xFF34BFA3); + static const Color success = Color(0xFF1BC5BD); + static const Color info = Color(0xFF8950FC); + static const Color warning = Color(0xFFFFA800); + static const Color danger = Color(0xFFF64E60); + static const Color light = Color(0xFFF3F6F9); + static const Color dark = Color(0xFF181C32); + static const Color muted = Color(0xFFB5B5C3); + + // 배경 컬러 + static const Color surface = Color(0xFFF7F8FA); + static const Color cardBackground = Colors.white; + + // 테마 데이터 + static ThemeData get lightTheme { + return ThemeData( + primaryColor: primary, + colorScheme: const ColorScheme.light( + primary: primary, + secondary: secondary, + background: surface, + surface: cardBackground, + error: danger, + ), + scaffoldBackgroundColor: surface, + fontFamily: 'Poppins', + + // AppBar 테마 + appBarTheme: const AppBarTheme( + backgroundColor: Colors.white, + foregroundColor: dark, + elevation: 0, + centerTitle: false, + titleTextStyle: TextStyle( + color: dark, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + iconTheme: IconThemeData(color: dark), + ), + + // 버튼 테마 + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ), + ), + + // 카드 테마 + cardTheme: CardTheme( + color: Colors.white, + elevation: 1, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + margin: const EdgeInsets.symmetric(vertical: 8), + ), + + // 입력 폼 테마 + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: Color(0xFFE5E7EB)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: Color(0xFFE5E7EB)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: primary), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: danger), + ), + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + + // 데이터 테이블 테마 + dataTableTheme: const DataTableThemeData( + headingRowColor: WidgetStatePropertyAll(light), + dividerThickness: 1, + columnSpacing: 24, + headingTextStyle: TextStyle( + color: dark, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + dataTextStyle: TextStyle(color: Color(0xFF6C7293), fontSize: 14), + ), + ); + } + + // 스타일 - 헤딩 및 텍스트 + static const TextStyle headingStyle = TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: dark, + ); + + static const TextStyle subheadingStyle = TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: dark, + ); + + static const TextStyle bodyStyle = TextStyle( + fontSize: 14, + color: Color(0xFF6C7293), + ); + + // 굵은 본문 텍스트 + static const TextStyle bodyBoldStyle = TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: dark, + ); + + static const TextStyle smallText = TextStyle(fontSize: 12, color: muted); + + // 버튼 스타일 + static final ButtonStyle primaryButtonStyle = ElevatedButton.styleFrom( + backgroundColor: primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ); + + // 라벨 스타일 + static const TextStyle formLabelStyle = TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: dark, + ); + + static final ButtonStyle secondaryButtonStyle = ElevatedButton.styleFrom( + backgroundColor: secondary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ); + + static final ButtonStyle outlineButtonStyle = OutlinedButton.styleFrom( + foregroundColor: primary, + side: const BorderSide(color: primary), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ); + + // 카드 장식 + static final BoxDecoration cardDecoration = BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(13), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ); + + // 기타 장식 + static final BoxDecoration containerDecoration = BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE5E7EB)), + ); + + static const EdgeInsets cardPadding = EdgeInsets.all(20); + static const EdgeInsets listPadding = EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ); +} diff --git a/lib/screens/common/widgets/address_input.dart b/lib/screens/common/widgets/address_input.dart new file mode 100644 index 0000000..dcb4818 --- /dev/null +++ b/lib/screens/common/widgets/address_input.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/custom_widgets.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/utils/address_constants.dart'; +import 'package:superport/models/address_model.dart'; + +/// 주소 입력 컴포넌트 +/// +/// 우편번호, 시/도 드롭다운, 상세주소로 구성된 주소 입력 폼입니다. +/// 1행 3열 구조로 배치되어 있으며, 각 필드는 SRP 원칙에 따라 개별적으로 관리됩니다. +class AddressInput extends StatefulWidget { + /// 최초 우편번호 값 + final String initialZipCode; + + /// 최초 시/도 값 + final String initialRegion; + + /// 최초 상세 주소 값 + final String initialDetailAddress; + + /// 주소가 변경될 때 호출되는 콜백 함수 + /// zipCode, region, detailAddress를 매개변수로 전달합니다. + final Function(String zipCode, String region, String detailAddress) + onAddressChanged; + + /// 필수 입력 여부 + final bool isRequired; + + const AddressInput({ + Key? key, + this.initialZipCode = '', + this.initialRegion = '', + this.initialDetailAddress = '', + required this.onAddressChanged, + this.isRequired = false, + }) : super(key: key); + + @override + State createState() => _AddressInputState(); + + /// Address 객체를 받아 읽기 전용으로 표시하는 위젯 + static Widget readonly({required Address address}) { + // 회사 리스트와 동일하게 address.toString() 사용, 스타일도 bodyStyle로 통일 + return Text(address.toString(), style: AppThemeTailwind.bodyStyle); + } +} + +class _AddressInputState extends State { + // 텍스트 컨트롤러 + late TextEditingController _zipCodeController; + late TextEditingController _detailAddressController; + + // 드롭다운 관련 변수 + String _selectedRegion = ''; + bool _showRegionDropdown = false; + + // 레이어 링크 (드롭다운 위치 조정용) + final LayerLink _regionLayerLink = LayerLink(); + + // 오버레이 엔트리 (드롭다운 메뉴) + OverlayEntry? _regionOverlayEntry; + + // 포커스 노드 + final FocusNode _regionFocusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _zipCodeController = TextEditingController(text: widget.initialZipCode); + _selectedRegion = widget.initialRegion; + _detailAddressController = TextEditingController( + text: widget.initialDetailAddress, + ); + + // 컨트롤러 변경 리스너 등록 + _zipCodeController.addListener(_notifyAddressChanged); + _detailAddressController.addListener(_notifyAddressChanged); + } + + @override + void dispose() { + _zipCodeController.dispose(); + _detailAddressController.dispose(); + _removeRegionOverlay(); + _regionFocusNode.dispose(); + super.dispose(); + } + + /// 주소 변경을 상위 위젯에 알립니다. + void _notifyAddressChanged() { + widget.onAddressChanged( + _zipCodeController.text, + _selectedRegion, + _detailAddressController.text, + ); + } + + /// 시/도 드롭다운을 토글합니다. + void _toggleRegionDropdown() { + setState(() { + if (_showRegionDropdown) { + _removeRegionOverlay(); + } else { + _showRegionDropdown = true; + _showRegionOverlay(); + } + }); + } + + /// 시/도 드롭다운 오버레이를 제거합니다. + void _removeRegionOverlay() { + _regionOverlayEntry?.remove(); + _regionOverlayEntry = null; + _showRegionDropdown = false; + } + + /// 시/도 드롭다운 오버레이를 표시합니다. + void _showRegionOverlay() { + final RenderBox renderBox = context.findRenderObject() as RenderBox; + final size = renderBox.size; + final offset = renderBox.localToGlobal(Offset.zero); + + final availableHeight = + MediaQuery.of(context).size.height - offset.dy - 100; + final maxHeight = 300.0 < availableHeight ? 300.0 : availableHeight; + + _regionOverlayEntry = OverlayEntry( + builder: + (context) => Positioned( + width: 200, + child: CompositedTransformFollower( + link: _regionLayerLink, + showWhenUnlinked: false, + offset: const Offset(0, 45), + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(4), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + constraints: BoxConstraints(maxHeight: maxHeight), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...KoreanRegions.topLevel.map( + (region) => InkWell( + onTap: () { + setState(() { + _selectedRegion = region; + _removeRegionOverlay(); + _notifyAddressChanged(); + }); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + width: double.infinity, + height: 48, + child: Text( + region, + style: AppThemeTailwind.bodyStyle.copyWith( + fontSize: 16, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + + Overlay.of(context).insert(_regionOverlayEntry!); + } + + @override + Widget build(BuildContext context) { + return FormFieldWrapper( + label: '주소', + isRequired: widget.isRequired, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 우편번호 입력 필드 (1열) + Expanded( + flex: 2, + child: TextField( + controller: _zipCodeController, + decoration: InputDecoration( + hintText: AddressLabels.zipCodeHint, + labelText: AddressLabels.zipCode, + border: const OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + + // 시/도 선택 드롭다운 (2열) + Expanded( + flex: 3, + child: CompositedTransformTarget( + link: _regionLayerLink, + child: InkWell( + onTap: _toggleRegionDropdown, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 0, + ), + height: 48, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _selectedRegion.isEmpty + ? AddressLabels.regionHint + : _selectedRegion, + style: TextStyle( + fontSize: 16, + color: + _selectedRegion.isEmpty + ? Colors.grey.shade600 + : Colors.black, + ), + ), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ), + ), + ), + const SizedBox(width: 8), + + // 상세 주소 입력 필드 (3열) + Expanded( + flex: 7, + child: TextField( + controller: _detailAddressController, + decoration: InputDecoration( + hintText: AddressLabels.detailHint, + labelText: AddressLabels.detail, + border: const OutlineInputBorder(), + ), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/screens/common/widgets/autocomplete_dropdown_field.dart b/lib/screens/common/widgets/autocomplete_dropdown_field.dart new file mode 100644 index 0000000..930361b --- /dev/null +++ b/lib/screens/common/widgets/autocomplete_dropdown_field.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; // kDebugMode 사용 + +/// 드롭다운 + 자동완성 + 텍스트 입력을 모두 지원하는 공통 위젯 +/// +/// - 텍스트 입력 시 자동완성 추천 리스트 노출 +/// - 드롭다운 버튼 클릭 시 전체 리스트 노출 +/// - 직접 입력, 선택 모두 가능 +/// - 재사용성 및 SRP 준수 +class AutocompleteDropdownField extends StatefulWidget { + final String label; + final String value; + final List items; + final bool isRequired; + final String hintText; + final void Function(String) onChanged; + final void Function(String) onSelected; + final bool enabled; + + const AutocompleteDropdownField({ + Key? key, + required this.label, + required this.value, + required this.items, + required this.onChanged, + required this.onSelected, + this.isRequired = false, + this.hintText = '', + this.enabled = true, + }) : super(key: key); + + @override + State createState() => + _AutocompleteDropdownFieldState(); +} + +class _AutocompleteDropdownFieldState extends State { + late TextEditingController _controller; + late final FocusNode _focusNode; + late List _filteredItems; + bool _showDropdown = false; + // 위젯 고유 키 추가 (동적 값 기반 키 대신 고정된 ValueKey 사용) + final GlobalKey _fieldKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.value); + if (kDebugMode) { + print( + '[AutocompleteDropdownField:initState] label=${widget.label}, value=${widget.value}', + ); + } + _focusNode = FocusNode(); + _filteredItems = List.from(widget.items); + _controller.addListener(_onTextChanged); + _focusNode.addListener(_handleFocusChange); + } + + @override + void didUpdateWidget(covariant AutocompleteDropdownField oldWidget) { + super.didUpdateWidget(oldWidget); + // 항상 부모의 value와 내부 컨트롤러를 동기화 (동기화 누락 방지) + _controller.text = widget.value; + if (kDebugMode) { + print( + '[AutocompleteDropdownField:didUpdateWidget] label=${widget.label}, value 동기화: widget.value=${widget.value}, controller.text=${_controller.text}', + ); + } + if (widget.items != oldWidget.items) { + _filteredItems = List.from(widget.items); + } + } + + @override + void dispose() { + _focusNode.dispose(); + _controller.dispose(); + super.dispose(); + } + + void _handleFocusChange() { + setState(() { + // 포커스가 있고 필터링된 아이템이 있을 때만 드롭다운 표시 + _showDropdown = _focusNode.hasFocus && _filteredItems.isNotEmpty; + + // 포커스가 없으면 드롭다운 닫기 + if (!_focusNode.hasFocus) { + _showDropdown = false; + } + }); + + if (kDebugMode) { + print( + '[AutocompleteDropdownField:_handleFocusChange] label=${widget.label}, hasFocus=${_focusNode.hasFocus}, showDropdown=$_showDropdown', + ); + } + } + + void _onTextChanged() { + final text = _controller.text; + if (kDebugMode) { + print( + '[AutocompleteDropdownField:_onTextChanged] label=${widget.label}, text=$text', + ); + } + setState(() { + if (text.isEmpty) { + _filteredItems = List.from(widget.items); + } else { + _filteredItems = + widget.items + .where( + (item) => item.toLowerCase().contains(text.toLowerCase()), + ) + .toList(); + // 일치하는 아이템 정렬 (시작 부분 일치 항목 우선) + _filteredItems.sort((a, b) { + bool aStartsWith = a.toLowerCase().startsWith(text.toLowerCase()); + bool bStartsWith = b.toLowerCase().startsWith(text.toLowerCase()); + if (aStartsWith && !bStartsWith) return -1; + if (!aStartsWith && bStartsWith) return 1; + return a.compareTo(b); + }); + } + // 포커스가 있고 필터링된 아이템이 있을 때만 드롭다운 표시 + _showDropdown = _filteredItems.isNotEmpty && _focusNode.hasFocus; + widget.onChanged(text); + }); + } + + void _handleSelect(String value) { + if (kDebugMode) { + print( + '[AutocompleteDropdownField:_handleSelect] 선택값=$value, 이전 값=${_controller.text}', + ); + } + // 1. 값 전달 (부모 콜백) + widget.onChanged(value); // 입력값 변경 콜백 + widget.onSelected(value); // 선택 콜백 + // 2. 부모 setState 이후, 프레임이 끝난 뒤 드롭다운 닫기 (즉각 반영 보장) + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _controller.text = value; + _controller.selection = TextSelection.collapsed(offset: value.length); + _showDropdown = false; + }); + _focusNode.unfocus(); + }); + if (kDebugMode) { + print( + '[AutocompleteDropdownField:_handleSelect] 업데이트 완료: controller.text=${_controller.text}', + ); + } + } + + void _toggleDropdown() { + setState(() { + _showDropdown = !_showDropdown && _filteredItems.isNotEmpty; + if (_showDropdown) { + _focusNode.requestFocus(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Stack( + key: _fieldKey, // 고정된 키 사용 + children: [ + TextFormField( + controller: _controller, + focusNode: _focusNode, + enabled: widget.enabled, + decoration: InputDecoration( + labelText: widget.label, + hintText: widget.hintText, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_controller.text.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + onPressed: + widget.enabled + ? () { + setState(() { + _controller.clear(); + _filteredItems = List.from(widget.items); + _showDropdown = + _focusNode.hasFocus && + _filteredItems.isNotEmpty; + widget.onSelected(''); + }); + } + : null, + ), + IconButton( + icon: const Icon(Icons.arrow_drop_down), + onPressed: widget.enabled ? _toggleDropdown : null, + ), + ], + ), + ), + validator: (value) { + if (widget.isRequired && (value == null || value.isEmpty)) { + return '${widget.label}을(를) 입력해주세요'; + } + return null; + }, + onSaved: (value) { + widget.onSelected(value ?? ''); + }, + ), + if (_showDropdown) + Positioned( + left: 0, + right: 0, + top: 56, // TextFormField 높이만큼 아래로 + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(4), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: ListView.builder( + shrinkWrap: true, + itemCount: _filteredItems.length, + itemBuilder: (context, index) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (kDebugMode) { + print( + '[AutocompleteDropdownField:GestureDetector:onTap] label=${widget.label}, 선택값=${_filteredItems[index]}', + ); + } + _handleSelect(_filteredItems[index]); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Text(_filteredItems[index]), + ), + ); + }, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/common/widgets/category_autocomplete_field.dart b/lib/screens/common/widgets/category_autocomplete_field.dart new file mode 100644 index 0000000..37ee49e --- /dev/null +++ b/lib/screens/common/widgets/category_autocomplete_field.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import '../custom_widgets.dart'; // AutocompleteDropdown, HighlightText 등 사용 + +// 입력 필드 + 자동완성 드롭다운을 하나로 묶은 공통 위젯 +class CategoryAutocompleteField extends StatefulWidget { + // 입력 필드의 힌트 텍스트 + final String hintText; + // 현재 선택된 값 + final String value; + // 항목 리스트 + final List items; + // 필수 입력 여부 + final bool isRequired; + // 선택 시 콜백 + final void Function(String) onSelect; + // 입력값 변경 시 콜백(옵션) + final void Function(String)? onChanged; + // 비활성화 여부 + final bool enabled; + + const CategoryAutocompleteField({ + Key? key, + required this.hintText, + required this.value, + required this.items, + required this.onSelect, + this.isRequired = false, + this.onChanged, + this.enabled = true, + }) : super(key: key); + + @override + State createState() => + _CategoryAutocompleteFieldState(); +} + +class _CategoryAutocompleteFieldState extends State { + // 텍스트 입력 컨트롤러 + late final TextEditingController _controller; + // 포커스 노드 + final FocusNode _focusNode = FocusNode(); + // 드롭다운 표시 여부 + bool _showDropdown = false; + // 필터링된 항목 리스트 + List _filteredItems = []; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.value); + _filteredItems = List.from(widget.items); + _controller.addListener(_onTextChanged); + _focusNode.addListener(() { + setState(() { + if (_focusNode.hasFocus) { + _showDropdown = _filteredItems.isNotEmpty; + } else { + _showDropdown = false; + } + }); + }); + } + + @override + void didUpdateWidget(covariant CategoryAutocompleteField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.value != _controller.text) { + _controller.text = widget.value; + } + if (widget.items != oldWidget.items) { + _filteredItems = List.from(widget.items); + } + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + // 입력값 변경 시 필터링 + void _onTextChanged() { + final String text = _controller.text; + setState(() { + if (text.isEmpty) { + _filteredItems = List.from(widget.items); + } else { + _filteredItems = + widget.items + .where( + (item) => item.toLowerCase().contains(text.toLowerCase()), + ) + .toList(); + // 시작 부분이 일치하는 항목 우선 정렬 + _filteredItems.sort((a, b) { + bool aStartsWith = a.toLowerCase().startsWith(text.toLowerCase()); + bool bStartsWith = b.toLowerCase().startsWith(text.toLowerCase()); + if (aStartsWith && !bStartsWith) return -1; + if (!aStartsWith && bStartsWith) return 1; + return a.compareTo(b); + }); + } + _showDropdown = _filteredItems.isNotEmpty && _focusNode.hasFocus; + if (widget.onChanged != null) { + widget.onChanged!(text); + } + }); + } + + // 항목 선택 시 처리 + void _handleSelect(String value) { + setState(() { + _controller.text = value; + _showDropdown = false; + }); + widget.onSelect(value); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _controller, + focusNode: _focusNode, + decoration: InputDecoration( + hintText: widget.hintText, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + suffixIcon: + _controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: + widget.enabled + ? () { + setState(() { + _controller.clear(); + _filteredItems = List.from(widget.items); + _showDropdown = _focusNode.hasFocus; + widget.onSelect(''); + }); + } + : null, + ) + : IconButton( + icon: const Icon(Icons.arrow_drop_down), + onPressed: + widget.enabled + ? () { + setState(() { + _showDropdown = !_showDropdown; + }); + } + : null, + ), + ), + enabled: widget.enabled, + validator: (value) { + if (widget.isRequired && (value == null || value.isEmpty)) { + return '${widget.hintText}를 선택해주세요'; + } + return null; + }, + onTap: () { + setState(() { + if (!_showDropdown) { + _showDropdown = true; + } + }); + }, + ), + AutocompleteDropdown( + items: _filteredItems, + inputText: _controller.text, + onSelect: _handleSelect, + showDropdown: _showDropdown, + ), + ], + ); + } +} diff --git a/lib/screens/common/widgets/company_branch_dropdown.dart b/lib/screens/common/widgets/company_branch_dropdown.dart new file mode 100644 index 0000000..ba21266 --- /dev/null +++ b/lib/screens/common/widgets/company_branch_dropdown.dart @@ -0,0 +1,76 @@ +// 회사/지점 드롭다운 공통 위젯 +// 여러 도메인에서 재사용 가능 +import 'package:flutter/material.dart'; +import '../../../models/company_model.dart'; + +class CompanyBranchDropdown extends StatelessWidget { + final List companies; + final int? selectedCompanyId; + final int? selectedBranchId; + final List branches; + final void Function(int? companyId) onCompanyChanged; + final void Function(int? branchId) onBranchChanged; + + const CompanyBranchDropdown({ + super.key, + required this.companies, + required this.selectedCompanyId, + required this.selectedBranchId, + required this.branches, + required this.onCompanyChanged, + required this.onBranchChanged, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 회사 드롭다운 + DropdownButtonFormField( + value: selectedCompanyId, + decoration: const InputDecoration(hintText: '소속 회사를 선택하세요'), + items: + companies + .map( + (company) => DropdownMenuItem( + value: company.id, + child: Text(company.name), + ), + ) + .toList(), + onChanged: onCompanyChanged, + validator: (value) { + if (value == null) { + return '소속 회사를 선택해주세요'; + } + return null; + }, + ), + const SizedBox(height: 12), + // 지점 드롭다운 (지점이 있을 때만) + if (branches.isNotEmpty) + DropdownButtonFormField( + value: selectedBranchId, + decoration: const InputDecoration(hintText: '소속 지점을 선택하세요'), + items: + branches + .map( + (branch) => DropdownMenuItem( + value: branch.id, + child: Text(branch.name), + ), + ) + .toList(), + onChanged: onBranchChanged, + validator: (value) { + if (branches.isNotEmpty && value == null) { + return '소속 지점을 선택해주세요'; + } + return null; + }, + ), + ], + ); + } +} diff --git a/lib/screens/common/widgets/pagination.dart b/lib/screens/common/widgets/pagination.dart new file mode 100644 index 0000000..a5e2e69 --- /dev/null +++ b/lib/screens/common/widgets/pagination.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +/// 페이지네이션 위젯 (<< < 1 2 3 ... 10 > >>) +/// - totalCount: 전체 아이템 수 +/// - currentPage: 현재 페이지 (1부터 시작) +/// - pageSize: 페이지당 아이템 수 +/// - onPageChanged: 페이지 변경 콜백 +class Pagination extends StatelessWidget { + final int totalCount; + final int currentPage; + final int pageSize; + final ValueChanged onPageChanged; + + const Pagination({ + Key? key, + required this.totalCount, + required this.currentPage, + required this.pageSize, + required this.onPageChanged, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + // 전체 페이지 수 계산 + final int totalPages = (totalCount / pageSize).ceil(); + // 페이지네이션 버튼 최대 10개 + final int maxButtons = 10; + // 시작 페이지 계산 + int startPage = ((currentPage - 1) ~/ maxButtons) * maxButtons + 1; + int endPage = (startPage + maxButtons - 1).clamp(1, totalPages); + + List pageButtons = []; + for (int i = startPage; i <= endPage; i++) { + pageButtons.add( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size(36, 36), + backgroundColor: i == currentPage ? Colors.blue : Colors.white, + foregroundColor: i == currentPage ? Colors.white : Colors.black, + padding: EdgeInsets.zero, + ), + onPressed: i == currentPage ? null : () => onPageChanged(i), + child: Text('$i'), + ), + ), + ); + } + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 가장 처음 페이지로 이동 + IconButton( + icon: const Icon(Icons.first_page), + tooltip: '처음', + onPressed: currentPage > 1 ? () => onPageChanged(1) : null, + ), + // 이전 페이지로 이동 + IconButton( + icon: const Icon(Icons.chevron_left), + tooltip: '이전', + onPressed: + currentPage > 1 ? () => onPageChanged(currentPage - 1) : null, + ), + // 페이지 번호 버튼들 + ...pageButtons, + // 다음 페이지로 이동 + IconButton( + icon: const Icon(Icons.chevron_right), + tooltip: '다음', + onPressed: + currentPage < totalPages + ? () => onPageChanged(currentPage + 1) + : null, + ), + // 마지막 페이지로 이동 + IconButton( + icon: const Icon(Icons.last_page), + tooltip: '마지막', + onPressed: + currentPage < totalPages ? () => onPageChanged(totalPages) : null, + ), + ], + ); + } +} diff --git a/lib/screens/common/widgets/remark_input.dart b/lib/screens/common/widgets/remark_input.dart new file mode 100644 index 0000000..713e526 --- /dev/null +++ b/lib/screens/common/widgets/remark_input.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +/// 공통 비고 입력 위젯 +/// 여러 화면에서 재사용할 수 있도록 설계 +class RemarkInput extends StatelessWidget { + final TextEditingController controller; + final String label; + final String hint; + final FormFieldValidator? validator; + final int minLines; + final int? maxLines; + final bool enabled; + + const RemarkInput({ + Key? key, + required this.controller, + this.label = '비고', + this.hint = '비고를 입력하세요', + this.validator, + this.minLines = 4, + this.maxLines, + this.enabled = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + minLines: minLines, + maxLines: maxLines, + enabled: enabled, + validator: validator, + decoration: InputDecoration( + labelText: label, + hintText: hint, + border: const OutlineInputBorder(), + ), + ); + } +} diff --git a/lib/screens/company/company_form.dart b/lib/screens/company/company_form.dart new file mode 100644 index 0000000..ac96a2f --- /dev/null +++ b/lib/screens/company/company_form.dart @@ -0,0 +1,398 @@ +/// 회사 등록 및 수정 화면 +/// +/// SRP(단일 책임 원칙)에 따라 컴포넌트를 분리하여 구현한 리팩토링 버전 +/// - 컨트롤러: CompanyFormController - 비즈니스 로직 담당 +/// - 위젯: +/// - CompanyFormHeader: 회사명 및 주소 입력 +/// - ContactInfoForm: 담당자 정보 입력 +/// - BranchCard: 지점 정보 카드 +/// - CompanyNameAutocomplete: 회사명 자동완성 +/// - MapDialog: 지도 다이얼로그 +/// - DuplicateCompanyDialog: 중복 회사 확인 다이얼로그 +/// - CompanyTypeSelector: 회사 유형 선택 라디오 버튼 +/// - 유틸리티: +/// - PhoneUtils: 전화번호 관련 유틸리티 +import 'package:flutter/material.dart'; +import 'package:superport/models/address_model.dart'; +import 'package:superport/models/company_model.dart'; +import 'package:superport/screens/common/custom_widgets.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/screens/company/controllers/company_form_controller.dart'; +import 'package:superport/screens/company/widgets/branch_card.dart'; +import 'package:superport/screens/company/widgets/company_form_header.dart'; +import 'package:superport/screens/company/widgets/contact_info_form.dart'; +import 'package:superport/screens/company/widgets/duplicate_company_dialog.dart'; +import 'package:superport/screens/company/widgets/map_dialog.dart'; +import 'package:superport/screens/company/widgets/branch_form_widget.dart'; +import 'package:superport/services/mock_data_service.dart'; +import 'dart:async'; +import 'dart:math' as math; +import 'package:superport/screens/company/controllers/branch_form_controller.dart'; + +/// 회사 유형 선택 위젯 (체크박스) +class CompanyTypeSelector extends StatelessWidget { + final List selectedTypes; + final Function(CompanyType, bool) onTypeChanged; + + const CompanyTypeSelector({ + Key? key, + required this.selectedTypes, + required this.onTypeChanged, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('회사 유형', style: AppThemeTailwind.formLabelStyle), + const SizedBox(height: 8), + Row( + children: [ + // 고객사 체크박스 + Checkbox( + value: selectedTypes.contains(CompanyType.customer), + onChanged: (checked) { + onTypeChanged(CompanyType.customer, checked ?? false); + }, + ), + const Text('고객사'), + const SizedBox(width: 24), + // 파트너사 체크박스 + Checkbox( + value: selectedTypes.contains(CompanyType.partner), + onChanged: (checked) { + onTypeChanged(CompanyType.partner, checked ?? false); + }, + ), + const Text('파트너사'), + ], + ), + ], + ), + ); + } +} + +class CompanyFormScreen extends StatefulWidget { + final Map? args; + const CompanyFormScreen({Key? key, this.args}) : super(key: key); + + @override + _CompanyFormScreenState createState() => _CompanyFormScreenState(); +} + +class _CompanyFormScreenState extends State { + late CompanyFormController _controller; + bool isBranch = false; + String? mainCompanyName; + int? companyId; + int? branchId; + + @override + void initState() { + super.initState(); + // controller는 didChangeDependencies에서 초기화 + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final args = widget.args; + if (args != null) { + isBranch = args['isBranch'] ?? false; + mainCompanyName = args['mainCompanyName']; + companyId = args['companyId']; + branchId = args['branchId']; + } + _controller = CompanyFormController( + dataService: MockDataService(), + companyId: companyId, + ); + // 지점 수정 모드일 때 branchId로 branch 정보 세팅 + if (isBranch && branchId != null) { + final company = MockDataService().getCompanyById(companyId!); + // 디버그: 진입 시 companyId, branchId, company, branches 정보 출력 + print('[DEBUG] 지점 수정 진입: companyId=$companyId, branchId=$branchId'); + if (company != null && company.branches != null) { + print( + '[DEBUG] 불러온 company.name=${company.name}, branches=${company.branches!.map((b) => 'id:${b.id}, name:${b.name}, remark:${b.remark}').toList()}', + ); + final branch = company.branches!.firstWhere( + (b) => b.id == branchId, + orElse: () => company.branches!.first, + ); + print( + '[DEBUG] 선택된 branch: id=${branch.id}, name=${branch.name}, remark=${branch.remark}', + ); + // 폼 컨트롤러의 각 필드에 branch 정보 세팅 + _controller.nameController.text = branch.name; + _controller.companyAddress = branch.address; + _controller.contactNameController.text = branch.contactName ?? ''; + _controller.contactPositionController.text = + branch.contactPosition ?? ''; + _controller.selectedPhonePrefix = extractPhonePrefix( + branch.contactPhone ?? '', + _controller.phonePrefixes, + ); + _controller + .contactPhoneController + .text = extractPhoneNumberWithoutPrefix( + branch.contactPhone ?? '', + _controller.phonePrefixes, + ); + _controller.contactEmailController.text = branch.contactEmail ?? ''; + // 지점 단일 입력만 허용 (branchControllers 초기화) + _controller.branchControllers.clear(); + _controller.branchControllers.add( + BranchFormController( + branch: branch, + positions: _controller.positions, + phonePrefixes: _controller.phonePrefixes, + ), + ); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + // 지점 추가 후 스크롤 처리 (branchControllers 기반) + void _scrollToAddedBranchCard() { + if (_controller.branchControllers.isEmpty || + !_controller.scrollController.hasClients) { + return; + } + // 추가 버튼 위치까지 스크롤 - 지점 추가 버튼이 있는 위치를 계산하여 그 위치로 스크롤 + final double additionalOffset = 80.0; + final maxPos = _controller.scrollController.position.maxScrollExtent; + final currentPos = _controller.scrollController.position.pixels; + final targetPos = math.min(currentPos + additionalOffset, maxPos - 20.0); + _controller.scrollController.animateTo( + targetPos, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutQuad, + ); + } + + // 지점 추가 + void _addBranch() { + setState(() { + _controller.addBranch(); + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + Future.delayed(const Duration(milliseconds: 100), () { + _scrollToAddedBranchCard(); + Future.delayed(const Duration(milliseconds: 300), () { + // 마지막 지점의 포커스 노드로 포커스 이동 + if (_controller.branchControllers.isNotEmpty) { + _controller.branchControllers.last.focusNode.requestFocus(); + } + }); + }); + }); + } + + // 회사 저장 + void _saveCompany() { + final duplicateCompany = _controller.checkDuplicateCompany(); + if (duplicateCompany != null) { + DuplicateCompanyDialog.show(context, duplicateCompany); + return; + } + if (_controller.saveCompany()) { + Navigator.pop(context, true); + } + } + + @override + Widget build(BuildContext context) { + final isEditMode = companyId != null; + final String title = + isBranch + ? '${mainCompanyName ?? ''} 지점 정보 수정' + : (isEditMode ? '회사 정보 수정' : '회사 등록'); + final String nameLabel = isBranch ? '지점명' : '회사명'; + final String nameHint = isBranch ? '지점명을 입력하세요' : '회사명을 입력하세요'; + + // 지점 수정 모드일 때는 BranchFormWidget만 단독 노출 + if (isBranch && branchId != null) { + return Scaffold( + appBar: AppBar(title: Text(title)), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _controller.formKey, + child: BranchFormWidget( + controller: _controller.branchControllers[0], + index: 0, + onRemove: null, + onAddressChanged: (address) { + setState(() { + _controller.updateBranchAddress(0, address); + }); + }, + ), + ), + ), + ); + } + // ... 기존 본사/신규 등록 모드 렌더링 + return GestureDetector( + onTap: () { + setState(() { + if (_controller.showCompanyNameDropdown) { + _controller.showCompanyNameDropdown = false; + } + }); + FocusScope.of(context).unfocus(); + }, + child: Scaffold( + appBar: AppBar(title: Text(title)), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _controller.formKey, + child: SingleChildScrollView( + controller: _controller.scrollController, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 회사 유형 선택 (체크박스) + CompanyTypeSelector( + selectedTypes: _controller.selectedCompanyTypes, + onTypeChanged: (type, checked) { + setState(() { + _controller.toggleCompanyType(type, checked); + }); + }, + ), + // 회사 기본 정보 헤더 (회사명/지점명 + 주소) + CompanyFormHeader( + nameController: _controller.nameController, + nameFocusNode: _controller.nameFocusNode, + companyNames: _controller.companyNames, + filteredCompanyNames: _controller.filteredCompanyNames, + showCompanyNameDropdown: + _controller.showCompanyNameDropdown, + onCompanyNameSelected: (name) { + setState(() { + _controller.selectCompanyName(name); + }); + }, + onShowMapPressed: () { + final fullAddress = _controller.companyAddress.toString(); + MapDialog.show(context, fullAddress); + }, + onNameSaved: (value) {}, + onAddressChanged: (address) { + setState(() { + _controller.updateCompanyAddress(address); + }); + }, + initialAddress: _controller.companyAddress, + nameLabel: nameLabel, + nameHint: nameHint, + remarkController: _controller.remarkController, + ), + // 담당자 정보 + ContactInfoForm( + contactNameController: _controller.contactNameController, + contactPositionController: + _controller.contactPositionController, + contactPhoneController: _controller.contactPhoneController, + contactEmailController: _controller.contactEmailController, + positions: _controller.positions, + selectedPhonePrefix: _controller.selectedPhonePrefix, + phonePrefixes: _controller.phonePrefixes, + onPhonePrefixChanged: (value) { + setState(() { + _controller.selectedPhonePrefix = value; + }); + }, + onNameSaved: (value) {}, + onPositionSaved: (value) {}, + onPhoneSaved: (value) {}, + onEmailSaved: (value) {}, + ), + // 지점 정보(하단) 및 +지점추가 버튼은 본사/신규 등록일 때만 노출 + if (!(isBranch && branchId != null)) ...[ + if (_controller.branchControllers.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 16.0, bottom: 8.0), + child: Text( + '지점 정보', + style: AppThemeTailwind.subheadingStyle, + ), + ), + if (_controller.branchControllers.isNotEmpty) + for ( + int i = 0; + i < _controller.branchControllers.length; + i++ + ) + BranchFormWidget( + controller: _controller.branchControllers[i], + index: i, + onRemove: () { + setState(() { + _controller.removeBranch(i); + }); + }, + onAddressChanged: (address) { + setState(() { + _controller.updateBranchAddress(i, address); + }); + }, + ), + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: ElevatedButton.icon( + onPressed: _addBranch, + icon: const Icon(Icons.add), + label: const Text('지점 추가'), + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + // 저장 버튼 + Padding( + padding: const EdgeInsets.only(top: 24.0, bottom: 16.0), + child: ElevatedButton( + onPressed: _saveCompany, + style: ElevatedButton.styleFrom( + backgroundColor: AppThemeTailwind.primary, + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + isEditMode ? '수정 완료' : '등록 완료', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/company/company_list.dart b/lib/screens/company/company_list.dart new file mode 100644 index 0000000..f9027a1 --- /dev/null +++ b/lib/screens/company/company_list.dart @@ -0,0 +1,501 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/company_model.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/screens/common/main_layout.dart'; +import 'package:superport/screens/common/custom_widgets.dart'; +import 'package:superport/services/mock_data_service.dart'; +import 'package:superport/utils/constants.dart'; +import 'package:superport/screens/common/widgets/pagination.dart'; +import 'package:superport/screens/company/widgets/company_branch_dialog.dart'; + +class CompanyListScreen extends StatefulWidget { + const CompanyListScreen({super.key}); + + @override + State createState() => _CompanyListScreenState(); +} + +class _CompanyListScreenState extends State { + final MockDataService _dataService = MockDataService(); + List _companies = []; + // 페이지네이션 상태 추가 + int _currentPage = 1; // 현재 페이지 (1부터 시작) + final int _pageSize = 10; // 페이지당 개수 + + @override + void initState() { + super.initState(); + _loadData(); + } + + void _loadData() { + setState(() { + _companies = _dataService.getAllCompanies(); + // 데이터가 변경되면 첫 페이지로 이동 + _currentPage = 1; + }); + } + + void _navigateToAddScreen() async { + final result = await Navigator.pushNamed(context, '/company/add'); + if (result == true) { + _loadData(); + } + } + + void _navigateToEditScreen(int id) async { + final result = await Navigator.pushNamed( + context, + '/company/edit', + arguments: id, + ); + if (result == true) { + _loadData(); + } + } + + void _deleteCompany(int id) { + showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('삭제 확인'), + content: const Text('이 회사 정보를 삭제하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('취소'), + ), + TextButton( + onPressed: () { + _dataService.deleteCompany(id); + Navigator.pop(context); + _loadData(); + }, + child: const Text('삭제'), + ), + ], + ), + ); + } + + // 회사 유형에 따라 칩 위젯 생성 (복수) + Widget _buildCompanyTypeChips(List types) { + return Row( + children: + types.map((type) { + final Color textColor = + type == CompanyType.customer + ? Colors.blue.shade800 + : Colors.green.shade800; + final String label = companyTypeToString(type); + return Container( + margin: const EdgeInsets.only(right: 4), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: textColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + label, + style: TextStyle( + color: textColor, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ); + }).toList(), + ); + } + + // 본사/지점 구분 표시 위젯 + Widget _buildCompanyTypeLabel(bool isBranch, {String? mainCompanyName}) { + if (isBranch) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.account_tree, size: 16, color: Colors.blue.shade600), + const SizedBox(width: 4), + const Text('지점'), + ], + ); + } else { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.business, size: 16, color: Colors.grey.shade700), + const SizedBox(width: 4), + const Text('본사'), + ], + ); + } + } + + // 회사 이름 표시 위젯 (지점인 경우 "본사명 > 지점명" 형식) + Widget _buildCompanyNameText( + Company company, + bool isBranch, { + String? mainCompanyName, + }) { + if (isBranch && mainCompanyName != null) { + return Text.rich( + TextSpan( + children: [ + TextSpan( + text: isBranch ? '▶ ' : '', + style: TextStyle(color: Colors.grey.shade600, fontSize: 14), + ), + TextSpan( + text: isBranch ? '$mainCompanyName > ' : '', + style: TextStyle( + color: Colors.grey.shade700, + fontWeight: FontWeight.normal, + ), + ), + TextSpan( + text: company.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ); + } else { + return Text( + company.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ); + } + } + + // 지점(본사+지점)만 보여주는 팝업 오픈 함수 + void _showBranchDialog(Company mainCompany) { + showDialog( + context: context, + builder: (context) => CompanyBranchDialog(mainCompany: mainCompany), + ); + } + + @override + Widget build(BuildContext context) { + // 대시보드 폭에 맞게 조정 + final screenWidth = MediaQuery.of(context).size.width; + final maxContentWidth = screenWidth > 1200 ? 1200.0 : screenWidth - 32; + + // 본사와 지점 구분하기 위한 데이터 준비 + final List> displayCompanies = []; + for (final company in _companies) { + displayCompanies.add({ + 'company': company, + 'isBranch': false, + 'mainCompanyName': null, + }); + if (company.branches != null) { + for (final branch in company.branches!) { + displayCompanies.add({ + 'branch': branch, // 지점 객체 자체 저장 + 'companyId': company.id, // 본사 id 저장 + 'isBranch': true, + 'mainCompanyName': company.name, + }); + } + } + } + + // 페이지네이션 데이터 슬라이싱 + final int totalCount = displayCompanies.length; + final int startIndex = (_currentPage - 1) * _pageSize; + final int endIndex = + (startIndex + _pageSize) > totalCount + ? totalCount + : (startIndex + _pageSize); + final List> pagedCompanies = displayCompanies.sublist( + startIndex, + endIndex, + ); + + return MainLayout( + title: '회사 관리', + currentRoute: Routes.company, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadData, + color: Colors.grey, + ), + ], + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PageTitle( + title: '회사 목록', + width: maxContentWidth - 32, + rightWidget: ElevatedButton.icon( + onPressed: _navigateToAddScreen, + icon: const Icon(Icons.add), + label: const Text('추가'), + style: AppThemeTailwind.primaryButtonStyle, + ), + ), + Expanded( + child: DataTableCard( + width: maxContentWidth - 32, + child: + pagedCompanies.isEmpty + ? const Center(child: Text('등록된 회사 정보가 없습니다.')) + : SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Container( + width: maxContentWidth - 32, + constraints: BoxConstraints( + minWidth: maxContentWidth - 64, + ), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: DataTable( + columns: const [ + DataColumn(label: Text('번호')), + DataColumn(label: Text('구분')), + DataColumn(label: Text('회사명')), + DataColumn(label: Text('유형')), + DataColumn(label: Text('주소')), + DataColumn(label: Text('지점 수 (본사만 표시)')), + DataColumn(label: Text('관리')), + ], + rows: + pagedCompanies.asMap().entries.map((entry) { + final index = entry.key; + final data = entry.value; + final bool isBranch = + data['isBranch'] as bool; + final String? mainCompanyName = + data['mainCompanyName'] as String?; + + if (isBranch) { + final Branch branch = + data['branch'] as Branch; + final int companyId = + data['companyId'] as int; + return DataRow( + cells: [ + DataCell( + Text('${startIndex + index + 1}'), + ), + DataCell( + _buildCompanyTypeLabel( + true, + mainCompanyName: + mainCompanyName, + ), + ), + DataCell( + _buildCompanyNameText( + Company( + id: branch.id, + name: branch.name, + address: branch.address, + contactName: + branch.contactName, + contactPosition: + branch.contactPosition, + contactPhone: + branch.contactPhone, + contactEmail: + branch.contactEmail, + companyTypes: [], + remark: branch.remark, + ), + true, + mainCompanyName: + mainCompanyName, + ), + ), + DataCell( + _buildCompanyTypeChips([]), + ), + DataCell( + Text(branch.address.toString()), + ), + DataCell(const Text('')), + DataCell( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon( + Icons.edit, + color: + AppThemeTailwind + .primary, + ), + onPressed: () { + Navigator.pushNamed( + context, + '/company/edit', + arguments: { + 'companyId': + companyId, + 'isBranch': true, + 'mainCompanyName': + mainCompanyName, + 'branchId': branch.id, + }, + ).then((result) { + if (result == true) + _loadData(); + }); + }, + ), + IconButton( + icon: const Icon( + Icons.delete, + color: + AppThemeTailwind + .danger, + ), + onPressed: () { + // 지점 삭제 로직 필요시 구현 + }, + ), + ], + ), + ), + ], + ); + } else { + final Company company = + data['company'] as Company; + return DataRow( + cells: [ + DataCell( + Text('${startIndex + index + 1}'), + ), + DataCell( + _buildCompanyTypeLabel(false), + ), + DataCell( + _buildCompanyNameText( + company, + false, + ), + ), + DataCell( + _buildCompanyTypeChips( + company.companyTypes, + ), + ), + DataCell( + Text(company.address.toString()), + ), + DataCell( + GestureDetector( + onTap: () { + if ((company + .branches + ?.isNotEmpty ?? + false)) { + _showBranchDialog(company); + } + }, + child: MouseRegion( + cursor: + SystemMouseCursors.click, + child: Text( + '${(company.branches?.length ?? 0)}', + style: TextStyle( + color: + (company + .branches + ?.isNotEmpty ?? + false) + ? Colors.blue + : Colors.black, + decoration: + (company + .branches + ?.isNotEmpty ?? + false) + ? TextDecoration + .underline + : TextDecoration + .none, + ), + ), + ), + ), + ), + DataCell( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon( + Icons.edit, + color: + AppThemeTailwind + .primary, + ), + onPressed: () { + Navigator.pushNamed( + context, + '/company/edit', + arguments: { + 'companyId': + company.id, + 'isBranch': false, + }, + ).then((result) { + if (result == true) + _loadData(); + }); + }, + ), + IconButton( + icon: const Icon( + Icons.delete, + color: + AppThemeTailwind + .danger, + ), + onPressed: () { + _deleteCompany( + company.id!, + ); + }, + ), + ], + ), + ), + ], + ); + } + }).toList(), + ), + ), + ), + ), + ), + ), + // 페이지네이션 위젯 추가 + if (totalCount > _pageSize) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Pagination( + totalCount: totalCount, + currentPage: _currentPage, + pageSize: _pageSize, + onPageChanged: (page) { + setState(() { + _currentPage = page; + }); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/company/controllers/branch_form_controller.dart b/lib/screens/company/controllers/branch_form_controller.dart new file mode 100644 index 0000000..a331ba2 --- /dev/null +++ b/lib/screens/company/controllers/branch_form_controller.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/address_model.dart'; +import 'package:superport/models/company_model.dart'; +import 'package:superport/utils/phone_utils.dart'; + +/// 지점(Branch) 폼 컨트롤러 +/// +/// 각 지점의 상태, 컨트롤러, 포커스, 드롭다운, 전화번호 등 관리를 담당 +class BranchFormController { + // 지점 데이터 + Branch branch; + + // 입력 컨트롤러 + final TextEditingController nameController; + final TextEditingController contactNameController; + final TextEditingController contactPositionController; + final TextEditingController contactPhoneController; + final TextEditingController contactEmailController; + final TextEditingController remarkController; + + // 포커스 노드 + final FocusNode focusNode; + // 카드 키(위젯 식별용) + final GlobalKey cardKey; + // 직책 드롭다운 상태 + final ValueNotifier positionDropdownNotifier; + // 전화번호 접두사 + String selectedPhonePrefix; + + // 직책 목록(공통 상수로 관리 권장) + final List positions; + // 전화번호 접두사 목록(공통 상수로 관리 권장) + final List phonePrefixes; + + BranchFormController({ + required this.branch, + required this.positions, + required this.phonePrefixes, + }) : nameController = TextEditingController(text: branch.name), + contactNameController = TextEditingController( + text: branch.contactName ?? '', + ), + contactPositionController = TextEditingController( + text: branch.contactPosition ?? '', + ), + contactPhoneController = TextEditingController( + text: PhoneUtils.extractPhoneNumberWithoutPrefix( + branch.contactPhone ?? '', + phonePrefixes, + ), + ), + contactEmailController = TextEditingController( + text: branch.contactEmail ?? '', + ), + remarkController = TextEditingController(text: branch.remark ?? ''), + focusNode = FocusNode(), + cardKey = GlobalKey(), + positionDropdownNotifier = ValueNotifier(false), + selectedPhonePrefix = PhoneUtils.extractPhonePrefix( + branch.contactPhone ?? '', + phonePrefixes, + ); + + /// 주소 업데이트 + void updateAddress(Address address) { + branch = branch.copyWith(address: address); + } + + /// 필드별 값 업데이트 + void updateField(String fieldName, String value) { + switch (fieldName) { + case 'name': + branch = branch.copyWith(name: value); + break; + case 'contactName': + branch = branch.copyWith(contactName: value); + break; + case 'contactPosition': + branch = branch.copyWith(contactPosition: value); + break; + case 'contactPhone': + branch = branch.copyWith( + contactPhone: PhoneUtils.getFullPhoneNumber( + selectedPhonePrefix, + value, + ), + ); + break; + case 'contactEmail': + branch = branch.copyWith(contactEmail: value); + break; + case 'remark': + branch = branch.copyWith(remark: value); + break; + } + } + + /// 전화번호 접두사 변경 + void updatePhonePrefix(String prefix) { + selectedPhonePrefix = prefix; + branch = branch.copyWith( + contactPhone: PhoneUtils.getFullPhoneNumber( + prefix, + contactPhoneController.text, + ), + ); + } + + /// 리소스 해제 + void dispose() { + nameController.dispose(); + contactNameController.dispose(); + contactPositionController.dispose(); + contactPhoneController.dispose(); + contactEmailController.dispose(); + remarkController.dispose(); + focusNode.dispose(); + positionDropdownNotifier.dispose(); + // cardKey는 위젯에서 자동 관리 + } +} diff --git a/lib/screens/company/controllers/company_form_controller.dart b/lib/screens/company/controllers/company_form_controller.dart new file mode 100644 index 0000000..b11861c Binary files /dev/null and b/lib/screens/company/controllers/company_form_controller.dart differ diff --git a/lib/screens/company/widgets/branch_card.dart b/lib/screens/company/widgets/branch_card.dart new file mode 100644 index 0000000..b75cd01 --- /dev/null +++ b/lib/screens/company/widgets/branch_card.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:superport/models/address_model.dart'; +import 'package:superport/models/company_model.dart'; +import 'package:superport/screens/common/custom_widgets.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/screens/common/widgets/address_input.dart'; +import 'package:superport/screens/company/widgets/contact_info_widget.dart'; +import 'package:superport/utils/validators.dart'; +import 'package:superport/utils/phone_utils.dart'; + +class BranchCard extends StatefulWidget { + final GlobalKey cardKey; + final int index; + final Branch branch; + final TextEditingController nameController; + final TextEditingController contactNameController; + final TextEditingController contactPositionController; + final TextEditingController contactPhoneController; + final TextEditingController contactEmailController; + final FocusNode focusNode; + final List positions; + final List phonePrefixes; + final String selectedPhonePrefix; + final ValueChanged onNameChanged; + final ValueChanged
onAddressChanged; + final ValueChanged onContactNameChanged; + final ValueChanged onContactPositionChanged; + final ValueChanged onContactPhoneChanged; + final ValueChanged onContactEmailChanged; + final ValueChanged onPhonePrefixChanged; + final VoidCallback onDelete; + + const BranchCard({ + Key? key, + required this.cardKey, + required this.index, + required this.branch, + required this.nameController, + required this.contactNameController, + required this.contactPositionController, + required this.contactPhoneController, + required this.contactEmailController, + required this.focusNode, + required this.positions, + required this.phonePrefixes, + required this.selectedPhonePrefix, + required this.onNameChanged, + required this.onAddressChanged, + required this.onContactNameChanged, + required this.onContactPositionChanged, + required this.onContactPhoneChanged, + required this.onContactEmailChanged, + required this.onPhonePrefixChanged, + required this.onDelete, + }) : super(key: key); + + @override + _BranchCardState createState() => _BranchCardState(); +} + +class _BranchCardState extends State { + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + // 화면의 빈 공간 터치 시 포커스 해제 + FocusScope.of(context).unfocus(); + }, + child: Card( + key: widget.cardKey, + margin: const EdgeInsets.only(bottom: 16.0), + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '지점 #${widget.index + 1}', + style: AppThemeTailwind.subheadingStyle, + ), + IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: widget.onDelete, + ), + ], + ), + const SizedBox(height: 8), + FormFieldWrapper( + label: '지점명', + isRequired: true, + child: TextFormField( + controller: widget.nameController, + focusNode: widget.focusNode, + decoration: const InputDecoration(hintText: '지점명을 입력하세요'), + onChanged: widget.onNameChanged, + validator: FormValidator.required('지점명은 필수입니다'), + ), + ), + AddressInput( + initialZipCode: widget.branch.address.zipCode, + initialRegion: widget.branch.address.region, + initialDetailAddress: widget.branch.address.detailAddress, + onAddressChanged: (zipCode, region, detailAddress) { + final address = Address( + zipCode: zipCode, + region: region, + detailAddress: detailAddress, + ); + widget.onAddressChanged(address); + }, + ), + + // 담당자 정보 - ContactInfoWidget 사용 + ContactInfoWidget( + title: '담당자 정보', + contactNameController: widget.contactNameController, + contactPositionController: widget.contactPositionController, + contactPhoneController: widget.contactPhoneController, + contactEmailController: widget.contactEmailController, + positions: widget.positions, + selectedPhonePrefix: widget.selectedPhonePrefix, + phonePrefixes: widget.phonePrefixes, + onPhonePrefixChanged: widget.onPhonePrefixChanged, + onContactNameChanged: widget.onContactNameChanged, + onContactPositionChanged: widget.onContactPositionChanged, + onContactPhoneChanged: widget.onContactPhoneChanged, + onContactEmailChanged: widget.onContactEmailChanged, + compactMode: false, // compactMode를 false로 변경하여 한 줄로 표시 + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/company/widgets/branch_form_widget.dart b/lib/screens/company/widgets/branch_form_widget.dart new file mode 100644 index 0000000..ec1e593 --- /dev/null +++ b/lib/screens/company/widgets/branch_form_widget.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import '../controllers/branch_form_controller.dart'; +import 'package:superport/models/address_model.dart'; +import 'package:superport/screens/company/widgets/contact_info_form.dart'; +import 'package:superport/screens/common/widgets/address_input.dart'; +import 'package:superport/screens/common/widgets/remark_input.dart'; + +/// 지점 입력 폼 위젯 +/// +/// BranchFormController를 받아서 입력 필드, 드롭다운, 포커스, 전화번호 등 UI/상태를 관리한다. +class BranchFormWidget extends StatelessWidget { + final BranchFormController controller; + final int index; + final void Function()? onRemove; + final void Function(Address)? onAddressChanged; + + const BranchFormWidget({ + Key? key, + required this.controller, + required this.index, + this.onRemove, + this.onAddressChanged, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Card( + key: controller.cardKey, + margin: const EdgeInsets.symmetric(vertical: 8), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: TextFormField( + controller: controller.nameController, + focusNode: controller.focusNode, + decoration: const InputDecoration(labelText: '지점명'), + onChanged: (value) => controller.updateField('name', value), + ), + ), + if (onRemove != null) + IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: onRemove, + ), + ], + ), + const SizedBox(height: 8), + // 주소 입력: 회사와 동일한 AddressInput 위젯 사용 + AddressInput( + initialZipCode: controller.branch.address.zipCode, + initialRegion: controller.branch.address.region, + initialDetailAddress: controller.branch.address.detailAddress, + isRequired: false, + onAddressChanged: (zipCode, region, detailAddress) { + controller.updateAddress( + Address( + zipCode: zipCode, + region: region, + detailAddress: detailAddress, + ), + ); + if (onAddressChanged != null) { + onAddressChanged!( + Address( + zipCode: zipCode, + region: region, + detailAddress: detailAddress, + ), + ); + } + }, + ), + const SizedBox(height: 8), + // 담당자 정보 입력: ContactInfoForm 위젯으로 대체 (회사 담당자와 동일 UI) + ContactInfoForm( + contactNameController: controller.contactNameController, + contactPositionController: controller.contactPositionController, + contactPhoneController: controller.contactPhoneController, + contactEmailController: controller.contactEmailController, + positions: controller.positions, + selectedPhonePrefix: controller.selectedPhonePrefix, + phonePrefixes: controller.phonePrefixes, + onPhonePrefixChanged: (value) { + controller.updatePhonePrefix(value); + }, + onNameSaved: (value) { + controller.updateField('contactName', value ?? ''); + }, + onPositionSaved: (value) { + controller.updateField('contactPosition', value ?? ''); + }, + onPhoneSaved: (value) { + controller.updateField('contactPhone', value ?? ''); + }, + onEmailSaved: (value) { + controller.updateField('contactEmail', value ?? ''); + }, + ), + const SizedBox(height: 8), + // 비고 입력란 + RemarkInput(controller: controller.remarkController), + ], + ), + ), + ); + } +} diff --git a/lib/screens/company/widgets/company_branch_dialog.dart b/lib/screens/company/widgets/company_branch_dialog.dart new file mode 100644 index 0000000..08c7af3 --- /dev/null +++ b/lib/screens/company/widgets/company_branch_dialog.dart @@ -0,0 +1,374 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/company_model.dart'; +import 'package:superport/screens/company/widgets/company_info_card.dart'; +import 'package:pdf/widgets.dart' as pw; // PDF 생성용 +import 'package:printing/printing.dart'; // PDF 프린트/미리보기용 +import 'dart:typed_data'; // Uint8List +import 'package:pdf/pdf.dart'; // PdfColors, PageFormat 등 전체 임포트 +import 'package:superport/screens/common/custom_widgets.dart'; // DataTableCard 사용을 위한 import +import 'package:flutter/services.dart'; // rootBundle 사용을 위한 import + +/// 본사와 지점 리스트를 보여주는 다이얼로그 위젯 +class CompanyBranchDialog extends StatelessWidget { + final Company mainCompany; + + const CompanyBranchDialog({super.key, required this.mainCompany}); + + // 본사+지점 정보를 PDF로 생성하는 함수 + Future _buildPdf(final pw.Document pdf) async { + // 한글 폰트 로드 (lib/assets/fonts/NotoSansKR-VariableFont_wght.ttf) + final fontData = await rootBundle.load( + 'lib/assets/fonts/NotoSansKR-VariableFont_wght.ttf', + ); + final ttf = pw.Font.ttf(fontData); + final List branchList = mainCompany.branches ?? []; + pdf.addPage( + pw.Page( + build: (pw.Context context) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + '본사 및 지점 목록', + style: pw.TextStyle( + font: ttf, // 한글 폰트 적용 + fontSize: 20, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.SizedBox(height: 16), + pw.Table( + border: pw.TableBorder.all(color: PdfColors.grey800), + defaultVerticalAlignment: pw.TableCellVerticalAlignment.middle, + children: [ + pw.TableRow( + decoration: pw.BoxDecoration(color: PdfColors.grey300), + children: [ + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text('구분', style: pw.TextStyle(font: ttf)), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text('이름', style: pw.TextStyle(font: ttf)), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text('우편번호', style: pw.TextStyle(font: ttf)), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text('담당자', style: pw.TextStyle(font: ttf)), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text('직책', style: pw.TextStyle(font: ttf)), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text('전화번호', style: pw.TextStyle(font: ttf)), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text('이메일', style: pw.TextStyle(font: ttf)), + ), + ], + ), + // 본사 + pw.TableRow( + children: [ + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text('본사', style: pw.TextStyle(font: ttf)), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + mainCompany.name, + style: pw.TextStyle(font: ttf), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + mainCompany.address.zipCode, + style: pw.TextStyle(font: ttf), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + mainCompany.contactName ?? '', + style: pw.TextStyle(font: ttf), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + mainCompany.contactPosition ?? '', + style: pw.TextStyle(font: ttf), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + mainCompany.contactPhone ?? '', + style: pw.TextStyle(font: ttf), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + mainCompany.contactEmail ?? '', + style: pw.TextStyle(font: ttf), + ), + ), + ], + ), + // 지점 + ...branchList.map( + (branch) => pw.TableRow( + children: [ + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text('지점', style: pw.TextStyle(font: ttf)), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + branch.name, + style: pw.TextStyle(font: ttf), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + branch.address.zipCode, + style: pw.TextStyle(font: ttf), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + branch.contactName ?? '', + style: pw.TextStyle(font: ttf), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + branch.contactPosition ?? '', + style: pw.TextStyle(font: ttf), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + branch.contactPhone ?? '', + style: pw.TextStyle(font: ttf), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + branch.contactEmail ?? '', + style: pw.TextStyle(font: ttf), + ), + ), + ], + ), + ), + ], + ), + ], + ); + }, + ), + ); + return pdf.save(); + } + + // 프린트 버튼 클릭 시 PDF 미리보기 및 인쇄 + void _printPopupData() async { + final pdf = pw.Document(); + await Printing.layoutPdf( + onLayout: (format) async { + return _buildPdf(pdf); + }, + ); + } + + @override + Widget build(BuildContext context) { + final List branchList = mainCompany.branches ?? []; + // 본사와 지점 정보를 한 리스트로 합침 + final List> displayList = [ + { + 'type': '본사', + 'name': mainCompany.name, + 'companyTypes': mainCompany.companyTypes, + 'address': mainCompany.address, + 'contactName': mainCompany.contactName, + 'contactPosition': mainCompany.contactPosition, + 'contactPhone': mainCompany.contactPhone, + 'contactEmail': mainCompany.contactEmail, + }, + ...branchList.map( + (branch) => { + 'type': '지점', + 'name': branch.name, + 'companyTypes': mainCompany.companyTypes, + 'address': branch.address, + 'contactName': branch.contactName, + 'contactPosition': branch.contactPosition, + 'contactPhone': branch.contactPhone, + 'contactEmail': branch.contactEmail, + }, + ), + ]; + final double maxDialogHeight = MediaQuery.of(context).size.height * 0.7; + final double maxDialogWidth = MediaQuery.of(context).size.width * 0.8; + return Dialog( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: maxDialogHeight, + maxWidth: maxDialogWidth, + ), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '본사 및 지점 목록', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + Row( + children: [ + IconButton( + icon: const Icon(Icons.print), + tooltip: '프린트', + onPressed: _printPopupData, + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: DataTableCard( + width: maxDialogWidth - 48, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Container( + width: maxDialogWidth - 48, + constraints: BoxConstraints(minWidth: 900), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: DataTable( + columns: const [ + DataColumn(label: Text('번호')), + DataColumn(label: Text('구분')), + DataColumn(label: Text('회사명')), + DataColumn(label: Text('유형')), + DataColumn(label: Text('주소')), + DataColumn(label: Text('담당자')), + DataColumn(label: Text('직책')), + DataColumn(label: Text('전화번호')), + DataColumn(label: Text('이메일')), + ], + rows: + displayList.asMap().entries.map((entry) { + final int index = entry.key; + final data = entry.value; + return DataRow( + cells: [ + DataCell(Text('${index + 1}')), + DataCell(Text(data['type'])), + DataCell(Text(data['name'])), + DataCell( + Row( + children: + (data['companyTypes'] + as List) + .map( + (type) => Container( + margin: + const EdgeInsets.only( + right: 4, + ), + padding: + const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: + type == + CompanyType + .customer + ? Colors + .blue + .shade50 + : Colors + .green + .shade50, + borderRadius: + BorderRadius.circular( + 8, + ), + ), + child: Text( + companyTypeToString(type), + style: TextStyle( + color: + type == + CompanyType + .customer + ? Colors + .blue + .shade800 + : Colors + .green + .shade800, + fontWeight: + FontWeight.bold, + fontSize: 14, + ), + ), + ), + ) + .toList(), + ), + ), + DataCell(Text(data['address'].toString())), + DataCell(Text(data['contactName'] ?? '')), + DataCell( + Text(data['contactPosition'] ?? ''), + ), + DataCell(Text(data['contactPhone'] ?? '')), + DataCell(Text(data['contactEmail'] ?? '')), + ], + ); + }).toList(), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/company/widgets/company_form_header.dart b/lib/screens/company/widgets/company_form_header.dart new file mode 100644 index 0000000..592684a --- /dev/null +++ b/lib/screens/company/widgets/company_form_header.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/address_model.dart'; +import 'package:superport/screens/common/custom_widgets.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/screens/common/widgets/address_input.dart'; +import 'package:superport/utils/validators.dart'; +import 'package:superport/screens/company/widgets/company_name_autocomplete.dart'; +import 'package:superport/screens/common/widgets/remark_input.dart'; + +class CompanyFormHeader extends StatelessWidget { + final TextEditingController nameController; + final FocusNode nameFocusNode; + final List companyNames; + final List filteredCompanyNames; + final bool showCompanyNameDropdown; + final Function(String) onCompanyNameSelected; + final Function() onShowMapPressed; + final ValueChanged onNameSaved; + final ValueChanged
onAddressChanged; + final Address initialAddress; + final String nameLabel; + final String nameHint; + final TextEditingController remarkController; + + const CompanyFormHeader({ + Key? key, + required this.nameController, + required this.nameFocusNode, + required this.companyNames, + required this.filteredCompanyNames, + required this.showCompanyNameDropdown, + required this.onCompanyNameSelected, + required this.onShowMapPressed, + required this.onNameSaved, + required this.onAddressChanged, + this.initialAddress = const Address(), + required this.nameLabel, + required this.nameHint, + required this.remarkController, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 회사명/지점명 + FormFieldWrapper( + label: nameLabel, + isRequired: true, + child: CompanyNameAutocomplete( + nameController: nameController, + nameFocusNode: nameFocusNode, + companyNames: companyNames, + filteredCompanyNames: filteredCompanyNames, + showCompanyNameDropdown: showCompanyNameDropdown, + onCompanyNameSelected: onCompanyNameSelected, + onNameSaved: onNameSaved, + label: nameLabel, + hint: nameHint, + ), + ), + + // 주소 입력 위젯 (SRP에 따라 별도 컴포넌트로 분리) + AddressInput( + initialZipCode: initialAddress.zipCode, + initialRegion: initialAddress.region, + initialDetailAddress: initialAddress.detailAddress, + isRequired: false, + onAddressChanged: (zipCode, region, detailAddress) { + final address = Address( + zipCode: zipCode, + region: region, + detailAddress: detailAddress, + ); + onAddressChanged(address); + }, + ), + const SizedBox(height: 12), + // 비고 입력란 + RemarkInput(controller: remarkController), + ], + ); + } +} diff --git a/lib/screens/company/widgets/company_info_card.dart b/lib/screens/company/widgets/company_info_card.dart new file mode 100644 index 0000000..83f1876 --- /dev/null +++ b/lib/screens/company/widgets/company_info_card.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/company_model.dart'; +import 'package:superport/models/address_model.dart'; + +/// 회사/지점 정보를 1행(1열)로 보여주는 재활용 위젯 +class CompanyInfoCard extends StatelessWidget { + final String title; // 본사/지점 구분 + final String name; + final List companyTypes; + final Address address; + final String? contactName; + final String? contactPosition; + final String? contactPhone; + final String? contactEmail; + + const CompanyInfoCard({ + super.key, + required this.title, + required this.name, + required this.companyTypes, + required this.address, + this.contactName, + this.contactPosition, + this.contactPhone, + this.contactEmail, + }); + + @override + Widget build(BuildContext context) { + // 각 데이터가 없으면 빈 문자열로 표기 + final String zipCode = address.zipCode.isNotEmpty ? address.zipCode : ''; + final String displayName = name.isNotEmpty ? name : ''; + final String displayContactName = + contactName != null && contactName!.isNotEmpty ? contactName! : ''; + final String displayContactPosition = + contactPosition != null && contactPosition!.isNotEmpty + ? contactPosition! + : ''; + final String displayContactPhone = + contactPhone != null && contactPhone!.isNotEmpty ? contactPhone! : ''; + final String displayContactEmail = + contactEmail != null && contactEmail!.isNotEmpty ? contactEmail! : ''; + + return Card( + color: Colors.grey.shade50, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 본사/지점 구분만 상단에 표기 (텍스트 크기 14로 축소) + Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 2), // 간격도 절반으로 축소 + // 1행(1열)로 데이터만 표기 + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + Text(displayName, style: const TextStyle(fontSize: 13)), + const SizedBox(width: 12), + Text(zipCode, style: const TextStyle(fontSize: 13)), + const SizedBox(width: 12), + Text( + displayContactName, + style: const TextStyle(fontSize: 13), + ), + const SizedBox(width: 12), + Text( + displayContactPosition, + style: const TextStyle(fontSize: 13), + ), + const SizedBox(width: 12), + Text( + displayContactPhone, + style: const TextStyle(fontSize: 13), + ), + const SizedBox(width: 12), + Text( + displayContactEmail, + style: const TextStyle(fontSize: 13), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/company/widgets/company_name_autocomplete.dart b/lib/screens/company/widgets/company_name_autocomplete.dart new file mode 100644 index 0000000..4a7e326 --- /dev/null +++ b/lib/screens/company/widgets/company_name_autocomplete.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:superport/utils/validators.dart'; + +class CompanyNameAutocomplete extends StatelessWidget { + final TextEditingController nameController; + final FocusNode nameFocusNode; + final List companyNames; + final List filteredCompanyNames; + final bool showCompanyNameDropdown; + final Function(String) onCompanyNameSelected; + final ValueChanged onNameSaved; + final String label; + final String hint; + + const CompanyNameAutocomplete({ + Key? key, + required this.nameController, + required this.nameFocusNode, + required this.companyNames, + required this.filteredCompanyNames, + required this.showCompanyNameDropdown, + required this.onCompanyNameSelected, + required this.onNameSaved, + required this.label, + required this.hint, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: nameController, + focusNode: nameFocusNode, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: label, + hintText: hint, + suffixIcon: + nameController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + nameController.clear(); + }, + ) + : IconButton( + icon: const Icon(Icons.arrow_drop_down), + onPressed: () {}, + ), + ), + validator: (value) => validateRequired(value, label), + onFieldSubmitted: (_) { + if (filteredCompanyNames.length == 1 && showCompanyNameDropdown) { + onCompanyNameSelected(filteredCompanyNames[0]); + } + }, + onTap: () {}, + onSaved: onNameSaved, + ), + AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: + showCompanyNameDropdown + ? (filteredCompanyNames.length > 4 + ? 200 + : filteredCompanyNames.length * 50.0) + : 0, + margin: EdgeInsets.only(top: showCompanyNameDropdown ? 4 : 0), + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: GestureDetector( + onTap: () {}, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.grey.shade300), + boxShadow: [ + BoxShadow( + color: Colors.grey.withAlpha(77), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + child: + filteredCompanyNames.isEmpty + ? const Padding( + padding: EdgeInsets.all(12.0), + child: Text('검색 결과가 없습니다'), + ) + : ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: filteredCompanyNames.length, + separatorBuilder: + (context, index) => Divider( + height: 1, + color: Colors.grey.shade200, + ), + itemBuilder: (context, index) { + final companyName = filteredCompanyNames[index]; + final text = nameController.text.toLowerCase(); + + if (text.isEmpty) { + return ListTile( + dense: true, + title: Text(companyName), + onTap: () => onCompanyNameSelected(companyName), + ); + } + + // 일치하는 부분 찾기 + final matchIndex = companyName + .toLowerCase() + .indexOf(text.toLowerCase()); + if (matchIndex < 0) { + return ListTile( + dense: true, + title: Text(companyName), + onTap: () => onCompanyNameSelected(companyName), + ); + } + + return ListTile( + dense: true, + title: RichText( + text: TextSpan( + children: [ + // 일치 이전 부분 + if (matchIndex > 0) + TextSpan( + text: companyName.substring( + 0, + matchIndex, + ), + style: const TextStyle( + color: Colors.black, + ), + ), + // 일치하는 부분 + TextSpan( + text: companyName.substring( + matchIndex, + matchIndex + text.length, + ), + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + ), + // 일치 이후 부분 + if (matchIndex + text.length < + companyName.length) + TextSpan( + text: companyName.substring( + matchIndex + text.length, + ), + style: TextStyle( + color: + matchIndex == 0 + ? Colors.grey[600] + : Colors.black, + ), + ), + ], + ), + ), + onTap: () => onCompanyNameSelected(companyName), + ); + }, + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/company/widgets/contact_info_form.dart b/lib/screens/company/widgets/contact_info_form.dart new file mode 100644 index 0000000..559e05b --- /dev/null +++ b/lib/screens/company/widgets/contact_info_form.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/company/widgets/contact_info_widget.dart'; + +/// 담당자 정보 폼 +/// +/// 회사 등록 및 수정 화면에서 사용되는 담당자 정보 입력 폼 +/// 내부적으로 공통 ContactInfoWidget을 사용하여 코드 재사용성 확보 +class ContactInfoForm extends StatelessWidget { + final TextEditingController contactNameController; + final TextEditingController contactPositionController; + final TextEditingController contactPhoneController; + final TextEditingController contactEmailController; + final List positions; + final String selectedPhonePrefix; + final List phonePrefixes; + final ValueChanged onPhonePrefixChanged; + final ValueChanged onNameSaved; + final ValueChanged onPositionSaved; + final ValueChanged onPhoneSaved; + final ValueChanged onEmailSaved; + + const ContactInfoForm({ + Key? key, + required this.contactNameController, + required this.contactPositionController, + required this.contactPhoneController, + required this.contactEmailController, + required this.positions, + required this.selectedPhonePrefix, + required this.phonePrefixes, + required this.onPhonePrefixChanged, + required this.onNameSaved, + required this.onPositionSaved, + required this.onPhoneSaved, + required this.onEmailSaved, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + // ContactInfoWidget을 사용하여 담당자 정보 UI 구성 + return ContactInfoWidget( + contactNameController: contactNameController, + contactPositionController: contactPositionController, + contactPhoneController: contactPhoneController, + contactEmailController: contactEmailController, + positions: positions, + selectedPhonePrefix: selectedPhonePrefix, + phonePrefixes: phonePrefixes, + onPhonePrefixChanged: onPhonePrefixChanged, + + // 각 콜백 함수를 ContactInfoWidget의 onChanged 콜백과 연결 + onContactNameChanged: (value) => onNameSaved?.call(value), + onContactPositionChanged: (value) => onPositionSaved?.call(value), + onContactPhoneChanged: (value) => onPhoneSaved?.call(value), + onContactEmailChanged: (value) => onEmailSaved?.call(value), + ); + } +} diff --git a/lib/screens/company/widgets/contact_info_widget.dart b/lib/screens/company/widgets/contact_info_widget.dart new file mode 100644 index 0000000..b408354 --- /dev/null +++ b/lib/screens/company/widgets/contact_info_widget.dart @@ -0,0 +1,702 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'dart:developer' as developer; +import 'package:superport/screens/common/custom_widgets.dart'; +import 'package:superport/utils/validators.dart'; +import 'package:superport/utils/phone_utils.dart'; +import 'dart:math' as math; + +/// 담당자 정보 위젯 +/// +/// 회사 및 지점의 담당자 정보를 입력받는 공통 위젯 +/// SRP(단일 책임 원칙)에 따라 담당자 정보 입력 로직을 분리 +class ContactInfoWidget extends StatefulWidget { + /// 위젯 제목 + final String title; + + /// 담당자 이름 컨트롤러 + final TextEditingController contactNameController; + + /// 담당자 직책 컨트롤러 + final TextEditingController contactPositionController; + + /// 담당자 전화번호 컨트롤러 + final TextEditingController contactPhoneController; + + /// 담당자 이메일 컨트롤러 + final TextEditingController contactEmailController; + + /// 직책 목록 + final List positions; + + /// 선택된 전화번호 접두사 + final String selectedPhonePrefix; + + /// 전화번호 접두사 목록 + final List phonePrefixes; + + /// 직책 컴팩트 모드 (Row 또는 Column 레이아웃 결정) + final bool compactMode; + + /// 전화번호 접두사 변경 콜백 + final ValueChanged onPhonePrefixChanged; + + /// 담당자 이름 변경 콜백 + final ValueChanged onContactNameChanged; + + /// 담당자 직책 변경 콜백 + final ValueChanged onContactPositionChanged; + + /// 담당자 전화번호 변경 콜백 + final ValueChanged onContactPhoneChanged; + + /// 담당자 이메일 변경 콜백 + final ValueChanged onContactEmailChanged; + + const ContactInfoWidget({ + Key? key, + this.title = '담당자 정보', + required this.contactNameController, + required this.contactPositionController, + required this.contactPhoneController, + required this.contactEmailController, + required this.positions, + required this.selectedPhonePrefix, + required this.phonePrefixes, + required this.onPhonePrefixChanged, + required this.onContactNameChanged, + required this.onContactPositionChanged, + required this.onContactPhoneChanged, + required this.onContactEmailChanged, + this.compactMode = false, + }) : super(key: key); + + @override + State createState() => _ContactInfoWidgetState(); +} + +class _ContactInfoWidgetState extends State { + bool _showPositionDropdown = false; + bool _showPhonePrefixDropdown = false; + final LayerLink _positionLayerLink = LayerLink(); + final LayerLink _phonePrefixLayerLink = LayerLink(); + + OverlayEntry? _positionOverlayEntry; + OverlayEntry? _phonePrefixOverlayEntry; + + final FocusNode _positionFocusNode = FocusNode(); + final FocusNode _phonePrefixFocusNode = FocusNode(); + + @override + void initState() { + super.initState(); + developer.log('ContactInfoWidget 초기화 완료', name: 'ContactInfoWidget'); + + _positionFocusNode.addListener(() { + if (_positionFocusNode.hasFocus) { + developer.log('직책 필드 포커스 얻음', name: 'ContactInfoWidget'); + } else { + developer.log('직책 필드 포커스 잃음', name: 'ContactInfoWidget'); + } + }); + + _phonePrefixFocusNode.addListener(() { + if (_phonePrefixFocusNode.hasFocus) { + developer.log('전화번호 접두사 필드 포커스 얻음', name: 'ContactInfoWidget'); + } else { + developer.log('전화번호 접두사 필드 포커스 잃음', name: 'ContactInfoWidget'); + } + }); + } + + @override + void dispose() { + _removeAllOverlays(); + _positionFocusNode.dispose(); + _phonePrefixFocusNode.dispose(); + super.dispose(); + } + + void _togglePositionDropdown() { + developer.log( + '직책 드롭다운 토글: $_showPositionDropdown -> ${!_showPositionDropdown}', + name: 'ContactInfoWidget', + ); + setState(() { + if (_showPositionDropdown) { + _removePositionOverlay(); + } else { + _showPositionDropdown = true; + _showPhonePrefixDropdown = false; + _removePhonePrefixOverlay(); + _showPositionOverlay(); + } + }); + } + + void _togglePhonePrefixDropdown() { + developer.log( + '전화번호 접두사 드롭다운 토글: $_showPhonePrefixDropdown -> ${!_showPhonePrefixDropdown}', + name: 'ContactInfoWidget', + ); + setState(() { + if (_showPhonePrefixDropdown) { + _removePhonePrefixOverlay(); + } else { + _showPhonePrefixDropdown = true; + _showPositionDropdown = false; + _removePositionOverlay(); + _showPhonePrefixOverlay(); + } + }); + } + + void _removePositionOverlay() { + _positionOverlayEntry?.remove(); + _positionOverlayEntry = null; + _showPositionDropdown = false; + } + + void _removePhonePrefixOverlay() { + _phonePrefixOverlayEntry?.remove(); + _phonePrefixOverlayEntry = null; + _showPhonePrefixDropdown = false; + } + + void _removeAllOverlays() { + _removePositionOverlay(); + _removePhonePrefixOverlay(); + } + + void _closeAllDropdowns() { + if (_showPositionDropdown || _showPhonePrefixDropdown) { + developer.log('모든 드롭다운 닫기', name: 'ContactInfoWidget'); + setState(() { + _removeAllOverlays(); + }); + } + } + + void _showPositionOverlay() { + final RenderBox renderBox = context.findRenderObject() as RenderBox; + final size = renderBox.size; + final offset = renderBox.localToGlobal(Offset.zero); + + final availableHeight = + MediaQuery.of(context).size.height - offset.dy - 100; + final maxHeight = math.min(300.0, availableHeight); + + _positionOverlayEntry = OverlayEntry( + builder: + (context) => Positioned( + width: 200, + child: CompositedTransformFollower( + link: _positionLayerLink, + showWhenUnlinked: false, + offset: const Offset(0, 45), + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(4), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + constraints: BoxConstraints(maxHeight: maxHeight), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...widget.positions.map( + (position) => InkWell( + onTap: () { + developer.log( + '직책 선택됨: $position', + name: 'ContactInfoWidget', + ); + setState(() { + widget.contactPositionController.text = + position; + widget.onContactPositionChanged(position); + _removePositionOverlay(); + }); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + width: double.infinity, + child: Text(position), + ), + ), + ), + InkWell( + onTap: () { + developer.log( + '직책 기타(직접 입력) 선택됨', + name: 'ContactInfoWidget', + ); + _removePositionOverlay(); + widget.contactPositionController.clear(); + widget.onContactPositionChanged(''); + _positionFocusNode.requestFocus(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + width: double.infinity, + child: const Text('기타 (직접 입력)'), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + + Overlay.of(context).insert(_positionOverlayEntry!); + } + + void _showPhonePrefixOverlay() { + final RenderBox renderBox = context.findRenderObject() as RenderBox; + final size = renderBox.size; + final offset = renderBox.localToGlobal(Offset.zero); + + final availableHeight = + MediaQuery.of(context).size.height - offset.dy - 100; + final maxHeight = math.min(300.0, availableHeight); + + _phonePrefixOverlayEntry = OverlayEntry( + builder: + (context) => Positioned( + width: 200, + child: CompositedTransformFollower( + link: _phonePrefixLayerLink, + showWhenUnlinked: false, + offset: const Offset(0, 45), + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(4), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + constraints: BoxConstraints(maxHeight: maxHeight), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...widget.phonePrefixes.map( + (prefix) => InkWell( + onTap: () { + developer.log( + '전화번호 접두사 선택됨: $prefix', + name: 'ContactInfoWidget', + ); + widget.onPhonePrefixChanged(prefix); + setState(() { + _removePhonePrefixOverlay(); + }); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + width: double.infinity, + child: Text(prefix), + ), + ), + ), + InkWell( + onTap: () { + developer.log( + '전화번호 접두사 직접 입력 선택됨', + name: 'ContactInfoWidget', + ); + _removePhonePrefixOverlay(); + _phonePrefixFocusNode.requestFocus(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + width: double.infinity, + child: const Text('기타 (직접 입력)'), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + + Overlay.of(context).insert(_phonePrefixOverlayEntry!); + } + + @override + Widget build(BuildContext context) { + developer.log( + 'ContactInfoWidget 빌드 시작: 직책 드롭다운=$_showPositionDropdown, 전화번호 접두사 드롭다운=$_showPhonePrefixDropdown', + name: 'ContactInfoWidget', + ); + + // 컴팩트 모드에 따라 다른 레이아웃 생성 + return FormFieldWrapper( + label: widget.title, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: + widget.compactMode ? _buildCompactLayout() : _buildDefaultLayout(), + ), + ); + } + + // 기본 레이아웃 (한 줄에 모든 필드 표시) + List _buildDefaultLayout() { + return [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 담당자 이름 + Expanded( + flex: 3, + child: TextFormField( + controller: widget.contactNameController, + decoration: const InputDecoration( + hintText: '이름', + contentPadding: EdgeInsets.symmetric( + horizontal: 10, + vertical: 15, + ), + ), + onTap: () { + developer.log('이름 필드 터치됨', name: 'ContactInfoWidget'); + _closeAllDropdowns(); + }, + onChanged: widget.onContactNameChanged, + ), + ), + const SizedBox(width: 8), + // 담당자 직책 + Expanded( + flex: 2, + child: CompositedTransformTarget( + link: _positionLayerLink, + child: Stack( + children: [ + TextFormField( + controller: widget.contactPositionController, + focusNode: _positionFocusNode, + decoration: InputDecoration( + hintText: '직책', + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 15, + ), + suffixIcon: IconButton( + icon: const Icon(Icons.arrow_drop_down, size: 20), + padding: EdgeInsets.zero, + onPressed: () { + developer.log( + '직책 드롭다운 버튼 클릭됨', + name: 'ContactInfoWidget', + ); + _togglePositionDropdown(); + }, + ), + ), + onTap: () { + // 필드를 터치했을 때는 드롭다운을 열지 않고 직접 입력 모드로 진입 + _closeAllDropdowns(); + }, + onChanged: widget.onContactPositionChanged, + ), + ], + ), + ), + ), + const SizedBox(width: 8), + // 전화번호 접두사 + Expanded( + flex: 2, + child: CompositedTransformTarget( + link: _phonePrefixLayerLink, + child: Stack( + children: [ + TextFormField( + controller: TextEditingController( + text: widget.selectedPhonePrefix, + ), + focusNode: _phonePrefixFocusNode, + decoration: InputDecoration( + hintText: '국가번호', + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 15, + ), + suffixIcon: IconButton( + icon: const Icon(Icons.arrow_drop_down, size: 20), + padding: EdgeInsets.zero, + onPressed: () { + developer.log( + '전화번호 접두사 드롭다운 버튼 클릭됨', + name: 'ContactInfoWidget', + ); + _togglePhonePrefixDropdown(); + }, + ), + ), + onTap: () { + // 필드를 터치했을 때는 드롭다운을 열지 않고 직접 입력 모드로 진입 + _closeAllDropdowns(); + }, + onChanged: (value) { + if (value.isNotEmpty) { + widget.onPhonePrefixChanged(value); + } + }, + ), + ], + ), + ), + ), + const SizedBox(width: 8), + // 담당자 전화번호 + Expanded( + flex: 3, + child: TextFormField( + controller: widget.contactPhoneController, + decoration: const InputDecoration( + hintText: '전화번호', + contentPadding: EdgeInsets.symmetric( + horizontal: 10, + vertical: 15, + ), + ), + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + PhoneUtils.phoneInputFormatter, + ], + onTap: () { + developer.log('전화번호 필드 터치됨', name: 'ContactInfoWidget'); + _closeAllDropdowns(); + }, + validator: validatePhoneNumber, + onChanged: widget.onContactPhoneChanged, + ), + ), + const SizedBox(width: 8), + // 담당자 이메일 + Expanded( + flex: 6, + child: TextFormField( + controller: widget.contactEmailController, + decoration: const InputDecoration( + hintText: '이메일', + contentPadding: EdgeInsets.symmetric( + horizontal: 10, + vertical: 15, + ), + ), + keyboardType: TextInputType.emailAddress, + onTap: () { + developer.log('이메일 필드 터치됨', name: 'ContactInfoWidget'); + _closeAllDropdowns(); + }, + validator: FormValidator.email(), + onChanged: widget.onContactEmailChanged, + ), + ), + ], + ), + ]; + } + + // 컴팩트 레이아웃 (여러 줄에 필드 표시) + List _buildCompactLayout() { + return [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 담당자 이름 + Expanded( + child: TextFormField( + controller: widget.contactNameController, + decoration: const InputDecoration( + hintText: '담당자 이름', + contentPadding: EdgeInsets.symmetric( + horizontal: 10, + vertical: 15, + ), + ), + onTap: () { + developer.log('이름 필드 터치됨', name: 'ContactInfoWidget'); + _closeAllDropdowns(); + }, + onChanged: widget.onContactNameChanged, + ), + ), + const SizedBox(width: 16), + // 담당자 직책 + Expanded( + child: CompositedTransformTarget( + link: _positionLayerLink, + child: InkWell( + onTap: _togglePositionDropdown, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 15, + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + widget.contactPositionController.text.isEmpty + ? '직책 선택' + : widget.contactPositionController.text, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: + widget.contactPositionController.text.isEmpty + ? Colors.grey.shade600 + : Colors.black, + ), + ), + ), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 전화번호 (접두사 + 번호) + Expanded( + child: Row( + children: [ + // 전화번호 접두사 + CompositedTransformTarget( + link: _phonePrefixLayerLink, + child: InkWell( + onTap: _togglePhonePrefixDropdown, + child: Container( + width: 70, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 14, + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(4), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + widget.selectedPhonePrefix, + style: const TextStyle(fontSize: 14), + ), + const Icon(Icons.arrow_drop_down, size: 18), + ], + ), + ), + ), + ), + // 전화번호 + Expanded( + child: TextFormField( + controller: widget.contactPhoneController, + decoration: const InputDecoration( + hintText: '전화번호', + border: OutlineInputBorder( + borderRadius: BorderRadius.horizontal( + left: Radius.zero, + right: Radius.circular(4), + ), + ), + contentPadding: EdgeInsets.symmetric( + horizontal: 10, + vertical: 15, + ), + ), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + PhoneUtils.phoneInputFormatter, + ], + keyboardType: TextInputType.phone, + onTap: _closeAllDropdowns, + onChanged: widget.onContactPhoneChanged, + validator: validatePhoneNumber, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + // 이메일 + Expanded( + child: TextFormField( + controller: widget.contactEmailController, + decoration: const InputDecoration( + hintText: '이메일 주소', + contentPadding: EdgeInsets.symmetric( + horizontal: 10, + vertical: 15, + ), + ), + keyboardType: TextInputType.emailAddress, + onTap: _closeAllDropdowns, + onChanged: widget.onContactEmailChanged, + validator: FormValidator.email(), + ), + ), + ], + ), + ]; + } +} diff --git a/lib/screens/company/widgets/duplicate_company_dialog.dart b/lib/screens/company/widgets/duplicate_company_dialog.dart new file mode 100644 index 0000000..1d5c042 --- /dev/null +++ b/lib/screens/company/widgets/duplicate_company_dialog.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/company_model.dart'; + +/// 중복된 회사명을 확인하는 대화상자 +class DuplicateCompanyDialog extends StatelessWidget { + final Company company; + + const DuplicateCompanyDialog({Key? key, required this.company}) + : super(key: key); + + static void show(BuildContext context, Company company) { + showDialog( + context: context, + builder: (context) => DuplicateCompanyDialog(company: company), + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('중복된 회사'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('동일한 이름의 회사가 이미 등록되어 있습니다.'), + const SizedBox(height: 16), + Text('회사명: ${company.name}'), + Text('주소: ${company.address ?? ''}'), + Text('담당자: ${company.contactName ?? ''}'), + Text('직책: ${company.contactPosition ?? ''}'), + Text('연락처: ${company.contactPhone ?? ''}'), + Text('이메일: ${company.contactEmail ?? ''}'), + const SizedBox(height: 8), + if (company.branches != null && company.branches!.isNotEmpty) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '지점 정보:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ...company.branches!.map( + (branch) => Padding( + padding: const EdgeInsets.only(left: 8.0, top: 4.0), + child: Text( + '${branch.name}: ${branch.address ?? ''} (담당자: ${branch.contactName ?? ''}, 직책: ${branch.contactPosition ?? ''}, 연락처: ${branch.contactPhone ?? ''})', + ), + ), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('확인'), + ), + ], + ); + } +} diff --git a/lib/screens/company/widgets/map_dialog.dart b/lib/screens/company/widgets/map_dialog.dart new file mode 100644 index 0000000..24cb836 --- /dev/null +++ b/lib/screens/company/widgets/map_dialog.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; + +/// 주소에 대한 지도 대화상자를 표시합니다. +class MapDialog extends StatelessWidget { + final String address; + + const MapDialog({Key? key, required this.address}) : super(key: key); + + static void show(BuildContext context, String address) { + if (address.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('주소를 먼저 입력해주세요.'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + showDialog( + context: context, + builder: (BuildContext context) { + return MapDialog(address: address); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Container( + width: MediaQuery.of(context).size.width * 0.9, + height: MediaQuery.of(context).size.height * 0.7, + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '주소 지도 보기', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 8), + Text('주소: $address', style: const TextStyle(fontSize: 14)), + const SizedBox(height: 16), + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.map, + size: 64, + color: AppThemeTailwind.primary, + ), + const SizedBox(height: 16), + Text( + '여기에 주소 "$address"에 대한\n지도가 표시됩니다.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade700), + ), + const SizedBox(height: 24), + Text( + '실제 구현 시에는 Google Maps 또는\n다른 지도 서비스 API를 연동하세요.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/equipment/controllers/equipment_in_form_controller.dart b/lib/screens/equipment/controllers/equipment_in_form_controller.dart new file mode 100644 index 0000000..760b791 --- /dev/null +++ b/lib/screens/equipment/controllers/equipment_in_form_controller.dart @@ -0,0 +1,267 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/equipment_unified_model.dart'; +import 'package:superport/models/company_model.dart'; +import 'package:superport/services/mock_data_service.dart'; +import 'package:superport/utils/constants.dart'; + +/// 장비 입고 폼 컨트롤러 +/// +/// 폼의 전체 상태, 유효성, 저장, 데이터 로딩 등 비즈니스 로직을 담당한다. +class EquipmentInFormController { + final MockDataService dataService; + final int? equipmentInId; + + // 폼 키 + final GlobalKey formKey = GlobalKey(); + + // 입력 상태 변수 + String manufacturer = ''; + String name = ''; + String category = ''; + String subCategory = ''; + String subSubCategory = ''; + String serialNumber = ''; + String barcode = ''; + int quantity = 1; + DateTime inDate = DateTime.now(); + String equipmentType = EquipmentType.new_; + bool hasSerialNumber = true; + + // 워런티 관련 상태 + String? warrantyLicense; + String? warrantyCode; // 워런티 코드(텍스트 입력) + DateTime warrantyStartDate = DateTime.now(); + DateTime warrantyEndDate = DateTime.now().add(const Duration(days: 365)); + List warrantyLicenses = []; + + // 자동완성 데이터 + List manufacturers = []; + List equipmentNames = []; + // 카테고리 자동완성 데이터 + List categories = []; + List subCategories = []; + List subSubCategories = []; + + // 편집 모드 여부 + bool isEditMode = false; + + // 입고지, 파트너사 관련 상태 + String? warehouseLocation; + String? partnerCompany; + List warehouseLocations = []; + List partnerCompanies = []; + + final TextEditingController remarkController = TextEditingController(); + + EquipmentInFormController({required this.dataService, this.equipmentInId}) { + isEditMode = equipmentInId != null; + _loadManufacturers(); + _loadEquipmentNames(); + _loadCategories(); + _loadSubCategories(); + _loadSubSubCategories(); + _loadWarehouseLocations(); + _loadPartnerCompanies(); + _loadWarrantyLicenses(); + if (isEditMode) { + _loadEquipmentIn(); + } + } + + // 제조사 목록 로드 + void _loadManufacturers() { + manufacturers = dataService.getAllManufacturers(); + } + + // 장비명 목록 로드 + void _loadEquipmentNames() { + equipmentNames = dataService.getAllEquipmentNames(); + } + + // 카테고리 목록 로드 + void _loadCategories() { + categories = dataService.getAllCategories(); + } + + // 서브카테고리 목록 로드 + void _loadSubCategories() { + subCategories = dataService.getAllSubCategories(); + } + + // 서브서브카테고리 목록 로드 + void _loadSubSubCategories() { + subSubCategories = dataService.getAllSubSubCategories(); + } + + // 입고지 목록 로드 + void _loadWarehouseLocations() { + warehouseLocations = + dataService.getAllWarehouseLocations().map((e) => e.name).toList(); + } + + // 파트너사 목록 로드 + void _loadPartnerCompanies() { + partnerCompanies = + dataService + .getAllCompanies() + .where((c) => c.companyTypes.contains(CompanyType.partner)) + .map((c) => c.name) + .toList(); + } + + // 워런티 라이센스 목록 로드 + void _loadWarrantyLicenses() { + // 실제로는 API나 서비스에서 불러와야 하지만, 파트너사와 동일한 데이터 사용 + warrantyLicenses = List.from(partnerCompanies); + } + + // 기존 데이터 로드(수정 모드) + void _loadEquipmentIn() { + final equipmentIn = dataService.getEquipmentInById(equipmentInId!); + if (equipmentIn != null) { + manufacturer = equipmentIn.equipment.manufacturer; + name = equipmentIn.equipment.name; + category = equipmentIn.equipment.category; + subCategory = equipmentIn.equipment.subCategory; + subSubCategory = equipmentIn.equipment.subSubCategory; + serialNumber = equipmentIn.equipment.serialNumber ?? ''; + barcode = equipmentIn.equipment.barcode ?? ''; + quantity = equipmentIn.equipment.quantity; + inDate = equipmentIn.inDate; + hasSerialNumber = serialNumber.isNotEmpty; + equipmentType = equipmentIn.type; + warehouseLocation = equipmentIn.warehouseLocation; + partnerCompany = equipmentIn.partnerCompany; + remarkController.text = equipmentIn.remark ?? ''; + + // 워런티 정보 로드 (실제 구현에서는 기존 값을 불러옵니다) + warrantyLicense = equipmentIn.partnerCompany; // 기본값으로 파트너사 이름 사용 + warrantyStartDate = equipmentIn.inDate; + warrantyEndDate = equipmentIn.inDate.add(const Duration(days: 365)); + // 워런티 코드도 불러오도록(실제 구현시) + warrantyCode = null; // TODO: 실제 데이터에서 불러올 경우 수정 + } + } + + // 워런티 기간 계산 + String getWarrantyPeriodSummary() { + final difference = warrantyEndDate.difference(warrantyStartDate); + final days = difference.inDays; + + if (days <= 0) { + return '유효하지 않은 기간'; + } + + final years = days ~/ 365; + final remainingDays = days % 365; + + String summary = ''; + if (years > 0) { + summary += '$years년 '; + } + if (remainingDays > 0) { + summary += '$remainingDays일'; + } + + return summary.trim(); + } + + // 저장 처리 + bool save() { + if (!formKey.currentState!.validate()) { + return false; + } + formKey.currentState!.save(); + + // 입력값이 리스트에 없으면 추가 + if (partnerCompany != null && + partnerCompany!.isNotEmpty && + !partnerCompanies.contains(partnerCompany)) { + partnerCompanies.add(partnerCompany!); + } + if (warehouseLocation != null && + warehouseLocation!.isNotEmpty && + !warehouseLocations.contains(warehouseLocation)) { + warehouseLocations.add(warehouseLocation!); + } + if (manufacturer.isNotEmpty && !manufacturers.contains(manufacturer)) { + manufacturers.add(manufacturer); + } + if (name.isNotEmpty && !equipmentNames.contains(name)) { + equipmentNames.add(name); + } + if (category.isNotEmpty && !categories.contains(category)) { + categories.add(category); + } + if (subCategory.isNotEmpty && !subCategories.contains(subCategory)) { + subCategories.add(subCategory); + } + if (subSubCategory.isNotEmpty && + !subSubCategories.contains(subSubCategory)) { + subSubCategories.add(subSubCategory); + } + if (warrantyLicense != null && + warrantyLicense!.isNotEmpty && + !warrantyLicenses.contains(warrantyLicense)) { + warrantyLicenses.add(warrantyLicense!); + } + + final equipment = Equipment( + manufacturer: manufacturer, + name: name, + category: category, + subCategory: subCategory, + subSubCategory: subSubCategory, + serialNumber: hasSerialNumber ? serialNumber : null, + barcode: barcode.isNotEmpty ? barcode : null, + quantity: quantity, + remark: remarkController.text.trim(), + warrantyLicense: warrantyLicense, + warrantyStartDate: warrantyStartDate, + warrantyEndDate: warrantyEndDate, + // 워런티 코드 저장 필요시 여기에 추가 + ); + if (isEditMode) { + final equipmentIn = dataService.getEquipmentInById(equipmentInId!); + if (equipmentIn != null) { + final updatedEquipmentIn = EquipmentIn( + id: equipmentIn.id, + equipment: equipment, + inDate: inDate, + status: equipmentIn.status, + type: equipmentType, + warehouseLocation: warehouseLocation, + partnerCompany: partnerCompany, + remark: remarkController.text.trim(), + ); + dataService.updateEquipmentIn(updatedEquipmentIn); + } + } else { + final newEquipmentIn = EquipmentIn( + equipment: equipment, + inDate: inDate, + type: equipmentType, + warehouseLocation: warehouseLocation, + partnerCompany: partnerCompany, + remark: remarkController.text.trim(), + ); + dataService.addEquipmentIn(newEquipmentIn); + } + + // 저장 후 리스트 재로딩 (중복 방지 및 최신화) + _loadManufacturers(); + _loadEquipmentNames(); + _loadCategories(); + _loadSubCategories(); + _loadSubSubCategories(); + _loadWarehouseLocations(); + _loadPartnerCompanies(); + _loadWarrantyLicenses(); + + return true; + } + + void dispose() { + remarkController.dispose(); + } +} diff --git a/lib/screens/equipment/controllers/equipment_list_controller.dart b/lib/screens/equipment/controllers/equipment_list_controller.dart new file mode 100644 index 0000000..0241d8b --- /dev/null +++ b/lib/screens/equipment/controllers/equipment_list_controller.dart @@ -0,0 +1,170 @@ +import 'package:superport/models/equipment_unified_model.dart'; +import 'package:superport/services/mock_data_service.dart'; +import 'package:superport/utils/constants.dart'; + +// companyTypeToString 함수 import +import 'package:superport/utils/constants.dart' + show companyTypeToString, CompanyType; +import 'package:superport/models/company_model.dart'; +import 'package:superport/models/address_model.dart'; + +// 장비 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러 +class EquipmentListController { + final MockDataService dataService; + List equipments = []; + String? selectedStatusFilter; + final Set selectedEquipmentIds = {}; // 'id:status' 형식 + + EquipmentListController({required this.dataService}); + + // 데이터 로드 및 상태 필터 적용 + void loadData() { + equipments = dataService.getAllEquipments(); + if (selectedStatusFilter != null) { + equipments = + equipments.where((e) => e.status == selectedStatusFilter).toList(); + } + selectedEquipmentIds.clear(); + } + + // 상태 필터 변경 + void changeStatusFilter(String? status) { + selectedStatusFilter = status; + loadData(); + } + + // 장비 선택/해제 (모든 상태 지원) + void selectEquipment(int? id, String status, bool? isSelected) { + if (id == null || isSelected == null) return; + final key = '$id:$status'; + if (isSelected) { + selectedEquipmentIds.add(key); + } else { + selectedEquipmentIds.remove(key); + } + } + + // 선택된 입고 장비 수 반환 + int getSelectedInStockCount() { + int count = 0; + for (final idStatusPair in selectedEquipmentIds) { + final parts = idStatusPair.split(':'); + if (parts.length == 2 && parts[1] == EquipmentStatus.in_) { + count++; + } + } + return count; + } + + // 선택된 전체 장비 수 반환 + int getSelectedEquipmentCount() { + return selectedEquipmentIds.length; + } + + // 선택된 특정 상태의 장비 수 반환 + int getSelectedEquipmentCountByStatus(String status) { + int count = 0; + for (final idStatusPair in selectedEquipmentIds) { + final parts = idStatusPair.split(':'); + if (parts.length == 2 && parts[1] == status) { + count++; + } + } + return count; + } + + // 선택된 장비들의 UnifiedEquipment 객체 목록 반환 + List getSelectedEquipments() { + List selected = []; + for (final idStatusPair in selectedEquipmentIds) { + final parts = idStatusPair.split(':'); + if (parts.length == 2) { + final id = int.tryParse(parts[0]); + if (id != null) { + final equipment = equipments.firstWhere( + (e) => e.id == id && e.status == parts[1], + orElse: () => null as UnifiedEquipment, + ); + if (equipment != null) { + selected.add(equipment); + } + } + } + } + return selected; + } + + // 선택된 특정 상태의 장비들의 UnifiedEquipment 객체 목록 반환 + List getSelectedEquipmentsByStatus(String status) { + List selected = []; + for (final idStatusPair in selectedEquipmentIds) { + final parts = idStatusPair.split(':'); + if (parts.length == 2 && parts[1] == status) { + final id = int.tryParse(parts[0]); + if (id != null) { + final equipment = equipments.firstWhere( + (e) => e.id == id && e.status == status, + orElse: () => null as UnifiedEquipment, + ); + if (equipment != null) { + selected.add(equipment); + } + } + } + } + return selected; + } + + // 선택된 장비들의 요약 정보를 Map 형태로 반환 (출고/대여/폐기 폼에서 사용) + List> getSelectedEquipmentsSummary() { + List> summaryList = []; + List selectedEquipmentsInStock = + getSelectedEquipmentsByStatus(EquipmentStatus.in_); + + for (final equipment in selectedEquipmentsInStock) { + summaryList.add({ + 'equipment': equipment.equipment, + 'equipmentInId': equipment.id, + 'status': equipment.status, + }); + } + + return summaryList; + } + + // 출고 정보(회사, 담당자, 라이센스 등) 반환 + String getOutEquipmentInfo(int equipmentId, String infoType) { + final equipmentOut = dataService.getEquipmentOutById(equipmentId); + if (equipmentOut != null) { + switch (infoType) { + case 'company': + final company = equipmentOut.company ?? '-'; + if (company != '-') { + final companyObj = dataService.getAllCompanies().firstWhere( + (c) => c.name == company, + orElse: + () => Company( + name: company, + address: Address(), + companyTypes: [CompanyType.customer], // 기본값 고객사 + ), + ); + // 여러 유형 중 첫 번째만 표시 (대표 유형) + final typeText = + companyObj.companyTypes.isNotEmpty + ? companyTypeToString(companyObj.companyTypes.first) + : '-'; + return '$company (${typeText})'; + } + return company; + case 'manager': + return equipmentOut.manager ?? '-'; + case 'license': + return equipmentOut.license ?? '-'; + default: + return '-'; + } + } + return '-'; + } +} diff --git a/lib/screens/equipment/controllers/equipment_out_form_controller.dart b/lib/screens/equipment/controllers/equipment_out_form_controller.dart new file mode 100644 index 0000000..52b47f9 --- /dev/null +++ b/lib/screens/equipment/controllers/equipment_out_form_controller.dart @@ -0,0 +1,645 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/equipment_unified_model.dart'; +import 'package:superport/models/company_model.dart'; +import 'package:superport/models/address_model.dart'; +import 'package:superport/services/mock_data_service.dart'; + +// 장비 출고 폼의 상태 및 비즈니스 로직을 담당하는 컨트롤러 +class EquipmentOutFormController { + final MockDataService dataService; + final GlobalKey formKey = GlobalKey(); + final TextEditingController remarkController = TextEditingController(); + + // 상태 변수 + bool isEditMode = false; + String manufacturer = ''; + String name = ''; + String category = ''; + String subCategory = ''; + String subSubCategory = ''; + String serialNumber = ''; + String barcode = ''; + int quantity = 1; + DateTime outDate = DateTime.now(); + bool hasSerialNumber = false; + DateTime? inDate; + String returnType = '재입고'; + DateTime returnDate = DateTime.now(); + bool hasManagers = false; + + // 출고 유형(출고/대여/폐기) 상태 변수 추가 + String outType = '출고'; // 기본값은 '출고' + + // 기존 필드 - 호환성을 위해 유지 + String? _selectedCompany; + String? get selectedCompany => + selectedCompanies.isNotEmpty ? selectedCompanies[0] : null; + set selectedCompany(String? value) { + if (selectedCompanies.isEmpty) { + selectedCompanies.add(value); + } else { + selectedCompanies[0] = value; + } + _selectedCompany = value; + } + + String? _selectedManager; + String? get selectedManager => + selectedManagersPerCompany.isNotEmpty + ? selectedManagersPerCompany[0] + : null; + set selectedManager(String? value) { + if (selectedManagersPerCompany.isEmpty) { + selectedManagersPerCompany.add(value); + } else { + selectedManagersPerCompany[0] = value; + } + _selectedManager = value; + } + + String? selectedLicense; + List companies = []; + // 회사 및 지점 관련 데이터 + List companiesWithBranches = []; + List managers = []; + List filteredManagers = []; + List licenses = []; + + // 출고 회사 목록 관리 + List selectedCompanies = [null]; // 첫 번째 드롭다운을 위한 초기값 + List> availableCompaniesPerDropdown = + []; // 각 드롭다운마다 사용 가능한 회사 목록 + List selectedManagersPerCompany = [null]; // 각 드롭다운 회사별 선택된 담당자 + List> filteredManagersPerCompany = []; // 각 드롭다운 회사별 필터링된 담당자 목록 + List hasManagersPerCompany = [false]; // 각 회사별 담당자 유무 + + // 입력 데이터 + Equipment? selectedEquipment; + int? selectedEquipmentInId; + int? equipmentOutId; + List>? _selectedEquipments; + + EquipmentOutFormController({required this.dataService}); + + // 선택된 장비 정보 설정 (디버그용) + set selectedEquipments(List>? equipments) { + debugPrint('설정된 장비 목록: ${equipments?.length ?? 0}개'); + if (equipments != null) { + for (var i = 0; i < equipments.length; i++) { + final equipment = equipments[i]['equipment'] as Equipment; + debugPrint('장비 $i: ${equipment.manufacturer} ${equipment.name}'); + } + } + _selectedEquipments = equipments; + } + + List>? get selectedEquipments => _selectedEquipments; + + // 드롭다운 데이터 로드 + void loadDropdownData() { + final allCompanies = dataService.getAllCompanies(); + + // 회사와 지점 통합 목록 생성 + companiesWithBranches = []; + companies = []; + + for (var company in allCompanies) { + // 회사 자체 정보 추가 + final companyType = + company.companyTypes.isNotEmpty + ? companyTypeToString(company.companyTypes.first) + : '-'; + final companyInfo = CompanyBranchInfo( + id: company.id, + name: "${company.name} (${companyType})", + originalName: company.name, + isMainCompany: true, + companyId: company.id, + branchId: null, + ); + companiesWithBranches.add(companyInfo); + companies.add(companyInfo.name); + + // 지점 정보 추가 + if (company.branches != null && company.branches!.isNotEmpty) { + for (var branch in company.branches!) { + final branchInfo = CompanyBranchInfo( + id: branch.id, + name: "${company.name} ${branch.name}", + displayName: branch.name, + originalName: branch.name, + isMainCompany: false, + companyId: company.id, + branchId: branch.id, + parentCompanyName: company.name, + ); + companiesWithBranches.add(branchInfo); + companies.add(branchInfo.name); + } + } + } + + // 나머지 데이터 로드 + final allUsers = dataService.getAllUsers(); + managers = allUsers.map((user) => user.name).toList(); + filteredManagers = managers; + final allLicenses = dataService.getAllLicenses(); + licenses = allLicenses.map((license) => license.name).toList(); + if (companies.isEmpty) companies.add('기타'); + if (managers.isEmpty) managers.add('기타'); + if (licenses.isEmpty) licenses.add('기타'); + updateManagersState(); + + // 출고 회사 드롭다운 초기화 + availableCompaniesPerDropdown = [List.from(companies)]; + filteredManagersPerCompany = [List.from(managers)]; + hasManagersPerCompany = [hasManagers]; + + // 디버그 정보 출력 + debugPrint('드롭다운 데이터 로드 완료'); + debugPrint('장비 목록: ${_selectedEquipments?.length ?? 0}개'); + debugPrint('회사 및 지점 목록: ${companiesWithBranches.length}개'); + + // 수정 모드인 경우 기존 선택값 동기화 + if (isEditMode && equipmentOutId != null) { + final equipmentOut = dataService.getEquipmentOutById(equipmentOutId!); + if (equipmentOut != null && equipmentOut.company != null) { + String companyName = ''; + + // 회사 이름 찾기 + for (String company in companies) { + if (company.startsWith(equipmentOut.company!)) { + companyName = company; + break; + } + } + + if (companyName.isNotEmpty) { + selectedCompanies[0] = companyName; + filterManagersByCompanyAtIndex(companyName, 0); + + // 기존 담당자 설정 + if (equipmentOut.manager != null) { + selectedManagersPerCompany[0] = equipmentOut.manager; + } + } + + // 라이센스 설정 + if (equipmentOut.license != null) { + selectedLicense = equipmentOut.license; + } + } + } + } + + // 회사에 따라 담당자 목록 필터링 + void filterManagersByCompany(String? companyName) { + if (companyName == null || companyName.isEmpty) { + filteredManagers = managers; + } else { + // 회사 또는 지점 이름에서 CompanyBranchInfo 찾기 + CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName( + companyName, + ); + + if (companyInfo != null && companyInfo.companyId != null) { + int companyId = companyInfo.companyId!; + final companyUsers = + dataService + .getAllUsers() + .where((user) => user.companyId == companyId) + .toList(); + + if (companyUsers.isNotEmpty) { + filteredManagers = companyUsers.map((user) => user.name).toList(); + } else { + filteredManagers = ['없음']; + } + } else { + filteredManagers = ['없음']; + } + } + + if (selectedManager != null && + !filteredManagers.contains(selectedManager)) { + selectedManager = + filteredManagers.isNotEmpty ? filteredManagers[0] : null; + } + updateManagersState(); + + // 첫 번째 회사에 대한 담당자 목록과 동기화 + if (filteredManagersPerCompany.isNotEmpty) { + filteredManagersPerCompany[0] = List.from(filteredManagers); + hasManagersPerCompany[0] = hasManagers; + if (selectedManagersPerCompany.isNotEmpty) { + selectedManagersPerCompany[0] = selectedManager; + } + } + } + + // 특정 인덱스의 회사에 따라 담당자 목록 필터링 + void filterManagersByCompanyAtIndex(String? companyName, int index) { + if (companyName == null || companyName.isEmpty) { + filteredManagersPerCompany[index] = managers; + } else { + // 회사 또는 지점 이름에서 CompanyBranchInfo 찾기 + CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName( + companyName, + ); + + if (companyInfo != null && companyInfo.companyId != null) { + int companyId = companyInfo.companyId!; + final companyUsers = + dataService + .getAllUsers() + .where((user) => user.companyId == companyId) + .toList(); + + if (companyUsers.isNotEmpty) { + filteredManagersPerCompany[index] = + companyUsers.map((user) => user.name).toList(); + } else { + filteredManagersPerCompany[index] = ['없음']; + } + } else { + filteredManagersPerCompany[index] = ['없음']; + } + } + + if (selectedManagersPerCompany[index] != null && + !filteredManagersPerCompany[index].contains( + selectedManagersPerCompany[index], + )) { + selectedManagersPerCompany[index] = + filteredManagersPerCompany[index].isNotEmpty + ? filteredManagersPerCompany[index][0] + : null; + } + updateManagersStateAtIndex(index); + + // 첫 번째 회사인 경우 기존 필드와 동기화 + if (index == 0) { + filteredManagers = List.from(filteredManagersPerCompany[0]); + hasManagers = hasManagersPerCompany[0]; + _selectedManager = selectedManagersPerCompany[0]; + } + } + + // 담당자 있는지 상태 업데이트 + void updateManagersState() { + hasManagers = + filteredManagers.isNotEmpty && + !(filteredManagers.length == 1 && filteredManagers[0] == '없음'); + } + + // 특정 인덱스의 담당자 상태 업데이트 + void updateManagersStateAtIndex(int index) { + hasManagersPerCompany[index] = + filteredManagersPerCompany[index].isNotEmpty && + !(filteredManagersPerCompany[index].length == 1 && + filteredManagersPerCompany[index][0] == '없음'); + } + + // 출고 회사 추가 + void addCompany() { + // 이미 선택된 회사 제외한 리스트 생성 + List availableCompanies = List.from(companies); + for (String? company in selectedCompanies) { + if (company != null) { + availableCompanies.remove(company); + } + } + + // 새 드롭다운 추가 + selectedCompanies.add(null); + availableCompaniesPerDropdown.add(availableCompanies); + selectedManagersPerCompany.add(null); + filteredManagersPerCompany.add(List.from(managers)); + hasManagersPerCompany.add(false); + } + + // 가능한 회사 목록 업데이트 + void updateAvailableCompanies() { + // 각 드롭다운에 대해 사용 가능한 회사 목록 업데이트 + for (int i = 0; i < selectedCompanies.length; i++) { + List availableCompanies = List.from(companies); + + // 이미 선택된 회사 제외 + for (int j = 0; j < selectedCompanies.length; j++) { + if (i != j && selectedCompanies[j] != null) { + availableCompanies.remove(selectedCompanies[j]); + } + } + + availableCompaniesPerDropdown[i] = availableCompanies; + } + } + + // 선택 장비로 초기화 + void initializeWithSelectedEquipment(Equipment equipment) { + manufacturer = equipment.manufacturer; + name = equipment.name; + category = equipment.category; + subCategory = equipment.subCategory; + subSubCategory = equipment.subSubCategory; + serialNumber = equipment.serialNumber ?? ''; + barcode = equipment.barcode ?? ''; + quantity = equipment.quantity; + hasSerialNumber = serialNumber.isNotEmpty; + inDate = equipment.inDate; + remarkController.text = equipment.remark ?? ''; + } + + // 회사/지점 표시 이름을 통해 CompanyBranchInfo 객체 찾기 + CompanyBranchInfo? _findCompanyInfoByDisplayName(String displayName) { + for (var info in companiesWithBranches) { + if (info.name == displayName) { + return info; + } + } + return null; + } + + // 출고 정보 저장 (UI에서 호출) + void saveEquipmentOut(Function(String) onSuccess, Function(String) onError) { + if (formKey.currentState?.validate() != true) { + onError('폼 유효성 검사 실패'); + return; + } + formKey.currentState?.save(); + + // 선택된 회사가 없는지 확인 + bool hasAnySelectedCompany = selectedCompanies.any( + (company) => company != null, + ); + if (!hasAnySelectedCompany) { + onError('최소 하나의 출고 회사를 선택해주세요'); + return; + } + + // 기존 방식으로 첫 번째 회사 정보 처리 + String? companyName; + if (selectedCompanies.isNotEmpty && selectedCompanies[0] != null) { + CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName( + selectedCompanies[0]!, + ); + if (companyInfo != null) { + companyName = + companyInfo.isMainCompany + ? companyInfo + .originalName // 본사인 경우 회사 원래 이름 + : "${companyInfo.originalName} (${companyInfo.branchId})"; // 지점인 경우 지점 정보 포함 + } else { + companyName = selectedCompanies[0]!.replaceAll( + RegExp(r' \(.*\)\$'), + '', + ); + } + } else { + onError('최소 하나의 출고 회사를 선택해주세요'); + return; + } + + if (isEditMode && equipmentOutId != null) { + final equipmentOut = dataService.getEquipmentOutById(equipmentOutId!); + if (equipmentOut != null) { + final updatedEquipmentOut = EquipmentOut( + id: equipmentOut.id, + equipment: equipmentOut.equipment, + outDate: equipmentOut.outDate, + status: returnType == '재입고' ? 'I' : 'R', + company: companyName, + manager: equipmentOut.manager, + license: equipmentOut.license, + returnDate: returnDate, + returnType: returnType, + remark: remarkController.text.trim(), + ); + dataService.updateEquipmentOut(updatedEquipmentOut); + onSuccess('장비 출고 상태 변경 완료'); + } else { + onError('출고 정보를 찾을 수 없습니다'); + } + } else { + if (selectedEquipments != null && selectedEquipments!.isNotEmpty) { + // 여러 회사에 각각 출고 처리 + List successCompanies = []; + + // 선택된 모든 회사에 대해 출고 처리 + for (int i = 0; i < selectedCompanies.length; i++) { + if (selectedCompanies[i] == null) continue; + + CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName( + selectedCompanies[i]!, + ); + String curCompanyName; + + if (companyInfo != null) { + curCompanyName = + companyInfo.isMainCompany + ? companyInfo + .originalName // 본사인 경우 회사 원래 이름 + : "${companyInfo.originalName} (${companyInfo.branchId})"; // 지점인 경우 지점 정보 포함 + } else { + curCompanyName = selectedCompanies[i]!.replaceAll( + RegExp(r' \(.*\)\$'), + '', + ); + } + + String? curManager = selectedManagersPerCompany[i]; + + if (curManager == null || curManager == '없음') { + // 담당자 없는 회사는 건너뛰기 + continue; + } + + // 해당 회사에 모든 장비 출고 처리 + for (final equipmentData in selectedEquipments!) { + final equipment = equipmentData['equipment'] as Equipment; + final equipmentInId = equipmentData['equipmentInId'] as int; + final newEquipmentOut = EquipmentOut( + equipment: equipment, + outDate: outDate, + company: curCompanyName, + manager: curManager, + license: selectedLicense, + remark: remarkController.text.trim(), + ); + dataService.changeEquipmentStatus(equipmentInId, newEquipmentOut); + } + + successCompanies.add(companyInfo?.name ?? curCompanyName); + } + + if (successCompanies.isEmpty) { + onError('모든 회사에 담당자가 없어 출고 처리할 수 없습니다'); + } else { + onSuccess('${successCompanies.join(", ")} 회사로 다중 장비 출고 처리 완료'); + } + } else if (selectedEquipmentInId != null) { + final equipment = Equipment( + manufacturer: manufacturer, + name: name, + category: category, + subCategory: subCategory, + subSubCategory: subSubCategory, + serialNumber: (hasSerialNumber) ? serialNumber : null, + barcode: barcode.isNotEmpty ? barcode : null, + quantity: quantity, + inDate: inDate, + remark: remarkController.text.trim(), + ); + + // 선택된 모든 회사에 대해 출고 처리 + List successCompanies = []; + + for (int i = 0; i < selectedCompanies.length; i++) { + if (selectedCompanies[i] == null) continue; + + CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName( + selectedCompanies[i]!, + ); + String curCompanyName; + + if (companyInfo != null) { + curCompanyName = + companyInfo.isMainCompany + ? companyInfo + .originalName // 본사인 경우 회사 원래 이름 + : "${companyInfo.originalName} (${companyInfo.branchId})"; // 지점인 경우 지점 정보 포함 + } else { + curCompanyName = selectedCompanies[i]!.replaceAll( + RegExp(r' \(.*\)\$'), + '', + ); + } + + String? curManager = selectedManagersPerCompany[i]; + + if (curManager == null || curManager == '없음') { + // 담당자 없는 회사는 건너뛰기 + continue; + } + + final newEquipmentOut = EquipmentOut( + equipment: equipment, + outDate: outDate, + company: curCompanyName, + manager: curManager, + license: selectedLicense, + remark: remarkController.text.trim(), + ); + dataService.changeEquipmentStatus( + selectedEquipmentInId!, + newEquipmentOut, + ); + + successCompanies.add(companyInfo?.name ?? curCompanyName); + break; // 한 장비는 한 회사에만 출고 + } + + if (successCompanies.isEmpty) { + onError('모든 회사에 담당자가 없어 출고 처리할 수 없습니다'); + } else { + onSuccess('${successCompanies.join(", ")} 회사로 장비 출고 처리 완료'); + } + } else { + final equipment = Equipment( + manufacturer: manufacturer, + name: name, + category: category, + subCategory: subCategory, + subSubCategory: subSubCategory, + serialNumber: null, + barcode: null, + quantity: 1, + inDate: inDate, + remark: remarkController.text.trim(), + ); + + // 선택된 모든 회사에 대해 출고 처리 + List successCompanies = []; + + for (int i = 0; i < selectedCompanies.length; i++) { + if (selectedCompanies[i] == null) continue; + + CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName( + selectedCompanies[i]!, + ); + String curCompanyName; + + if (companyInfo != null) { + curCompanyName = + companyInfo.isMainCompany + ? companyInfo + .originalName // 본사인 경우 회사 원래 이름 + : "${companyInfo.originalName} (${companyInfo.branchId})"; // 지점인 경우 지점 정보 포함 + } else { + curCompanyName = selectedCompanies[i]!.replaceAll( + RegExp(r' \(.*\)\$'), + '', + ); + } + + String? curManager = selectedManagersPerCompany[i]; + + if (curManager == null || curManager == '없음') { + // 담당자 없는 회사는 건너뛰기 + continue; + } + + final newEquipmentOut = EquipmentOut( + equipment: equipment, + outDate: outDate, + company: curCompanyName, + manager: curManager, + license: selectedLicense, + remark: remarkController.text.trim(), + ); + dataService.addEquipmentOut(newEquipmentOut); + + successCompanies.add(companyInfo?.name ?? curCompanyName); + } + + if (successCompanies.isEmpty) { + onError('모든 회사에 담당자가 없어 출고 처리할 수 없습니다'); + } else { + onSuccess('${successCompanies.join(", ")} 회사로 새 출고 장비 추가 완료'); + } + } + } + } + + // 날짜 포맷 유틸리티 + String formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + void dispose() { + remarkController.dispose(); + } +} + +/// 회사 및 지점 정보를 저장하는 클래스 +class CompanyBranchInfo { + final int? id; + final String name; // 표시용 이름 (회사명 + 지점명 또는 회사명 (유형)) + final String originalName; // 원래 이름 (회사 본사명 또는 지점명) + final String? displayName; // UI에 표시할 이름 (주로 지점명) + final bool isMainCompany; // 본사인지 지점인지 구분 + final int? companyId; // 회사 ID + final int? branchId; // 지점 ID + final String? parentCompanyName; // 부모 회사명 (지점인 경우) + + CompanyBranchInfo({ + required this.id, + required this.name, + required this.originalName, + this.displayName, + required this.isMainCompany, + required this.companyId, + required this.branchId, + this.parentCompanyName, + }); +} diff --git a/lib/screens/equipment/equipment_in_form.dart b/lib/screens/equipment/equipment_in_form.dart new file mode 100644 index 0000000..fe020ae --- /dev/null +++ b/lib/screens/equipment/equipment_in_form.dart @@ -0,0 +1,2267 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +// import 'package:superport/models/equipment_unified_model.dart'; +import 'package:superport/screens/common/custom_widgets.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/services/mock_data_service.dart'; +import 'package:superport/utils/constants.dart'; +// import 'package:flutter_localizations/flutter_localizations.dart'; +// import 'package:superport/screens/equipment/widgets/autocomplete_text_field.dart'; +import 'controllers/equipment_in_form_controller.dart'; +// import 'package:superport/screens/common/widgets/category_autocomplete_field.dart'; +import 'package:superport/screens/common/widgets/autocomplete_dropdown_field.dart'; +import 'package:superport/screens/common/widgets/remark_input.dart'; + +class EquipmentInFormScreen extends StatefulWidget { + final int? equipmentInId; + + const EquipmentInFormScreen({super.key, this.equipmentInId}); + + @override + State createState() => _EquipmentInFormScreenState(); +} + +class _EquipmentInFormScreenState extends State { + late EquipmentInFormController _controller; + late FocusNode _manufacturerFocusNode; + late FocusNode _nameFieldFocusNode; + + // 구매처 드롭다운 오버레이 관련 + final LayerLink _partnerLayerLink = LayerLink(); + OverlayEntry? _partnerOverlayEntry; + final FocusNode _partnerFocusNode = FocusNode(); + late TextEditingController _partnerController; + + // 입고지 드롭다운 오버레이 관련 + final LayerLink _warehouseLayerLink = LayerLink(); + OverlayEntry? _warehouseOverlayEntry; + final FocusNode _warehouseFocusNode = FocusNode(); + late TextEditingController _warehouseController; + + // 제조사 드롭다운 오버레이 관련 + final LayerLink _manufacturerLayerLink = LayerLink(); + OverlayEntry? _manufacturerOverlayEntry; + late TextEditingController _manufacturerController; + + // 장비명 드롭다운 오버레이 관련 + final LayerLink _equipmentNameLayerLink = LayerLink(); + OverlayEntry? _equipmentNameOverlayEntry; + late TextEditingController _equipmentNameController; + + // 대분류 드롭다운 오버레이 관련 + final LayerLink _categoryLayerLink = LayerLink(); + OverlayEntry? _categoryOverlayEntry; + final FocusNode _categoryFocusNode = FocusNode(); + late TextEditingController _categoryController; + + // 중분류 드롭다운 오버레이 관련 + final LayerLink _subCategoryLayerLink = LayerLink(); + OverlayEntry? _subCategoryOverlayEntry; + final FocusNode _subCategoryFocusNode = FocusNode(); + late TextEditingController _subCategoryController; + + // 소분류 드롭다운 오버레이 관련 + final LayerLink _subSubCategoryLayerLink = LayerLink(); + OverlayEntry? _subSubCategoryOverlayEntry; + final FocusNode _subSubCategoryFocusNode = FocusNode(); + late TextEditingController _subSubCategoryController; + + // 프로그램적 입력란 변경 여부 플래그 + bool _isProgrammaticPartnerChange = false; + bool _isProgrammaticWarehouseChange = false; + bool _isProgrammaticManufacturerChange = false; + bool _isProgrammaticEquipmentNameChange = false; + bool _isProgrammaticCategoryChange = false; + bool _isProgrammaticSubCategoryChange = false; + bool _isProgrammaticSubSubCategoryChange = false; + + // 입력란의 정확한 위치를 위한 GlobalKey + final GlobalKey _partnerFieldKey = GlobalKey(); + final GlobalKey _warehouseFieldKey = GlobalKey(); + final GlobalKey _manufacturerFieldKey = GlobalKey(); + final GlobalKey _equipmentNameFieldKey = GlobalKey(); + final GlobalKey _categoryFieldKey = GlobalKey(); + final GlobalKey _subCategoryFieldKey = GlobalKey(); + final GlobalKey _subSubCategoryFieldKey = GlobalKey(); + + // 자동완성 후보(입력값과 가장 근접한 파트너사) 계산 함수 + String? _getAutocompleteSuggestion(String input) { + if (input.isEmpty) return null; + // 입력값으로 시작하는 후보 중 가장 짧은 것 + final lower = input.toLowerCase(); + final match = _controller.partnerCompanies.firstWhere( + (c) => c.toLowerCase().startsWith(lower), + orElse: () => '', + ); + return match.isNotEmpty && match.length > input.length ? match : null; + } + + // 자동완성 후보(입력값과 가장 근접한 입고지) 계산 함수 + String? _getWarehouseAutocompleteSuggestion(String input) { + if (input.isEmpty) return null; + // 입력값으로 시작하는 후보 중 가장 짧은 것 + final lower = input.toLowerCase(); + final match = _controller.warehouseLocations.firstWhere( + (c) => c.toLowerCase().startsWith(lower), + orElse: () => '', + ); + return match.isNotEmpty && match.length > input.length ? match : null; + } + + // 자동완성 후보(입력값과 가장 근접한 제조사) 계산 함수 + String? _getManufacturerAutocompleteSuggestion(String input) { + if (input.isEmpty) return null; + // 입력값으로 시작하는 후보 중 가장 짧은 것 + final lower = input.toLowerCase(); + final match = _controller.manufacturers.firstWhere( + (c) => c.toLowerCase().startsWith(lower), + orElse: () => '', + ); + return match.isNotEmpty && match.length > input.length ? match : null; + } + + // 자동완성 후보(입력값과 가장 근접한 장비명) 계산 함수 + String? _getEquipmentNameAutocompleteSuggestion(String input) { + if (input.isEmpty) return null; + // 입력값으로 시작하는 후보 중 가장 짧은 것 + final lower = input.toLowerCase(); + final match = _controller.equipmentNames.firstWhere( + (c) => c.toLowerCase().startsWith(lower), + orElse: () => '', + ); + return match.isNotEmpty && match.length > input.length ? match : null; + } + + // 자동완성 후보(입력값과 가장 근접한 대분류) 계산 함수 + String? _getCategoryAutocompleteSuggestion(String input) { + if (input.isEmpty) return null; + // 입력값으로 시작하는 후보 중 가장 짧은 것 + final lower = input.toLowerCase(); + final match = _controller.categories.firstWhere( + (c) => c.toLowerCase().startsWith(lower), + orElse: () => '', + ); + return match.isNotEmpty && match.length > input.length ? match : null; + } + + // 자동완성 후보(입력값과 가장 근접한 중분류) 계산 함수 + String? _getSubCategoryAutocompleteSuggestion(String input) { + if (input.isEmpty) return null; + // 입력값으로 시작하는 후보 중 가장 짧은 것 + final lower = input.toLowerCase(); + final match = _controller.subCategories.firstWhere( + (c) => c.toLowerCase().startsWith(lower), + orElse: () => '', + ); + return match.isNotEmpty && match.length > input.length ? match : null; + } + + // 자동완성 후보(입력값과 가장 근접한 소분류) 계산 함수 + String? _getSubSubCategoryAutocompleteSuggestion(String input) { + if (input.isEmpty) return null; + // 입력값으로 시작하는 후보 중 가장 짧은 것 + final lower = input.toLowerCase(); + final match = _controller.subSubCategories.firstWhere( + (c) => c.toLowerCase().startsWith(lower), + orElse: () => '', + ); + return match.isNotEmpty && match.length > input.length ? match : null; + } + + @override + void initState() { + super.initState(); + _controller = EquipmentInFormController( + dataService: MockDataService(), + equipmentInId: widget.equipmentInId, + ); + _manufacturerFocusNode = FocusNode(); + _nameFieldFocusNode = FocusNode(); + _partnerController = TextEditingController( + text: _controller.partnerCompany ?? '', + ); + + // 추가 컨트롤러 초기화 + _warehouseController = TextEditingController( + text: _controller.warehouseLocation ?? '', + ); + + _manufacturerController = TextEditingController( + text: _controller.manufacturer, + ); + + _equipmentNameController = TextEditingController(text: _controller.name); + + _categoryController = TextEditingController(text: _controller.category); + + _subCategoryController = TextEditingController( + text: _controller.subCategory, + ); + + _subSubCategoryController = TextEditingController( + text: _controller.subSubCategory, + ); + + // 포커스 변경 리스너 추가 + _partnerFocusNode.addListener(_onPartnerFocusChange); + _warehouseFocusNode.addListener(_onWarehouseFocusChange); + _manufacturerFocusNode.addListener(_onManufacturerFocusChange); + _nameFieldFocusNode.addListener(_onNameFieldFocusChange); + _categoryFocusNode.addListener(_onCategoryFocusChange); + _subCategoryFocusNode.addListener(_onSubCategoryFocusChange); + _subSubCategoryFocusNode.addListener(_onSubSubCategoryFocusChange); + } + + @override + void dispose() { + _manufacturerFocusNode.dispose(); + _nameFieldFocusNode.dispose(); + _partnerOverlayEntry?.remove(); + _partnerFocusNode.dispose(); + _partnerController.dispose(); + + // 추가 리소스 정리 + _warehouseOverlayEntry?.remove(); + _warehouseFocusNode.dispose(); + _warehouseController.dispose(); + + _manufacturerOverlayEntry?.remove(); + _manufacturerController.dispose(); + + _equipmentNameOverlayEntry?.remove(); + _equipmentNameController.dispose(); + + _categoryOverlayEntry?.remove(); + _categoryFocusNode.dispose(); + _categoryController.dispose(); + + _subCategoryOverlayEntry?.remove(); + _subCategoryFocusNode.dispose(); + _subCategoryController.dispose(); + + _subSubCategoryOverlayEntry?.remove(); + _subSubCategoryFocusNode.dispose(); + _subSubCategoryController.dispose(); + + super.dispose(); + } + + // 포커스 변경 리스너 함수들 + void _onPartnerFocusChange() { + if (!_partnerFocusNode.hasFocus) { + // 포커스가 벗어나면 드롭다운 닫기 + _removePartnerDropdown(); + } else { + // 다른 모든 드롭다운 닫기 + _removeOtherDropdowns(_partnerOverlayEntry); + } + } + + void _onWarehouseFocusChange() { + if (!_warehouseFocusNode.hasFocus) { + // 포커스가 벗어나면 드롭다운 닫기 + _removeWarehouseDropdown(); + } else { + // 다른 모든 드롭다운 닫기 + _removeOtherDropdowns(_warehouseOverlayEntry); + } + } + + void _onManufacturerFocusChange() { + if (!_manufacturerFocusNode.hasFocus) { + // 포커스가 벗어나면 드롭다운 닫기 + _removeManufacturerDropdown(); + } else { + // 다른 모든 드롭다운 닫기 + _removeOtherDropdowns(_manufacturerOverlayEntry); + } + } + + void _onNameFieldFocusChange() { + if (!_nameFieldFocusNode.hasFocus) { + // 포커스가 벗어나면 드롭다운 닫기 + _removeEquipmentNameDropdown(); + } else { + // 다른 모든 드롭다운 닫기 + _removeOtherDropdowns(_equipmentNameOverlayEntry); + } + } + + void _onCategoryFocusChange() { + if (!_categoryFocusNode.hasFocus) { + // 포커스가 벗어나면 드롭다운 닫기 + _removeCategoryDropdown(); + } else { + // 다른 모든 드롭다운 닫기 + _removeOtherDropdowns(_categoryOverlayEntry); + } + } + + void _onSubCategoryFocusChange() { + if (!_subCategoryFocusNode.hasFocus) { + // 포커스가 벗어나면 드롭다운 닫기 + _removeSubCategoryDropdown(); + } else { + // 다른 모든 드롭다운 닫기 + _removeOtherDropdowns(_subCategoryOverlayEntry); + } + } + + void _onSubSubCategoryFocusChange() { + if (!_subSubCategoryFocusNode.hasFocus) { + // 포커스가 벗어나면 드롭다운 닫기 + _removeSubSubCategoryDropdown(); + } else { + // 다른 모든 드롭다운 닫기 + _removeOtherDropdowns(_subSubCategoryOverlayEntry); + } + } + + // 현재 포커스 필드 외의 다른 모든 드롭다운 제거 + void _removeOtherDropdowns(OverlayEntry? currentOverlay) { + // 모든 드롭다운 중 현재 오버레이를 제외한 나머지 닫기 + if (_partnerOverlayEntry != null && + _partnerOverlayEntry != currentOverlay) { + _removePartnerDropdown(); + } + if (_warehouseOverlayEntry != null && + _warehouseOverlayEntry != currentOverlay) { + _removeWarehouseDropdown(); + } + if (_manufacturerOverlayEntry != null && + _manufacturerOverlayEntry != currentOverlay) { + _removeManufacturerDropdown(); + } + if (_equipmentNameOverlayEntry != null && + _equipmentNameOverlayEntry != currentOverlay) { + _removeEquipmentNameDropdown(); + } + if (_categoryOverlayEntry != null && + _categoryOverlayEntry != currentOverlay) { + _removeCategoryDropdown(); + } + if (_subCategoryOverlayEntry != null && + _subCategoryOverlayEntry != currentOverlay) { + _removeSubCategoryDropdown(); + } + if (_subSubCategoryOverlayEntry != null && + _subSubCategoryOverlayEntry != currentOverlay) { + _removeSubSubCategoryDropdown(); + } + } + + void _saveEquipmentIn() { + if (_controller.save()) { + Navigator.pop(context, true); + } + } + + void _showPartnerDropdown() { + // 항상 기존 오버레이를 먼저 제거하여 중복 생성 방지 + _removePartnerDropdown(); + // 다른 모든 드롭다운 닫기 + _removeOtherDropdowns(_partnerOverlayEntry); + // 입력란의 정확한 RenderBox를 key로부터 참조 + final RenderBox renderBox = + _partnerFieldKey.currentContext!.findRenderObject() as RenderBox; + final size = renderBox.size; + print('[구매처:showPartnerDropdown] 드롭다운 표시, width=${size.width}'); + final itemsToShow = _controller.partnerCompanies; + print('[구매처:showPartnerDropdown] 드롭다운에 노출될 아이템: $itemsToShow'); + _partnerOverlayEntry = OverlayEntry( + builder: + (context) => Positioned( + width: size.width, + child: CompositedTransformFollower( + link: _partnerLayerLink, + showWhenUnlinked: false, + offset: const Offset(0, 45), + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(4), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + constraints: const BoxConstraints(maxHeight: 200), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...itemsToShow.map((item) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + print( + '[구매처:드롭다운아이템:클릭] 선택값: "$item" (길이: ${item.length})', + ); + if (item.isEmpty) { + print('[구매처:드롭다운아이템:클릭] 경고: 빈 값이 선택됨!'); + } + setState(() { + // 프로그램적 변경 시작 + _isProgrammaticPartnerChange = true; + print( + '[구매처:setState:드롭다운아이템] _controller.partnerCompany <- "$item"', + ); + _controller.partnerCompany = item; + print( + '[구매처:setState:드롭다운아이템] _partnerController.text <- "$item"', + ); + _partnerController.text = item; + }); + print( + '[구매처:드롭다운아이템:클릭] setState 이후 _partnerController.text=${_partnerController.text}, _controller.partnerCompany=${_controller.partnerCompany}', + ); + // 프로그램적 변경 종료 (다음 프레임에서) + WidgetsBinding.instance.addPostFrameCallback((_) { + _isProgrammaticPartnerChange = false; + }); + _removePartnerDropdown(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + width: double.infinity, + child: Text(item), + ), + ); + }).toList(), + ], + ), + ), + ), + ), + ), + ), + ); + Overlay.of(context).insert(_partnerOverlayEntry!); + } + + void _removePartnerDropdown() { + // 오버레이가 있으면 정상적으로 제거 및 null 처리 + if (_partnerOverlayEntry != null) { + _partnerOverlayEntry!.remove(); + _partnerOverlayEntry = null; + print('[구매처:removePartnerDropdown] 오버레이 제거 완료'); + } + } + + // 입고지 드롭다운 표시 함수 + void _showWarehouseDropdown() { + // 항상 기존 오버레이를 먼저 제거하여 중복 생성 방지 + _removeWarehouseDropdown(); + // 다른 모든 드롭다운 닫기 + _removeOtherDropdowns(_warehouseOverlayEntry); + // 입력란의 정확한 RenderBox를 key로부터 참조 + final RenderBox renderBox = + _warehouseFieldKey.currentContext!.findRenderObject() as RenderBox; + final size = renderBox.size; + print('[입고지:showWarehouseDropdown] 드롭다운 표시, width=${size.width}'); + final itemsToShow = _controller.warehouseLocations; + print('[입고지:showWarehouseDropdown] 드롭다운에 노출될 아이템: $itemsToShow'); + _warehouseOverlayEntry = OverlayEntry( + builder: + (context) => Positioned( + width: size.width, + child: CompositedTransformFollower( + link: _warehouseLayerLink, + showWhenUnlinked: false, + offset: const Offset(0, 45), + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(4), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + constraints: const BoxConstraints(maxHeight: 200), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...itemsToShow.map((item) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + print( + '[입고지:드롭다운아이템:클릭] 선택값: "$item" (길이: ${item.length})', + ); + if (item.isEmpty) { + print('[입고지:드롭다운아이템:클릭] 경고: 빈 값이 선택됨!'); + } + setState(() { + // 프로그램적 변경 시작 + _isProgrammaticWarehouseChange = true; + print( + '[입고지:setState:드롭다운아이템] _controller.warehouseLocation <- "$item"', + ); + _controller.warehouseLocation = item; + print( + '[입고지:setState:드롭다운아이템] _warehouseController.text <- "$item"', + ); + _warehouseController.text = item; + }); + print( + '[입고지:드롭다운아이템:클릭] setState 이후 _warehouseController.text=${_warehouseController.text}, _controller.warehouseLocation=${_controller.warehouseLocation}', + ); + // 프로그램적 변경 종료 (다음 프레임에서) + WidgetsBinding.instance.addPostFrameCallback((_) { + _isProgrammaticWarehouseChange = false; + }); + _removeWarehouseDropdown(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + width: double.infinity, + child: Text(item), + ), + ); + }).toList(), + ], + ), + ), + ), + ), + ), + ), + ); + Overlay.of(context).insert(_warehouseOverlayEntry!); + } + + void _removeWarehouseDropdown() { + // 오버레이가 있으면 정상적으로 제거 및 null 처리 + if (_warehouseOverlayEntry != null) { + _warehouseOverlayEntry!.remove(); + _warehouseOverlayEntry = null; + print('[입고지:removeWarehouseDropdown] 오버레이 제거 완료'); + } + } + + // 제조사 드롭다운 표시 함수 + void _showManufacturerDropdown() { + // 항상 기존 오버레이를 먼저 제거하여 중복 생성 방지 + _removeManufacturerDropdown(); + // 다른 모든 드롭다운 닫기 + _removeOtherDropdowns(_manufacturerOverlayEntry); + // 입력란의 정확한 RenderBox를 key로부터 참조 + final RenderBox renderBox = + _manufacturerFieldKey.currentContext!.findRenderObject() as RenderBox; + final size = renderBox.size; + print('[제조사:showManufacturerDropdown] 드롭다운 표시, width=${size.width}'); + final itemsToShow = _controller.manufacturers; + print('[제조사:showManufacturerDropdown] 드롭다운에 노출될 아이템: $itemsToShow'); + _manufacturerOverlayEntry = OverlayEntry( + builder: + (context) => Positioned( + width: size.width, + child: CompositedTransformFollower( + link: _manufacturerLayerLink, + showWhenUnlinked: false, + offset: const Offset(0, 45), + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(4), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + constraints: const BoxConstraints(maxHeight: 200), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...itemsToShow.map((item) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + print( + '[제조사:드롭다운아이템:클릭] 선택값: "$item" (길이: ${item.length})', + ); + if (item.isEmpty) { + print('[제조사:드롭다운아이템:클릭] 경고: 빈 값이 선택됨!'); + } + setState(() { + // 프로그램적 변경 시작 + _isProgrammaticManufacturerChange = true; + print( + '[제조사:setState:드롭다운아이템] _controller.manufacturer <- "$item"', + ); + _controller.manufacturer = item; + print( + '[제조사:setState:드롭다운아이템] _manufacturerController.text <- "$item"', + ); + _manufacturerController.text = item; + }); + print( + '[제조사:드롭다운아이템:클릭] setState 이후 _manufacturerController.text=${_manufacturerController.text}, _controller.manufacturer=${_controller.manufacturer}', + ); + // 프로그램적 변경 종료 (다음 프레임에서) + WidgetsBinding.instance.addPostFrameCallback((_) { + _isProgrammaticManufacturerChange = false; + }); + _removeManufacturerDropdown(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + width: double.infinity, + child: Text(item), + ), + ); + }).toList(), + ], + ), + ), + ), + ), + ), + ), + ); + Overlay.of(context).insert(_manufacturerOverlayEntry!); + } + + void _removeManufacturerDropdown() { + // 오버레이가 있으면 정상적으로 제거 및 null 처리 + if (_manufacturerOverlayEntry != null) { + _manufacturerOverlayEntry!.remove(); + _manufacturerOverlayEntry = null; + print('[제조사:removeManufacturerDropdown] 오버레이 제거 완료'); + } + } + + // 장비명 드롭다운 표시 함수 + void _showEquipmentNameDropdown() { + // 항상 기존 오버레이를 먼저 제거하여 중복 생성 방지 + _removeEquipmentNameDropdown(); + // 다른 모든 드롭다운 닫기 + _removeOtherDropdowns(_equipmentNameOverlayEntry); + // 입력란의 정확한 RenderBox를 key로부터 참조 + final RenderBox renderBox = + _equipmentNameFieldKey.currentContext!.findRenderObject() as RenderBox; + final size = renderBox.size; + print('[장비명:showEquipmentNameDropdown] 드롭다운 표시, width=${size.width}'); + final itemsToShow = _controller.equipmentNames; + print('[장비명:showEquipmentNameDropdown] 드롭다운에 노출될 아이템: $itemsToShow'); + _equipmentNameOverlayEntry = OverlayEntry( + builder: + (context) => Positioned( + width: size.width, + child: CompositedTransformFollower( + link: _equipmentNameLayerLink, + showWhenUnlinked: false, + offset: const Offset(0, 45), + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(4), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + constraints: const BoxConstraints(maxHeight: 200), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...itemsToShow.map((item) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + print( + '[장비명:드롭다운아이템:클릭] 선택값: "$item" (길이: ${item.length})', + ); + if (item.isEmpty) { + print('[장비명:드롭다운아이템:클릭] 경고: 빈 값이 선택됨!'); + } + setState(() { + // 프로그램적 변경 시작 + _isProgrammaticEquipmentNameChange = true; + print( + '[장비명:setState:드롭다운아이템] _controller.name <- "$item"', + ); + _controller.name = item; + print( + '[장비명:setState:드롭다운아이템] _equipmentNameController.text <- "$item"', + ); + _equipmentNameController.text = item; + }); + print( + '[장비명:드롭다운아이템:클릭] setState 이후 _equipmentNameController.text=${_equipmentNameController.text}, _controller.name=${_controller.name}', + ); + // 프로그램적 변경 종료 (다음 프레임에서) + WidgetsBinding.instance.addPostFrameCallback((_) { + _isProgrammaticEquipmentNameChange = false; + }); + _removeEquipmentNameDropdown(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + width: double.infinity, + child: Text(item), + ), + ); + }).toList(), + ], + ), + ), + ), + ), + ), + ), + ); + Overlay.of(context).insert(_equipmentNameOverlayEntry!); + } + + void _removeEquipmentNameDropdown() { + // 오버레이가 있으면 정상적으로 제거 및 null 처리 + if (_equipmentNameOverlayEntry != null) { + _equipmentNameOverlayEntry!.remove(); + _equipmentNameOverlayEntry = null; + print('[장비명:removeEquipmentNameDropdown] 오버레이 제거 완료'); + } + } + + // 대분류 드롭다운 표시 함수 + void _showCategoryDropdown() { + // 항상 기존 오버레이를 먼저 제거하여 중복 생성 방지 + _removeCategoryDropdown(); + // 다른 모든 드롭다운 닫기 + _removeOtherDropdowns(_categoryOverlayEntry); + // 입력란의 정확한 RenderBox를 key로부터 참조 + final RenderBox renderBox = + _categoryFieldKey.currentContext!.findRenderObject() as RenderBox; + final size = renderBox.size; + print('[대분류:showCategoryDropdown] 드롭다운 표시, width=${size.width}'); + final itemsToShow = _controller.categories; + print('[대분류:showCategoryDropdown] 드롭다운에 노출될 아이템: $itemsToShow'); + _categoryOverlayEntry = OverlayEntry( + builder: + (context) => Positioned( + width: size.width, + child: CompositedTransformFollower( + link: _categoryLayerLink, + showWhenUnlinked: false, + offset: const Offset(0, 45), + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(4), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + constraints: const BoxConstraints(maxHeight: 200), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...itemsToShow.map((item) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + print( + '[대분류:드롭다운아이템:클릭] 선택값: "$item" (길이: ${item.length})', + ); + if (item.isEmpty) { + print('[대분류:드롭다운아이템:클릭] 경고: 빈 값이 선택됨!'); + } + setState(() { + // 프로그램적 변경 시작 + _isProgrammaticCategoryChange = true; + print( + '[대분류:setState:드롭다운아이템] _controller.category <- "$item"', + ); + _controller.category = item; + print( + '[대분류:setState:드롭다운아이템] _categoryController.text <- "$item"', + ); + _categoryController.text = item; + }); + print( + '[대분류:드롭다운아이템:클릭] setState 이후 _categoryController.text=${_categoryController.text}, _controller.category=${_controller.category}', + ); + // 프로그램적 변경 종료 (다음 프레임에서) + WidgetsBinding.instance.addPostFrameCallback((_) { + _isProgrammaticCategoryChange = false; + }); + _removeCategoryDropdown(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + width: double.infinity, + child: Text(item), + ), + ); + }).toList(), + ], + ), + ), + ), + ), + ), + ), + ); + Overlay.of(context).insert(_categoryOverlayEntry!); + } + + void _removeCategoryDropdown() { + // 오버레이가 있으면 정상적으로 제거 및 null 처리 + if (_categoryOverlayEntry != null) { + _categoryOverlayEntry!.remove(); + _categoryOverlayEntry = null; + print('[대분류:removeCategoryDropdown] 오버레이 제거 완료'); + } + } + + // 중분류 드롭다운 표시 함수 + void _showSubCategoryDropdown() { + // 항상 기존 오버레이를 먼저 제거하여 중복 생성 방지 + _removeSubCategoryDropdown(); + // 다른 모든 드롭다운 닫기 + _removeOtherDropdowns(_subCategoryOverlayEntry); + // 입력란의 정확한 RenderBox를 key로부터 참조 + final RenderBox renderBox = + _subCategoryFieldKey.currentContext!.findRenderObject() as RenderBox; + final size = renderBox.size; + print('[중분류:showSubCategoryDropdown] 드롭다운 표시, width=${size.width}'); + final itemsToShow = _controller.subCategories; + print('[중분류:showSubCategoryDropdown] 드롭다운에 노출될 아이템: $itemsToShow'); + _subCategoryOverlayEntry = OverlayEntry( + builder: + (context) => Positioned( + width: size.width, + child: CompositedTransformFollower( + link: _subCategoryLayerLink, + showWhenUnlinked: false, + offset: const Offset(0, 45), + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(4), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + constraints: const BoxConstraints(maxHeight: 200), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...itemsToShow.map((item) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + print( + '[중분류:드롭다운아이템:클릭] 선택값: "$item" (길이: ${item.length})', + ); + if (item.isEmpty) { + print('[중분류:드롭다운아이템:클릭] 경고: 빈 값이 선택됨!'); + } + setState(() { + // 프로그램적 변경 시작 + _isProgrammaticSubCategoryChange = true; + print( + '[중분류:setState:드롭다운아이템] _controller.subCategory <- "$item"', + ); + _controller.subCategory = item; + print( + '[중분류:setState:드롭다운아이템] _subCategoryController.text <- "$item"', + ); + _subCategoryController.text = item; + }); + print( + '[중분류:드롭다운아이템:클릭] setState 이후 _subCategoryController.text=${_subCategoryController.text}, _controller.subCategory=${_controller.subCategory}', + ); + // 프로그램적 변경 종료 (다음 프레임에서) + WidgetsBinding.instance.addPostFrameCallback((_) { + _isProgrammaticSubCategoryChange = false; + }); + _removeSubCategoryDropdown(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + width: double.infinity, + child: Text(item), + ), + ); + }).toList(), + ], + ), + ), + ), + ), + ), + ), + ); + Overlay.of(context).insert(_subCategoryOverlayEntry!); + } + + void _removeSubCategoryDropdown() { + // 오버레이가 있으면 정상적으로 제거 및 null 처리 + if (_subCategoryOverlayEntry != null) { + _subCategoryOverlayEntry!.remove(); + _subCategoryOverlayEntry = null; + print('[중분류:removeSubCategoryDropdown] 오버레이 제거 완료'); + } + } + + // 소분류 드롭다운 표시 함수 + void _showSubSubCategoryDropdown() { + // 항상 기존 오버레이를 먼저 제거하여 중복 생성 방지 + _removeSubSubCategoryDropdown(); + // 다른 모든 드롭다운 닫기 + _removeOtherDropdowns(_subSubCategoryOverlayEntry); + // 입력란의 정확한 RenderBox를 key로부터 참조 + final RenderBox renderBox = + _subSubCategoryFieldKey.currentContext!.findRenderObject() as RenderBox; + final size = renderBox.size; + print('[소분류:showSubSubCategoryDropdown] 드롭다운 표시, width=${size.width}'); + final itemsToShow = _controller.subSubCategories; + print('[소분류:showSubSubCategoryDropdown] 드롭다운에 노출될 아이템: $itemsToShow'); + _subSubCategoryOverlayEntry = OverlayEntry( + builder: + (context) => Positioned( + width: size.width, + child: CompositedTransformFollower( + link: _subSubCategoryLayerLink, + showWhenUnlinked: false, + offset: const Offset(0, 45), + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(4), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + constraints: const BoxConstraints(maxHeight: 200), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...itemsToShow.map((item) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + print( + '[소분류:드롭다운아이템:클릭] 선택값: "$item" (길이: ${item.length})', + ); + if (item.isEmpty) { + print('[소분류:드롭다운아이템:클릭] 경고: 빈 값이 선택됨!'); + } + setState(() { + // 프로그램적 변경 시작 + _isProgrammaticSubSubCategoryChange = true; + print( + '[소분류:setState:드롭다운아이템] _controller.subSubCategory <- "$item"', + ); + _controller.subSubCategory = item; + print( + '[소분류:setState:드롭다운아이템] _subSubCategoryController.text <- "$item"', + ); + _subSubCategoryController.text = item; + }); + print( + '[소분류:드롭다운아이템:클릭] setState 이후 _subSubCategoryController.text=${_subSubCategoryController.text}, _controller.subSubCategory=${_controller.subSubCategory}', + ); + // 프로그램적 변경 종료 (다음 프레임에서) + WidgetsBinding.instance.addPostFrameCallback((_) { + _isProgrammaticSubSubCategoryChange = false; + }); + _removeSubSubCategoryDropdown(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + width: double.infinity, + child: Text(item), + ), + ); + }).toList(), + ], + ), + ), + ), + ), + ), + ), + ); + Overlay.of(context).insert(_subSubCategoryOverlayEntry!); + } + + void _removeSubSubCategoryDropdown() { + // 오버레이가 있으면 정상적으로 제거 및 null 처리 + if (_subSubCategoryOverlayEntry != null) { + _subSubCategoryOverlayEntry!.remove(); + _subSubCategoryOverlayEntry = null; + print('[소분류:removeSubSubCategoryDropdown] 오버레이 제거 완료'); + } + } + + @override + Widget build(BuildContext context) { + print( + '[구매처:build] _partnerController.text=${_partnerController.text}, _controller.partnerCompany=${_controller.partnerCompany}', + ); + final inputText = _partnerController.text; + final suggestion = _getAutocompleteSuggestion(inputText); + final showSuggestion = + suggestion != null && suggestion.length > inputText.length; + print( + '[구매처:autocomplete] 입력값: "$inputText", 자동완성 후보: "$suggestion", showSuggestion=$showSuggestion', + ); + return GestureDetector( + // 화면의 다른 곳을 탭하면 모든 드롭다운 닫기 + onTap: () { + // 현재 포커스된 위젯 포커스 해제 + FocusScope.of(context).unfocus(); + // 모든 드롭다운 닫기 + _removePartnerDropdown(); + _removeWarehouseDropdown(); + _removeManufacturerDropdown(); + _removeEquipmentNameDropdown(); + _removeCategoryDropdown(); + _removeSubCategoryDropdown(); + _removeSubSubCategoryDropdown(); + }, + child: Scaffold( + appBar: AppBar( + title: Text(_controller.isEditMode ? '장비 입고 수정' : '장비 입고 등록'), + ), + body: Form( + key: _controller.formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 기본 정보 섹션 + Text('기본 정보', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + // 장비 유형 선택 (라디오 버튼) + FormFieldWrapper( + label: '장비 유형', + isRequired: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: RadioListTile( + title: const Text( + '신제품', + style: TextStyle(fontSize: 14), + ), + value: EquipmentType.new_, + groupValue: _controller.equipmentType, + onChanged: (value) { + setState(() { + _controller.equipmentType = value!; + }); + }, + contentPadding: EdgeInsets.zero, + dense: true, + ), + ), + Expanded( + child: RadioListTile( + title: const Text( + '중고', + style: TextStyle(fontSize: 14), + ), + value: EquipmentType.used, + groupValue: _controller.equipmentType, + onChanged: (value) { + setState(() { + _controller.equipmentType = value!; + }); + }, + contentPadding: EdgeInsets.zero, + dense: true, + ), + ), + Expanded( + child: RadioListTile( + title: const Text( + '계약', + style: TextStyle(fontSize: 14), + ), + subtitle: const Text( + '(입고후 즉각 출고)', + style: TextStyle(fontSize: 11), + ), + value: EquipmentType.contract, + groupValue: _controller.equipmentType, + onChanged: (value) { + setState(() { + _controller.equipmentType = value!; + }); + }, + contentPadding: EdgeInsets.zero, + dense: true, + ), + ), + ], + ), + ], + ), + ), + // 1행: 구매처(파트너사), 입고지 + Row( + children: [ + Expanded( + child: FormFieldWrapper( + label: '구매처', + isRequired: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 입력란(CompositedTransformTarget으로 감싸기) + CompositedTransformTarget( + link: _partnerLayerLink, + child: TextFormField( + key: _partnerFieldKey, + controller: _partnerController, + focusNode: _partnerFocusNode, + decoration: InputDecoration( + labelText: '구매처', + hintText: '구매처를 입력 또는 선택하세요', + suffixIcon: IconButton( + icon: const Icon(Icons.arrow_drop_down), + onPressed: _showPartnerDropdown, + ), + ), + onChanged: (value) { + print('[구매처:onChanged] 입력값: "$value"'); + // 프로그램적 변경이면 무시 + if (_isProgrammaticPartnerChange) { + print('[구매처:onChanged] 프로그램적 변경이므로 무시'); + return; + } + setState(() { + print( + '[구매처:setState:onChanged] _controller.partnerCompany <- "$value"', + ); + _controller.partnerCompany = value; + }); + }, + onFieldSubmitted: (value) { + // 엔터 입력 시 자동완성 + print( + '[구매처:onFieldSubmitted] 엔터 입력됨, 입력값: "$value", 자동완성 후보: "$suggestion", showSuggestion=$showSuggestion', + ); + if (showSuggestion) { + setState(() { + print( + '[구매처:onFieldSubmitted] 자동완성 적용: "$suggestion"', + ); + _isProgrammaticPartnerChange = true; + _partnerController.text = suggestion!; + _controller.partnerCompany = suggestion; + // 커서를 맨 뒤로 이동 + _partnerController + .selection = TextSelection.collapsed( + offset: suggestion.length, + ); + print( + '[구매처:onFieldSubmitted] 커서 위치: ${_partnerController.selection.start}', + ); + }); + WidgetsBinding.instance + .addPostFrameCallback((_) { + _isProgrammaticPartnerChange = false; + }); + } + }, + ), + ), + // 입력란 아래에 자동완성 후보 전체를 더 작은 글씨로 명확하게 표시 + if (showSuggestion) + Padding( + padding: const EdgeInsets.only( + left: 12, + top: 2, + ), + child: Text( + suggestion!, + style: const TextStyle( + color: Color(0xFF1976D2), + fontWeight: FontWeight.bold, + fontSize: 13, // 더 작은 글씨 + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: FormFieldWrapper( + label: '입고지', + isRequired: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 입력란(CompositedTransformTarget으로 감싸기) + CompositedTransformTarget( + link: _warehouseLayerLink, + child: TextFormField( + key: _warehouseFieldKey, + controller: _warehouseController, + focusNode: _warehouseFocusNode, + decoration: InputDecoration( + labelText: '입고지', + hintText: '입고지를 입력 또는 선택하세요', + suffixIcon: IconButton( + icon: const Icon(Icons.arrow_drop_down), + onPressed: _showWarehouseDropdown, + ), + ), + onChanged: (value) { + print('[입고지:onChanged] 입력값: "$value"'); + // 프로그램적 변경이면 무시 + if (_isProgrammaticWarehouseChange) { + print('[입고지:onChanged] 프로그램적 변경이므로 무시'); + return; + } + setState(() { + print( + '[입고지:setState:onChanged] _controller.warehouseLocation <- "$value"', + ); + _controller.warehouseLocation = value; + }); + }, + onFieldSubmitted: (value) { + // 엔터 입력 시 자동완성 + final warehouseSuggestion = + _getWarehouseAutocompleteSuggestion( + value, + ); + final showWarehouseSuggestion = + warehouseSuggestion != null && + warehouseSuggestion.length > value.length; + print( + '[입고지:onFieldSubmitted] 엔터 입력됨, 입력값: "$value", 자동완성 후보: "$warehouseSuggestion", showWarehouseSuggestion=$showWarehouseSuggestion', + ); + if (showWarehouseSuggestion) { + setState(() { + print( + '[입고지:onFieldSubmitted] 자동완성 적용: "$warehouseSuggestion"', + ); + _isProgrammaticWarehouseChange = true; + _warehouseController.text = + warehouseSuggestion!; + _controller.warehouseLocation = + warehouseSuggestion; + // 커서를 맨 뒤로 이동 + _warehouseController + .selection = TextSelection.collapsed( + offset: warehouseSuggestion.length, + ); + print( + '[입고지:onFieldSubmitted] 커서 위치: ${_warehouseController.selection.start}', + ); + }); + WidgetsBinding.instance + .addPostFrameCallback((_) { + _isProgrammaticWarehouseChange = + false; + }); + } + }, + ), + ), + // 입력란 아래에 자동완성 후보 전체를 더 작은 글씨로 명확하게 표시 + if (_getWarehouseAutocompleteSuggestion( + _warehouseController.text, + ) != + null && + _getWarehouseAutocompleteSuggestion( + _warehouseController.text, + )!.length > + _warehouseController.text.length) + Padding( + padding: const EdgeInsets.only( + left: 12, + top: 2, + ), + child: Text( + _getWarehouseAutocompleteSuggestion( + _warehouseController.text, + )!, + style: const TextStyle( + color: Color(0xFF1976D2), + fontWeight: FontWeight.bold, + fontSize: 13, // 더 작은 글씨 + ), + ), + ), + ], + ), + ), + ), + ], + ), + // 2행: 제조사, 장비명 + Row( + children: [ + Expanded( + child: FormFieldWrapper( + label: '제조사', + isRequired: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 입력란(CompositedTransformTarget으로 감싸기) + CompositedTransformTarget( + link: _manufacturerLayerLink, + child: TextFormField( + key: _manufacturerFieldKey, + controller: _manufacturerController, + focusNode: _manufacturerFocusNode, + decoration: InputDecoration( + labelText: '제조사', + hintText: '제조사를 입력 또는 선택하세요', + suffixIcon: IconButton( + icon: const Icon(Icons.arrow_drop_down), + onPressed: _showManufacturerDropdown, + ), + ), + onChanged: (value) { + print('[제조사:onChanged] 입력값: "$value"'); + // 프로그램적 변경이면 무시 + if (_isProgrammaticManufacturerChange) { + print('[제조사:onChanged] 프로그램적 변경이므로 무시'); + return; + } + setState(() { + print( + '[제조사:setState:onChanged] _controller.manufacturer <- "$value"', + ); + _controller.manufacturer = value; + }); + }, + onFieldSubmitted: (value) { + // 엔터 입력 시 자동완성 + final manufacturerSuggestion = + _getManufacturerAutocompleteSuggestion( + value, + ); + final showManufacturerSuggestion = + manufacturerSuggestion != null && + manufacturerSuggestion.length > + value.length; + print( + '[제조사:onFieldSubmitted] 엔터 입력됨, 입력값: "$value", 자동완성 후보: "$manufacturerSuggestion", showManufacturerSuggestion=$showManufacturerSuggestion', + ); + if (showManufacturerSuggestion) { + setState(() { + print( + '[제조사:onFieldSubmitted] 자동완성 적용: "$manufacturerSuggestion"', + ); + _isProgrammaticManufacturerChange = true; + _manufacturerController.text = + manufacturerSuggestion!; + _controller.manufacturer = + manufacturerSuggestion; + // 커서를 맨 뒤로 이동 + _manufacturerController + .selection = TextSelection.collapsed( + offset: manufacturerSuggestion.length, + ); + print( + '[제조사:onFieldSubmitted] 커서 위치: ${_manufacturerController.selection.start}', + ); + }); + WidgetsBinding.instance + .addPostFrameCallback((_) { + _isProgrammaticManufacturerChange = + false; + }); + } + }, + ), + ), + // 입력란 아래에 자동완성 후보 전체를 더 작은 글씨로 명확하게 표시 + if (_getManufacturerAutocompleteSuggestion( + _manufacturerController.text, + ) != + null && + _getManufacturerAutocompleteSuggestion( + _manufacturerController.text, + )!.length > + _manufacturerController.text.length) + Padding( + padding: const EdgeInsets.only( + left: 12, + top: 2, + ), + child: Text( + _getManufacturerAutocompleteSuggestion( + _manufacturerController.text, + )!, + style: const TextStyle( + color: Color(0xFF1976D2), + fontWeight: FontWeight.bold, + fontSize: 13, // 더 작은 글씨 + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: FormFieldWrapper( + label: '장비명', + isRequired: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 입력란(CompositedTransformTarget으로 감싸기) + CompositedTransformTarget( + link: _equipmentNameLayerLink, + child: TextFormField( + key: _equipmentNameFieldKey, + controller: _equipmentNameController, + focusNode: _nameFieldFocusNode, + decoration: InputDecoration( + labelText: '장비명', + hintText: '장비명을 입력 또는 선택하세요', + suffixIcon: IconButton( + icon: const Icon(Icons.arrow_drop_down), + onPressed: _showEquipmentNameDropdown, + ), + ), + onChanged: (value) { + print('[장비명:onChanged] 입력값: "$value"'); + // 프로그램적 변경이면 무시 + if (_isProgrammaticEquipmentNameChange) { + print('[장비명:onChanged] 프로그램적 변경이므로 무시'); + return; + } + setState(() { + print( + '[장비명:setState:onChanged] _controller.name <- "$value"', + ); + _controller.name = value; + }); + }, + onFieldSubmitted: (value) { + // 엔터 입력 시 자동완성 + final equipmentNameSuggestion = + _getEquipmentNameAutocompleteSuggestion( + value, + ); + final showEquipmentNameSuggestion = + equipmentNameSuggestion != null && + equipmentNameSuggestion.length > + value.length; + print( + '[장비명:onFieldSubmitted] 엔터 입력됨, 입력값: "$value", 자동완성 후보: "$equipmentNameSuggestion", showEquipmentNameSuggestion=$showEquipmentNameSuggestion', + ); + if (showEquipmentNameSuggestion) { + setState(() { + print( + '[장비명:onFieldSubmitted] 자동완성 적용: "$equipmentNameSuggestion"', + ); + _isProgrammaticEquipmentNameChange = true; + _equipmentNameController.text = + equipmentNameSuggestion!; + _controller.name = + equipmentNameSuggestion; + // 커서를 맨 뒤로 이동 + _equipmentNameController + .selection = TextSelection.collapsed( + offset: equipmentNameSuggestion.length, + ); + print( + '[장비명:onFieldSubmitted] 커서 위치: ${_equipmentNameController.selection.start}', + ); + }); + WidgetsBinding.instance + .addPostFrameCallback((_) { + _isProgrammaticEquipmentNameChange = + false; + }); + } + }, + ), + ), + // 입력란 아래에 자동완성 후보 전체를 더 작은 글씨로 명확하게 표시 + if (_getEquipmentNameAutocompleteSuggestion( + _equipmentNameController.text, + ) != + null && + _getEquipmentNameAutocompleteSuggestion( + _equipmentNameController.text, + )!.length > + _equipmentNameController.text.length) + Padding( + padding: const EdgeInsets.only( + left: 12, + top: 2, + ), + child: Text( + _getEquipmentNameAutocompleteSuggestion( + _equipmentNameController.text, + )!, + style: const TextStyle( + color: Color(0xFF1976D2), + fontWeight: FontWeight.bold, + fontSize: 13, // 더 작은 글씨 + ), + ), + ), + ], + ), + ), + ), + ], + ), + // 3행: 대분류, 중분류, 소분류 + Row( + children: [ + Expanded( + child: FormFieldWrapper( + label: '대분류', + isRequired: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 입력란(CompositedTransformTarget으로 감싸기) + CompositedTransformTarget( + link: _categoryLayerLink, + child: TextFormField( + key: _categoryFieldKey, + controller: _categoryController, + focusNode: _categoryFocusNode, + decoration: InputDecoration( + labelText: '대분류', + hintText: '대분류를 입력 또는 선택하세요', + suffixIcon: IconButton( + icon: const Icon(Icons.arrow_drop_down), + onPressed: _showCategoryDropdown, + ), + ), + onChanged: (value) { + print('[대분류:onChanged] 입력값: "$value"'); + // 프로그램적 변경이면 무시 + if (_isProgrammaticCategoryChange) { + print('[대분류:onChanged] 프로그램적 변경이므로 무시'); + return; + } + setState(() { + print( + '[대분류:setState:onChanged] _controller.category <- "$value"', + ); + _controller.category = value; + }); + }, + onFieldSubmitted: (value) { + // 엔터 입력 시 자동완성 + final categorySuggestion = + _getCategoryAutocompleteSuggestion(value); + final showCategorySuggestion = + categorySuggestion != null && + categorySuggestion.length > value.length; + print( + '[대분류:onFieldSubmitted] 엔터 입력됨, 입력값: "$value", 자동완성 후보: "$categorySuggestion", showCategorySuggestion=$showCategorySuggestion', + ); + if (showCategorySuggestion) { + setState(() { + print( + '[대분류:onFieldSubmitted] 자동완성 적용: "$categorySuggestion"', + ); + _isProgrammaticCategoryChange = true; + _categoryController.text = + categorySuggestion!; + _controller.category = categorySuggestion; + // 커서를 맨 뒤로 이동 + _categoryController + .selection = TextSelection.collapsed( + offset: categorySuggestion.length, + ); + print( + '[대분류:onFieldSubmitted] 커서 위치: ${_categoryController.selection.start}', + ); + }); + WidgetsBinding.instance + .addPostFrameCallback((_) { + _isProgrammaticCategoryChange = false; + }); + } + }, + ), + ), + // 입력란 아래에 자동완성 후보 전체를 더 작은 글씨로 명확하게 표시 + if (_getCategoryAutocompleteSuggestion( + _categoryController.text, + ) != + null && + _getCategoryAutocompleteSuggestion( + _categoryController.text, + )!.length > + _categoryController.text.length) + Padding( + padding: const EdgeInsets.only( + left: 12, + top: 2, + ), + child: Text( + _getCategoryAutocompleteSuggestion( + _categoryController.text, + )!, + style: const TextStyle( + color: Color(0xFF1976D2), + fontWeight: FontWeight.bold, + fontSize: 13, // 더 작은 글씨 + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: FormFieldWrapper( + label: '중분류', + isRequired: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 입력란(CompositedTransformTarget으로 감싸기) + CompositedTransformTarget( + link: _subCategoryLayerLink, + child: TextFormField( + key: _subCategoryFieldKey, + controller: _subCategoryController, + focusNode: _subCategoryFocusNode, + decoration: InputDecoration( + labelText: '중분류', + hintText: '중분류를 입력 또는 선택하세요', + suffixIcon: IconButton( + icon: const Icon(Icons.arrow_drop_down), + onPressed: _showSubCategoryDropdown, + ), + ), + onChanged: (value) { + print('[중분류:onChanged] 입력값: "$value"'); + // 프로그램적 변경이면 무시 + if (_isProgrammaticSubCategoryChange) { + print('[중분류:onChanged] 프로그램적 변경이므로 무시'); + return; + } + setState(() { + print( + '[중분류:setState:onChanged] _controller.subCategory <- "$value"', + ); + _controller.subCategory = value; + }); + }, + onFieldSubmitted: (value) { + // 엔터 입력 시 자동완성 + final subCategorySuggestion = + _getSubCategoryAutocompleteSuggestion( + value, + ); + final showSubCategorySuggestion = + subCategorySuggestion != null && + subCategorySuggestion.length > + value.length; + print( + '[중분류:onFieldSubmitted] 엔터 입력됨, 입력값: "$value", 자동완성 후보: "$subCategorySuggestion", showSubCategorySuggestion=$showSubCategorySuggestion', + ); + if (showSubCategorySuggestion) { + setState(() { + print( + '[중분류:onFieldSubmitted] 자동완성 적용: "$subCategorySuggestion"', + ); + _isProgrammaticSubCategoryChange = true; + _subCategoryController.text = + subCategorySuggestion!; + _controller.subCategory = + subCategorySuggestion; + // 커서를 맨 뒤로 이동 + _subCategoryController + .selection = TextSelection.collapsed( + offset: subCategorySuggestion.length, + ); + print( + '[중분류:onFieldSubmitted] 커서 위치: ${_subCategoryController.selection.start}', + ); + }); + WidgetsBinding.instance + .addPostFrameCallback((_) { + _isProgrammaticSubCategoryChange = + false; + }); + } + }, + ), + ), + // 입력란 아래에 자동완성 후보 전체를 더 작은 글씨로 명확하게 표시 + if (_getSubCategoryAutocompleteSuggestion( + _subCategoryController.text, + ) != + null && + _getSubCategoryAutocompleteSuggestion( + _subCategoryController.text, + )!.length > + _subCategoryController.text.length) + Padding( + padding: const EdgeInsets.only( + left: 12, + top: 2, + ), + child: Text( + _getSubCategoryAutocompleteSuggestion( + _subCategoryController.text, + )!, + style: const TextStyle( + color: Color(0xFF1976D2), + fontWeight: FontWeight.bold, + fontSize: 13, // 더 작은 글씨 + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: FormFieldWrapper( + label: '소분류', + isRequired: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 입력란(CompositedTransformTarget으로 감싸기) + CompositedTransformTarget( + link: _subSubCategoryLayerLink, + child: TextFormField( + key: _subSubCategoryFieldKey, + controller: _subSubCategoryController, + focusNode: _subSubCategoryFocusNode, + decoration: InputDecoration( + labelText: '소분류', + hintText: '소분류를 입력 또는 선택하세요', + suffixIcon: IconButton( + icon: const Icon(Icons.arrow_drop_down), + onPressed: _showSubSubCategoryDropdown, + ), + ), + onChanged: (value) { + print('[소분류:onChanged] 입력값: "$value"'); + // 프로그램적 변경이면 무시 + if (_isProgrammaticSubSubCategoryChange) { + print('[소분류:onChanged] 프로그램적 변경이므로 무시'); + return; + } + setState(() { + print( + '[소분류:setState:onChanged] _controller.subSubCategory <- "$value"', + ); + _controller.subSubCategory = value; + }); + }, + onFieldSubmitted: (value) { + // 엔터 입력 시 자동완성 + final subSubCategorySuggestion = + _getSubSubCategoryAutocompleteSuggestion( + value, + ); + final showSubSubCategorySuggestion = + subSubCategorySuggestion != null && + subSubCategorySuggestion.length > + value.length; + print( + '[소분류:onFieldSubmitted] 엔터 입력됨, 입력값: "$value", 자동완성 후보: "$subSubCategorySuggestion", showSubSubCategorySuggestion=$showSubSubCategorySuggestion', + ); + if (showSubSubCategorySuggestion) { + setState(() { + print( + '[소분류:onFieldSubmitted] 자동완성 적용: "$subSubCategorySuggestion"', + ); + _isProgrammaticSubSubCategoryChange = + true; + _subSubCategoryController.text = + subSubCategorySuggestion!; + _controller.subSubCategory = + subSubCategorySuggestion; + // 커서를 맨 뒤로 이동 + _subSubCategoryController + .selection = TextSelection.collapsed( + offset: subSubCategorySuggestion.length, + ); + print( + '[소분류:onFieldSubmitted] 커서 위치: ${_subSubCategoryController.selection.start}', + ); + }); + WidgetsBinding.instance + .addPostFrameCallback((_) { + _isProgrammaticSubSubCategoryChange = + false; + }); + } + }, + ), + ), + // 입력란 아래에 자동완성 후보 전체를 더 작은 글씨로 명확하게 표시 + if (_getSubSubCategoryAutocompleteSuggestion( + _subSubCategoryController.text, + ) != + null && + _getSubSubCategoryAutocompleteSuggestion( + _subSubCategoryController.text, + )!.length > + _subSubCategoryController.text.length) + Padding( + padding: const EdgeInsets.only( + left: 12, + top: 2, + ), + child: Text( + _getSubSubCategoryAutocompleteSuggestion( + _subSubCategoryController.text, + )!, + style: const TextStyle( + color: Color(0xFF1976D2), + fontWeight: FontWeight.bold, + fontSize: 13, // 더 작은 글씨 + ), + ), + ), + ], + ), + ), + ), + ], + ), + // 시리얼 번호 유무 토글 + FormFieldWrapper( + label: '시리얼 번호', + isRequired: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Checkbox( + value: _controller.hasSerialNumber, + onChanged: (value) { + setState(() { + _controller.hasSerialNumber = value ?? true; + }); + }, + ), + const Text('시리얼 번호 있음'), + ], + ), + if (_controller.hasSerialNumber) + TextFormField( + initialValue: _controller.serialNumber, + decoration: const InputDecoration( + hintText: '시리얼 번호를 입력하세요', + ), + validator: (value) { + if (_controller.hasSerialNumber && + (value == null || value.isEmpty)) { + return '시리얼 번호를 입력해주세요'; + } + return null; + }, + onSaved: (value) { + _controller.serialNumber = value ?? ''; + }, + ), + ], + ), + ), + // 바코드 필드 + FormFieldWrapper( + label: '바코드', + isRequired: false, + child: TextFormField( + initialValue: _controller.barcode, + decoration: const InputDecoration( + hintText: '바코드를 입력하세요 (선택사항)', + ), + onSaved: (value) { + _controller.barcode = value ?? ''; + }, + ), + ), + // 수량 필드 + FormFieldWrapper( + label: '수량', + isRequired: true, + child: TextFormField( + initialValue: _controller.quantity.toString(), + decoration: const InputDecoration(hintText: '수량을 입력하세요'), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + validator: (value) { + if (value == null || value.isEmpty) { + return '수량을 입력해주세요'; + } + if (int.tryParse(value) == null || + int.parse(value) <= 0) { + return '유효한 수량을 입력해주세요'; + } + return null; + }, + onSaved: (value) { + _controller.quantity = int.tryParse(value ?? '1') ?? 1; + }, + ), + ), + // 입고일 필드 + FormFieldWrapper( + label: '입고일', + isRequired: true, + child: InkWell( + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _controller.inDate, + firstDate: DateTime(2000), + lastDate: DateTime.now(), + ); + if (picked != null && picked != _controller.inDate) { + setState(() { + _controller.inDate = picked; + // 입고일 변경 시 워런티 시작일도 같이 변경 + _controller.warrantyStartDate = picked; + }); + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 15, + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${_controller.inDate.year}-${_controller.inDate.month.toString().padLeft(2, '0')}-${_controller.inDate.day.toString().padLeft(2, '0')}', + style: AppThemeTailwind.bodyStyle, + ), + const Icon(Icons.calendar_today, size: 20), + ], + ), + ), + ), + ), + + // 워런티 정보 섹션 + const SizedBox(height: 16), + Text('워런티 정보', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + + // 워런티 필드들을 1행으로 통합 (전체 너비 사용) + SizedBox( + width: double.infinity, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 워런티 라이센스 + Expanded( + flex: 2, + child: FormFieldWrapper( + label: '워런티 라이센스', + isRequired: false, + child: TextFormField( + initialValue: _controller.warrantyLicense ?? '', + decoration: const InputDecoration( + hintText: '워런티 라이센스명을 입력하세요', + ), + onChanged: (value) { + _controller.warrantyLicense = value; + }, + ), + ), + ), + const SizedBox(width: 12), + + // 워런티 코드 입력란 추가 + Expanded( + flex: 2, + child: FormFieldWrapper( + label: '워런티 코드', + isRequired: false, + child: TextFormField( + initialValue: _controller.warrantyCode ?? '', + decoration: const InputDecoration( + hintText: '워런티 코드를 입력하세요', + ), + onChanged: (value) { + _controller.warrantyCode = value; + }, + ), + ), + ), + const SizedBox(width: 12), + + // 워런티 시작일 + Expanded( + flex: 1, + child: FormFieldWrapper( + label: '시작일', + isRequired: false, + child: InkWell( + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _controller.warrantyStartDate, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null && + picked != _controller.warrantyStartDate) { + setState(() { + _controller.warrantyStartDate = picked; + }); + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 15, + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + '${_controller.warrantyStartDate.year}-${_controller.warrantyStartDate.month.toString().padLeft(2, '0')}-${_controller.warrantyStartDate.day.toString().padLeft(2, '0')}', + style: AppThemeTailwind.bodyStyle, + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.calendar_today, size: 16), + ], + ), + ), + ), + ), + ), + const SizedBox(width: 12), + + // 워런티 종료일 + Expanded( + flex: 1, + child: FormFieldWrapper( + label: '종료일', + isRequired: false, + child: InkWell( + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _controller.warrantyEndDate, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null && + picked != _controller.warrantyEndDate) { + setState(() { + _controller.warrantyEndDate = picked; + }); + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 15, + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + '${_controller.warrantyEndDate.year}-${_controller.warrantyEndDate.month.toString().padLeft(2, '0')}-${_controller.warrantyEndDate.day.toString().padLeft(2, '0')}', + style: AppThemeTailwind.bodyStyle, + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.calendar_today, size: 16), + ], + ), + ), + ), + ), + ), + const SizedBox(width: 12), + + // 워런티 기간 요약 + Expanded( + flex: 1, + child: FormFieldWrapper( + label: '워런티 기간', + isRequired: false, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 15, + ), + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(4), + ), + alignment: Alignment.centerLeft, + child: Text( + ' ${_controller.getWarrantyPeriodSummary()}', + style: TextStyle( + color: Colors.grey.shade700, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ], + ), + ), + + // 비고 입력란 추가 + const SizedBox(height: 16), + FormFieldWrapper( + label: '비고', + isRequired: false, + child: RemarkInput( + controller: _controller.remarkController, + hint: '비고를 입력하세요', + minLines: 4, + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _saveEquipmentIn, + style: AppThemeTailwind.primaryButtonStyle, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + _controller.isEditMode ? '수정하기' : '등록하기', + style: const TextStyle(fontSize: 16), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/equipment/equipment_list.dart b/lib/screens/equipment/equipment_list.dart new file mode 100644 index 0000000..73abc84 --- /dev/null +++ b/lib/screens/equipment/equipment_list.dart @@ -0,0 +1,696 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/equipment/controllers/equipment_list_controller.dart'; +import 'package:superport/screens/equipment/widgets/equipment_table.dart'; +import 'package:superport/utils/equipment_display_helper.dart'; +import 'package:superport/services/mock_data_service.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/screens/common/main_layout.dart'; +import 'package:superport/utils/constants.dart'; +import 'package:superport/models/equipment_unified_model.dart'; +import 'package:superport/screens/common/widgets/pagination.dart'; + +// 장비 목록 화면 (UI만 담당, 상태/로직/헬퍼/위젯 분리) +class EquipmentListScreen extends StatefulWidget { + final String currentRoute; + const EquipmentListScreen({super.key, this.currentRoute = Routes.equipment}); + + @override + State createState() => _EquipmentListScreenState(); +} + +class _EquipmentListScreenState extends State { + late final EquipmentListController _controller; + bool _showDetailedColumns = true; + final ScrollController _horizontalScrollController = ScrollController(); + final ScrollController _verticalScrollController = ScrollController(); + int _currentPage = 1; + final int _pageSize = 10; + String _searchKeyword = ''; + String _appliedSearchKeyword = ''; + + @override + void initState() { + super.initState(); + _controller = EquipmentListController(dataService: MockDataService()); + _controller.loadData(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _adjustColumnsForScreenSize(); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _setDefaultFilterByRoute(); + } + + @override + void didUpdateWidget(EquipmentListScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.currentRoute != widget.currentRoute) { + _setDefaultFilterByRoute(); + } + } + + @override + void dispose() { + _horizontalScrollController.dispose(); + _verticalScrollController.dispose(); + super.dispose(); + } + + // 라우트에 따라 기본 필터 설정 + void _setDefaultFilterByRoute() { + String? newFilter; + if (widget.currentRoute == Routes.equipmentInList) { + newFilter = EquipmentStatus.in_; + } else if (widget.currentRoute == Routes.equipmentOutList) { + newFilter = EquipmentStatus.out; + } else if (widget.currentRoute == Routes.equipmentRentList) { + newFilter = EquipmentStatus.rent; + } else if (widget.currentRoute == Routes.equipment) { + newFilter = null; + } + if ((newFilter != _controller.selectedStatusFilter) || + widget.currentRoute != Routes.equipment) { + setState(() { + _controller.selectedStatusFilter = newFilter; + _controller.loadData(); + }); + } + } + + // 화면 크기에 따라 컬럼 표시 조정 + void _adjustColumnsForScreenSize() { + final width = MediaQuery.of(context).size.width; + setState(() { + _showDetailedColumns = width > 900; + }); + } + + // 상태 필터 변경 + void _onStatusFilterChanged(String? status) { + setState(() { + _controller.changeStatusFilter(status); + }); + } + + // 장비 선택/해제 + void _onEquipmentSelected(int? id, String status, bool? isSelected) { + setState(() { + _controller.selectEquipment(id, status, isSelected); + }); + } + + // 출고 처리 버튼 핸들러 + void _handleOutEquipment() async { + if (_controller.getSelectedInStockCount() == 0) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('출고할 장비를 선택해주세요.'))); + return; + } + + // 선택된 장비들의 요약 정보를 가져와서 출고 폼으로 전달 + final selectedEquipmentsSummary = + _controller.getSelectedEquipmentsSummary(); + + final result = await Navigator.pushNamed( + context, + Routes.equipmentOutAdd, + arguments: {'selectedEquipments': selectedEquipmentsSummary}, + ); + + if (result == true) { + setState(() { + _controller.loadData(); + }); + } + } + + // 대여 처리 버튼 핸들러 + void _handleRentEquipment() async { + if (_controller.getSelectedInStockCount() == 0) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('대여할 장비를 선택해주세요.'))); + return; + } + + // 선택된 장비들의 요약 정보를 가져와서 대여 폼으로 전달 + final selectedEquipmentsSummary = + _controller.getSelectedEquipmentsSummary(); + + // 현재는 대여 기능이 준비되지 않았으므로 간단히 스낵바 표시 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${selectedEquipmentsSummary.length}개 장비 대여 기능은 준비 중입니다.', + ), + ), + ); + } + + // 폐기 처리 버튼 핸들러 + void _handleDisposeEquipment() { + if (_controller.getSelectedInStockCount() == 0) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('폐기할 장비를 선택해주세요.'))); + return; + } + + // 선택된 장비들의 요약 정보를 가져옴 + final selectedEquipmentsSummary = + _controller.getSelectedEquipmentsSummary(); + + showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('폐기 확인'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '선택한 ${selectedEquipmentsSummary.length}개 장비를 폐기하시겠습니까?', + ), + const SizedBox(height: 16), + const Text( + '폐기할 장비 목록:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + ...selectedEquipmentsSummary.map((equipmentData) { + final equipment = equipmentData['equipment'] as Equipment; + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + '${equipment.manufacturer} ${equipment.name} (${equipment.quantity}개)', + style: const TextStyle(fontSize: 14), + ), + ); + }).toList(), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('취소'), + ), + TextButton( + onPressed: () { + // 여기에 폐기 로직 추가 예정 + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('폐기 기능은 준비 중입니다.')), + ); + Navigator.pop(context); + }, + child: const Text('폐기'), + ), + ], + ), + ); + } + + // 카테고리 축약 표기 함수 (예: 컴... > 태... > 안드로...) + String _shortenCategory(String category) { + if (category.length <= 2) return category; + return category.substring(0, 2) + '...'; + } + + // 카테고리 툴팁 위젯 (UI만 담당, 축약 표기 적용) + Widget _buildCategoryWithTooltip(UnifiedEquipment equipment) { + final fullCategory = EquipmentDisplayHelper.formatCategory( + equipment.equipment.category, + equipment.equipment.subCategory, + equipment.equipment.subSubCategory, + ); + // 축약 표기 적용 + final shortCategory = [ + _shortenCategory(equipment.equipment.category), + _shortenCategory(equipment.equipment.subCategory), + _shortenCategory(equipment.equipment.subSubCategory), + ].join(' > '); + return Tooltip(message: fullCategory, child: Text(shortCategory)); + } + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final maxContentWidth = screenWidth > 1200 ? 1200.0 : screenWidth - 32; + String screenTitle = '장비 목록'; + if (widget.currentRoute == Routes.equipmentInList) { + screenTitle = '입고된 장비'; + } else if (widget.currentRoute == Routes.equipmentOutList) { + screenTitle = '출고된 장비'; + } else if (widget.currentRoute == Routes.equipmentRentList) { + screenTitle = '대여된 장비'; + } + final int totalCount = _controller.equipments.length; + final List filteredEquipments = + _appliedSearchKeyword.isEmpty + ? _controller.equipments + : _controller.equipments.where((e) { + final keyword = _appliedSearchKeyword.toLowerCase(); + // 모든 주요 필드에서 검색 + return [ + e.equipment.manufacturer, + e.equipment.name, + e.equipment.category, + e.equipment.subCategory, + e.equipment.subSubCategory, + e.equipment.serialNumber ?? '', + e.equipment.barcode ?? '', + e.equipment.remark ?? '', + e.equipment.warrantyLicense ?? '', + e.notes ?? '', + ].any((field) => field.toLowerCase().contains(keyword)); + }).toList(); + final int filteredCount = filteredEquipments.length; + final int startIndex = (_currentPage - 1) * _pageSize; + final int endIndex = + (startIndex + _pageSize) > filteredCount + ? filteredCount + : (startIndex + _pageSize); + final pagedEquipments = filteredEquipments.sublist(startIndex, endIndex); + + // 선택된 장비 개수 + final int selectedCount = _controller.getSelectedEquipmentCount(); + final int selectedInCount = _controller.getSelectedInStockCount(); + final int selectedOutCount = _controller.getSelectedEquipmentCountByStatus( + EquipmentStatus.out, + ); + final int selectedRentCount = _controller.getSelectedEquipmentCountByStatus( + EquipmentStatus.rent, + ); + + return MainLayout( + title: screenTitle, + currentRoute: widget.currentRoute, + actions: [ + IconButton( + icon: Icon( + _showDetailedColumns ? Icons.view_column : Icons.view_compact, + color: Colors.grey, + ), + tooltip: _showDetailedColumns ? '간소화된 보기' : '상세 보기', + onPressed: () { + setState(() { + _showDetailedColumns = !_showDetailedColumns; + }); + }, + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () { + setState(() { + _controller.loadData(); + _currentPage = 1; + }); + }, + color: Colors.grey, + ), + ], + child: Container( + width: maxContentWidth, + padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + screenTitle, + style: AppThemeTailwind.headingStyle, + ), + ), + if (selectedCount > 0) + Container( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '$selectedCount개 선택됨', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + if (widget.currentRoute == Routes.equipmentInList) + Row( + children: [ + ElevatedButton.icon( + onPressed: + selectedInCount > 0 ? _handleOutEquipment : null, + icon: const Icon( + Icons.exit_to_app, + color: Colors.white, + ), + label: const Text( + '출고', + style: TextStyle(color: Colors.white), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + disabledBackgroundColor: Colors.blue.withOpacity(0.5), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: () async { + final result = await Navigator.pushNamed( + context, + Routes.equipmentInAdd, + ); + if (result == true) { + setState(() { + _controller.loadData(); + _currentPage = 1; + }); + } + }, + icon: const Icon(Icons.add, color: Colors.white), + label: const Text( + '입고', + style: TextStyle(color: Colors.white), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 220, + child: Row( + children: [ + Expanded( + child: TextField( + decoration: const InputDecoration( + hintText: '장비 검색', + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder(), + isDense: true, + contentPadding: EdgeInsets.symmetric( + vertical: 8, + horizontal: 12, + ), + ), + onChanged: (value) { + setState(() { + _searchKeyword = value; + }); + }, + onSubmitted: (value) { + setState(() { + _appliedSearchKeyword = value; + _currentPage = 1; + }); + }, + ), + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.arrow_forward), + tooltip: '검색', + onPressed: () { + setState(() { + _appliedSearchKeyword = _searchKeyword; + _currentPage = 1; + }); + }, + ), + ], + ), + ), + ], + ), + // 출고 목록 화면일 때 버튼들 + if (widget.currentRoute == Routes.equipmentOutList) + Row( + children: [ + ElevatedButton.icon( + onPressed: + selectedOutCount > 0 + ? () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('재입고 기능은 준비 중입니다.'), + ), + ); + } + : null, + icon: const Icon( + Icons.assignment_return, + color: Colors.white, + ), + label: const Text( + '재입고', + style: TextStyle(color: Colors.white), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + disabledBackgroundColor: Colors.green.withOpacity( + 0.5, + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: + selectedOutCount > 0 + ? () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('수리 요청 기능은 준비 중입니다.'), + ), + ); + } + : null, + icon: const Icon(Icons.build, color: Colors.white), + label: const Text( + '수리 요청', + style: TextStyle(color: Colors.white), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + disabledBackgroundColor: Colors.orange.withOpacity( + 0.5, + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + // 대여 목록 화면일 때 버튼들 + if (widget.currentRoute == Routes.equipmentRentList) + Row( + children: [ + ElevatedButton.icon( + onPressed: + selectedRentCount > 0 + ? () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('대여 반납 기능은 준비 중입니다.'), + ), + ); + } + : null, + icon: const Icon( + Icons.keyboard_return, + color: Colors.white, + ), + label: const Text( + '반납', + style: TextStyle(color: Colors.white), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + disabledBackgroundColor: Colors.green.withOpacity( + 0.5, + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: + selectedRentCount > 0 + ? () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('대여 연장 기능은 준비 중입니다.'), + ), + ); + } + : null, + icon: const Icon(Icons.date_range, color: Colors.white), + label: const Text( + '연장', + style: TextStyle(color: Colors.white), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + disabledBackgroundColor: Colors.blue.withOpacity(0.5), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: + pagedEquipments.isEmpty + ? const Center(child: Text('장비 정보가 없습니다.')) + : SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: maxContentWidth, + ), + child: EquipmentTable( + equipments: pagedEquipments, + selectedEquipmentIds: + _controller.selectedEquipmentIds, + showDetailedColumns: _showDetailedColumns, + onEquipmentSelected: _onEquipmentSelected, + getOutEquipmentInfo: + _controller.getOutEquipmentInfo, + buildCategoryWithTooltip: _buildCategoryWithTooltip, + // 수정 버튼 동작: 입고 폼(수정 모드)로 이동 + onEdit: (id, status) async { + if (status == EquipmentStatus.in_) { + final result = await Navigator.pushNamed( + context, + Routes.equipmentInEdit, + arguments: id, + ); + if (result == true) { + setState(() { + _controller.loadData(); + }); + } + } else { + // 출고/대여 등은 별도 폼으로 이동 필요시 구현 + } + }, + // 삭제 버튼 동작: 삭제 다이얼로그 및 삭제 처리 + onDelete: (id, status) { + showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('삭제 확인'), + content: const Text('이 장비 정보를 삭제하시겠습니까?'), + actions: [ + TextButton( + onPressed: + () => Navigator.pop(context), + child: const Text('취소'), + ), + TextButton( + onPressed: () { + setState(() { + // 입고/출고 상태에 따라 삭제 처리 + if (status == + EquipmentStatus.in_) { + MockDataService() + .deleteEquipmentIn(id); + } else if (status == + EquipmentStatus.out) { + MockDataService() + .deleteEquipmentOut(id); + } + _controller.loadData(); + }); + Navigator.pop(context); + }, + child: const Text('삭제'), + ), + ], + ), + ); + }, + getSelectedInStockCount: + _controller.getSelectedInStockCount, + ), + ), + ), + ), + if (totalCount > _pageSize) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Pagination( + totalCount: filteredCount, + currentPage: _currentPage, + pageSize: _pageSize, + onPageChanged: (page) { + setState(() { + _currentPage = page; + }); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/equipment/equipment_out_form.dart b/lib/screens/equipment/equipment_out_form.dart new file mode 100644 index 0000000..288e766 --- /dev/null +++ b/lib/screens/equipment/equipment_out_form.dart @@ -0,0 +1,805 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:superport/models/equipment_unified_model.dart'; +import 'package:superport/models/company_model.dart'; +import 'package:superport/models/address_model.dart'; +import 'package:superport/screens/common/custom_widgets.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/services/mock_data_service.dart'; +import 'package:superport/screens/equipment/controllers/equipment_out_form_controller.dart'; +import 'package:superport/screens/equipment/widgets/equipment_summary_card.dart'; +import 'package:superport/screens/equipment/widgets/equipment_summary_row.dart'; +import 'package:superport/screens/common/widgets/remark_input.dart'; + +class EquipmentOutFormScreen extends StatefulWidget { + final int? equipmentOutId; + final Equipment? selectedEquipment; + final int? selectedEquipmentInId; + final List>? selectedEquipments; + + const EquipmentOutFormScreen({ + Key? key, + this.equipmentOutId, + this.selectedEquipment, + this.selectedEquipmentInId, + this.selectedEquipments, + }) : super(key: key); + + @override + State createState() => _EquipmentOutFormScreenState(); +} + +class _EquipmentOutFormScreenState extends State { + late final EquipmentOutFormController _controller; + + @override + void initState() { + super.initState(); + _controller = EquipmentOutFormController(dataService: MockDataService()); + _controller.isEditMode = widget.equipmentOutId != null; + _controller.equipmentOutId = widget.equipmentOutId; + _controller.selectedEquipment = widget.selectedEquipment; + _controller.selectedEquipmentInId = widget.selectedEquipmentInId; + _controller.selectedEquipments = widget.selectedEquipments; + _controller.loadDropdownData(); + if (_controller.isEditMode) { + // 수정 모드: 기존 출고 정보 로드 + // (이 부분은 실제 서비스에서 컨트롤러에 메서드 추가 필요) + } else if (widget.selectedEquipments != null && + widget.selectedEquipments!.isNotEmpty) { + // 다중 선택 장비 있음: 별도 초기화 필요시 컨트롤러에서 처리 + } else if (widget.selectedEquipment != null) { + _controller.initializeWithSelectedEquipment(widget.selectedEquipment!); + } + } + + // 요약 테이블 위젯 - 다중 선택 장비에 대한 요약 테이블 + Widget _buildSummaryTable() { + if (_controller.selectedEquipments == null || + _controller.selectedEquipments!.isEmpty) { + return const SizedBox.shrink(); + } + + // 각 장비별로 전체 폭을 사용하는 리스트로 구현 + return Container( + width: double.infinity, // 전체 폭 사용 + child: Card( + elevation: 2, + margin: EdgeInsets.zero, // margin 제거 + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '선택된 장비 목록 (${_controller.selectedEquipments!.length}개)', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + // 리스트 헤더 + Row( + children: const [ + Expanded( + flex: 2, + child: Text( + '제조사', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + Expanded( + flex: 2, + child: Text( + '장비명', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + Expanded( + flex: 1, + child: Text( + '수량', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + Expanded( + flex: 2, + child: Text( + '워런티 시작일', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + Expanded( + flex: 2, + child: Text( + '워런티 종료일', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ), + const Divider(), + // 리스트 본문 + Column( + children: List.generate(_controller.selectedEquipments!.length, ( + index, + ) { + final equipmentData = _controller.selectedEquipments![index]; + final equipment = equipmentData['equipment'] as Equipment; + // 워런티 날짜를 임시로 저장할 수 있도록 상태를 관리(컨트롤러에 리스트로 추가하거나, 여기서 임시로 관리) + // 여기서는 equipment 객체의 필드를 직접 수정(실제 서비스에서는 별도 상태 관리 필요) + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + children: [ + Expanded(flex: 2, child: Text(equipment.manufacturer)), + Expanded(flex: 2, child: Text(equipment.name)), + Expanded(flex: 1, child: Text('${equipment.quantity}')), + Expanded( + flex: 2, + child: InkWell( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: + equipment.warrantyStartDate ?? + DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null) { + setState(() { + equipment.warrantyStartDate = picked; + }); + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 4, + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _formatDate(equipment.warrantyStartDate), + style: const TextStyle( + decoration: TextDecoration.underline, + color: Colors.blue, + ), + ), + ), + ), + ), + Expanded( + flex: 2, + child: InkWell( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: + equipment.warrantyEndDate ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null) { + setState(() { + equipment.warrantyEndDate = picked; + }); + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 4, + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _formatDate(equipment.warrantyEndDate), + style: const TextStyle( + decoration: TextDecoration.underline, + color: Colors.blue, + ), + ), + ), + ), + ), + ], + ), + ); + }), + ), + ], + ), + ), + ), + ); + } + + // 날짜 포맷 유틸리티 + String _formatDate(DateTime? date) { + if (date == null) return '정보 없음'; + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + // 담당자가 없거나 첫 번째 회사에 대한 담당자가 '없음'인 경우 등록 버튼 비활성화 조건 + final bool canSubmit = + _controller.selectedCompanies.isNotEmpty && + _controller.selectedCompanies[0] != null && + _controller.hasManagersPerCompany[0] && + _controller.filteredManagersPerCompany[0].first != '없음'; + final int totalSelectedEquipments = + _controller.selectedEquipments?.length ?? 0; + return Scaffold( + appBar: AppBar( + title: Text( + _controller.isEditMode + ? '장비 출고 수정' + : totalSelectedEquipments > 0 + ? '장비 출고 등록 (${totalSelectedEquipments}개)' + : '장비 출고 등록', + ), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _controller.formKey, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 장비 정보 요약 섹션 + if (_controller.selectedEquipments != null && + _controller.selectedEquipments!.isNotEmpty) + _buildSummaryTable() + else if (_controller.selectedEquipment != null) + // 단일 장비 요약 카드도 전체 폭으로 맞춤 + Container( + width: double.infinity, + child: EquipmentSingleSummaryCard( + equipment: _controller.selectedEquipment!, + ), + ) + else + const SizedBox.shrink(), + // 요약 카드 아래 라디오 버튼 추가 + const SizedBox(height: 12), + // 전체 폭을 사용하는 라디오 버튼 + Container(width: double.infinity, child: _buildOutTypeRadio()), + const SizedBox(height: 16), + // 출고 정보 입력 섹션 (수정/등록) + _buildOutgoingInfoSection(context), + // 비고 입력란 추가 + const SizedBox(height: 16), + FormFieldWrapper( + label: '비고', + isRequired: false, + child: RemarkInput( + controller: _controller.remarkController, + hint: '비고를 입력하세요', + minLines: 4, + ), + ), + const SizedBox(height: 24), + // 담당자 없음 경고 메시지 + if (_controller.selectedCompanies.isNotEmpty && + _controller.selectedCompanies[0] != null && + (!_controller.hasManagersPerCompany[0] || + _controller.filteredManagersPerCompany[0].first == + '없음')) + Container( + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.red.shade100, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.red.shade300), + ), + child: const Row( + children: [ + Icon(Icons.warning, color: Colors.red), + SizedBox(width: 8), + Expanded( + child: Text( + '선택한 회사에 등록된 담당자가 없습니다. 담당자를 먼저 등록해야 합니다.', + style: TextStyle(color: Colors.red), + ), + ), + ], + ), + ), + // 저장 버튼 + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: + canSubmit + ? () { + // 각 회사별 담당자를 첫 번째 항목으로 설정 + for ( + int i = 0; + i < _controller.selectedCompanies.length; + i++ + ) { + if (_controller.selectedCompanies[i] != null && + _controller.hasManagersPerCompany[i] && + _controller + .filteredManagersPerCompany[i] + .isNotEmpty && + _controller + .filteredManagersPerCompany[i] + .first != + '없음') { + _controller.selectedManagersPerCompany[i] = + _controller + .filteredManagersPerCompany[i] + .first; + } + } + + _controller.saveEquipmentOut( + (msg) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg), + duration: const Duration(seconds: 2), + ), + ); + Navigator.pop(context, true); + }, + (err) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(err), + duration: const Duration(seconds: 2), + ), + ); + }, + ); + } + : null, + style: + canSubmit + ? AppThemeTailwind.primaryButtonStyle + : ElevatedButton.styleFrom( + backgroundColor: Colors.grey.shade300, + foregroundColor: Colors.grey.shade700, + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + _controller.isEditMode ? '수정하기' : '등록하기', + style: const TextStyle(fontSize: 16), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + // 출고 정보 입력 섹션 위젯 (등록/수정 공통) + Widget _buildOutgoingInfoSection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('출고 정보', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + // 출고일 + _buildDateField( + context, + label: '출고일', + date: _controller.outDate, + onDateChanged: (picked) { + setState(() { + _controller.outDate = picked; + }); + }, + ), + + // 출고 회사 영역 헤더 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('출고 회사', style: TextStyle(fontWeight: FontWeight.bold)), + TextButton.icon( + onPressed: () { + setState(() { + _controller.addCompany(); + }); + }, + icon: const Icon(Icons.add_circle_outline, size: 18), + label: const Text('출고 회사 추가'), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), + const SizedBox(height: 4), + + // 동적 출고 회사 드롭다운 목록 + ...List.generate(_controller.selectedCompanies.length, (index) { + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: DropdownButtonFormField( + value: _controller.selectedCompanies[index], + decoration: InputDecoration( + hintText: index == 0 ? '출고할 회사를 선택하세요' : '추가된 출고할 회사를 선택하세요', + // 이전 드롭다운에 값이 선택되지 않았으면 비활성화 + enabled: + index == 0 || + _controller.selectedCompanies[index - 1] != null, + ), + items: + _controller.availableCompaniesPerDropdown[index] + .map( + (item) => DropdownMenuItem( + value: item, + child: _buildCompanyDropdownItem(item), + ), + ) + .toList(), + validator: (value) { + if (index == 0 && (value == null || value.isEmpty)) { + return '출고 회사를 선택해주세요'; + } + return null; + }, + onChanged: + (index == 0 || + _controller.selectedCompanies[index - 1] != null) + ? (value) { + setState(() { + _controller.selectedCompanies[index] = value; + _controller.filterManagersByCompanyAtIndex( + value, + index, + ); + _controller.updateAvailableCompanies(); + }); + } + : null, + ), + ); + }), + + // 각 회사별 담당자 선택 목록 + ...List.generate(_controller.selectedCompanies.length, (index) { + // 회사가 선택된 경우에만 담당자 표시 + if (_controller.selectedCompanies[index] != null) { + // 회사 정보 가져오기 + final companyInfo = _controller.companiesWithBranches.firstWhere( + (info) => info.name == _controller.selectedCompanies[index], + orElse: + () => CompanyBranchInfo( + id: 0, + name: _controller.selectedCompanies[index]!, + originalName: _controller.selectedCompanies[index]!, + isMainCompany: true, + companyId: 0, + branchId: null, + ), + ); + + // 실제 회사/지점 정보를 ID로 가져오기 + Company? company; + Branch? branch; + + if (companyInfo.companyId != null) { + company = _controller.dataService.getCompanyById( + companyInfo.companyId!, + ); + if (!companyInfo.isMainCompany && + companyInfo.branchId != null && + company != null) { + final branches = company.branches; + if (branches != null) { + branch = branches.firstWhere( + (b) => b.id == companyInfo.branchId, + orElse: + () => Branch( + companyId: companyInfo.companyId!, + name: companyInfo.originalName, + ), + ); + } + } + } + + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '담당자 정보 (${_controller.selectedCompanies[index]})', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 15, + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(4), + ), + child: + company != null + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 본사/지점 정보 표시 + if (companyInfo.isMainCompany && + company.contactName != null && + company.contactName!.isNotEmpty) + Text( + '${company.contactName} ${company.contactPosition ?? ""} ${company.contactPhone ?? ""} ${company.contactEmail ?? ""}', + style: AppThemeTailwind.bodyStyle, + ), + if (!companyInfo.isMainCompany && + branch != null && + branch.contactName != null && + branch.contactName!.isNotEmpty) + Text( + '${branch.contactName} ${branch.contactPosition ?? ""} ${branch.contactPhone ?? ""} ${branch.contactEmail ?? ""}', + style: AppThemeTailwind.bodyStyle, + ), + const SizedBox(height: 8), + // 담당자 목록에서 실제 담당자 정보만 표시하는 부분은 제거 + ], + ) + : Text( + '회사 정보를 불러올 수 없습니다.', + style: TextStyle( + color: Colors.red.shade400, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ); + } else { + return const SizedBox.shrink(); + } + }), + + // 유지 보수(라이센스) 선택 + _buildDropdownField( + label: '유지 보수', // 텍스트 변경 + value: _controller.selectedLicense, + items: _controller.licenses, + hint: '유지 보수를 선택하세요', // 텍스트 변경 + onChanged: (value) { + setState(() { + _controller.selectedLicense = value; + }); + }, + validator: (value) { + if (value == null || value.isEmpty) { + return '유지 보수를 선택해주세요'; // 텍스트 변경 + } + return null; + }, + ), + ], + ); + } + + // 날짜 선택 필드 위젯 + Widget _buildDateField( + BuildContext context, { + required String label, + required DateTime date, + required ValueChanged onDateChanged, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + InkWell( + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: date, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null && picked != date) { + onDateChanged(picked); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 15), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _controller.formatDate(date), + style: AppThemeTailwind.bodyStyle, + ), + const Icon(Icons.calendar_today, size: 20), + ], + ), + ), + ), + const SizedBox(height: 12), + ], + ); + } + + // 드롭다운 필드 위젯 + Widget _buildDropdownField({ + required String label, + required String? value, + required List items, + required String hint, + required ValueChanged? onChanged, + required String? Function(String?) validator, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + DropdownButtonFormField( + value: value, + decoration: InputDecoration(hintText: hint), + items: + items + .map( + (item) => DropdownMenuItem( + value: item, + child: Text(item), + ), + ) + .toList(), + validator: validator, + onChanged: onChanged, + ), + const SizedBox(height: 12), + ], + ); + } + + // 회사 이름을 표시하는 위젯 (지점 포함) + Widget _buildCompanyDropdownItem(String item) { + final TextStyle defaultStyle = TextStyle( + color: Colors.black87, + fontSize: 14, + fontWeight: FontWeight.normal, + ); + + // 컨트롤러에서 해당 항목에 대한 정보 확인 + final companyInfoList = + _controller.companiesWithBranches + .where((info) => info.name == item) + .toList(); + + // 회사 정보가 존재하고 지점인 경우 + if (companyInfoList.isNotEmpty && !companyInfoList[0].isMainCompany) { + final companyInfo = companyInfoList[0]; + final parentCompanyName = companyInfo.parentCompanyName ?? ''; + final branchName = companyInfo.displayName ?? companyInfo.originalName; + + // Row 대신 RichText 사용 - 지점 표시 + return RichText( + text: TextSpan( + style: defaultStyle, // 기본 스타일 설정 + children: [ + WidgetSpan( + child: Icon( + Icons.subdirectory_arrow_right, + size: 16, + color: Colors.grey, + ), + alignment: PlaceholderAlignment.middle, + ), + TextSpan(text: ' ', style: defaultStyle), + TextSpan( + text: parentCompanyName, // 회사명 + style: defaultStyle, + ), + TextSpan(text: ' ', style: defaultStyle), + TextSpan( + text: branchName, // 지점명 + style: const TextStyle( + color: Colors.indigo, + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + ], + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); + } + + // 일반 회사명 (본사) + return RichText( + text: TextSpan( + style: defaultStyle, // 기본 스타일 설정 + children: [ + WidgetSpan( + child: Icon(Icons.business, size: 16, color: Colors.black54), + alignment: PlaceholderAlignment.middle, + ), + TextSpan(text: ' ', style: defaultStyle), + TextSpan( + text: item, + style: defaultStyle.copyWith(fontWeight: FontWeight.w500), + ), + ], + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); + } + + // 회사 ID에 따른 담당자 정보를 가져와 표시하는 위젯 목록 생성 + List _getUsersForCompany(CompanyBranchInfo companyInfo) { + final List userWidgets = []; + + // 판교지점 특별 처리 + if (companyInfo.originalName == "판교지점" && + companyInfo.parentCompanyName == "LG전자") { + userWidgets.add( + Text( + '정수진 사원 010-4567-8901 jung.soojin@lg.com', + style: AppThemeTailwind.bodyStyle, + ), + ); + } + + return userWidgets; + } + + // 출고/대여/폐기 라디오 버튼 위젯 + Widget _buildOutTypeRadio() { + // 출고 유형 리스트 + final List outTypes = ['출고', '대여', '폐기']; + return Row( + mainAxisAlignment: MainAxisAlignment.start, + children: + outTypes.map((type) { + return Row( + children: [ + Radio( + value: type, + groupValue: _controller.outType, // 컨트롤러에서 현재 선택값 관리 + onChanged: (value) { + setState(() { + _controller.outType = value!; + }); + }, + ), + Text(type), + ], + ); + }).toList(), + ); + } +} diff --git a/lib/screens/equipment/widgets/autocomplete_text_field.dart b/lib/screens/equipment/widgets/autocomplete_text_field.dart new file mode 100644 index 0000000..018da6e --- /dev/null +++ b/lib/screens/equipment/widgets/autocomplete_text_field.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; + +/// 자동완성 텍스트 필드 위젯 +/// +/// 입력, 드롭다운, 포커스, 필터링, 선택 기능을 모두 포함한다. +class AutocompleteTextField extends StatefulWidget { + final String label; + final String value; + final List items; + final bool isRequired; + final String hintText; + final void Function(String) onChanged; + final void Function(String) onSelected; + final FocusNode? focusNode; + + const AutocompleteTextField({ + Key? key, + required this.label, + required this.value, + required this.items, + required this.onChanged, + required this.onSelected, + this.isRequired = false, + this.hintText = '', + this.focusNode, + }) : super(key: key); + + @override + State createState() => _AutocompleteTextFieldState(); +} + +class _AutocompleteTextFieldState extends State { + late final TextEditingController _controller; + late final FocusNode _focusNode; + late List _filteredItems; + bool _showDropdown = false; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.value); + _focusNode = widget.focusNode ?? FocusNode(); + _filteredItems = List.from(widget.items); + _controller.addListener(_onTextChanged); + _focusNode.addListener(() { + setState(() { + if (_focusNode.hasFocus) { + _showDropdown = _filteredItems.isNotEmpty; + } else { + _showDropdown = false; + } + }); + }); + } + + @override + void didUpdateWidget(covariant AutocompleteTextField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.value != _controller.text) { + _controller.text = widget.value; + } + if (widget.items != oldWidget.items) { + _filteredItems = List.from(widget.items); + } + } + + @override + void dispose() { + if (widget.focusNode == null) { + _focusNode.dispose(); + } + _controller.dispose(); + super.dispose(); + } + + // 입력값 변경 시 필터링 + void _onTextChanged() { + final text = _controller.text; + setState(() { + if (text.isEmpty) { + _filteredItems = List.from(widget.items); + } else { + _filteredItems = + widget.items + .where( + (item) => item.toLowerCase().contains(text.toLowerCase()), + ) + .toList(); + // 시작 부분이 일치하는 항목 우선 정렬 + _filteredItems.sort((a, b) { + bool aStartsWith = a.toLowerCase().startsWith(text.toLowerCase()); + bool bStartsWith = b.toLowerCase().startsWith(text.toLowerCase()); + if (aStartsWith && !bStartsWith) return -1; + if (!aStartsWith && bStartsWith) return 1; + return a.compareTo(b); + }); + } + _showDropdown = _filteredItems.isNotEmpty && _focusNode.hasFocus; + widget.onChanged(text); + }); + } + + void _handleSelect(String value) { + setState(() { + _controller.text = value; + _showDropdown = false; + }); + widget.onSelected(value); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + TextFormField( + controller: _controller, + focusNode: _focusNode, + decoration: InputDecoration( + labelText: widget.label, + hintText: widget.hintText, + ), + validator: (value) { + if (widget.isRequired && (value == null || value.isEmpty)) { + return '${widget.label}을(를) 입력해주세요'; + } + return null; + }, + onSaved: (value) { + widget.onSelected(value ?? ''); + }, + ), + if (_showDropdown) + Positioned( + top: 50, + left: 0, + right: 0, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + constraints: const BoxConstraints(maxHeight: 200), + child: ListView.builder( + shrinkWrap: true, + itemCount: _filteredItems.length, + itemBuilder: (context, index) { + return InkWell( + onTap: () => _handleSelect(_filteredItems[index]), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Text(_filteredItems[index]), + ), + ); + }, + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/equipment/widgets/equipment_out_info.dart b/lib/screens/equipment/widgets/equipment_out_info.dart new file mode 100644 index 0000000..4d6888c --- /dev/null +++ b/lib/screens/equipment/widgets/equipment_out_info.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +// 출고 정보(회사, 담당자, 라이센스 등)를 아이콘과 함께 표시하는 위젯 +class EquipmentOutInfoIcon extends StatelessWidget { + final String infoType; // company, manager, license 등 + final String text; + + const EquipmentOutInfoIcon({ + super.key, + required this.infoType, + required this.text, + }); + + @override + Widget build(BuildContext context) { + // infoType에 따라 아이콘 결정 + IconData iconData; + switch (infoType) { + case 'company': + iconData = Icons.business; + break; + case 'manager': + iconData = Icons.person; + break; + case 'license': + iconData = Icons.book; + break; + default: + iconData = Icons.info; + } + + // 아이콘과 텍스트를 Row로 표시 + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(iconData, size: 14, color: Colors.grey[700]), + const SizedBox(width: 4), + Flexible( + child: Text( + text, + style: TextStyle(fontSize: 13, color: Colors.grey[800]), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} diff --git a/lib/screens/equipment/widgets/equipment_status_chip.dart b/lib/screens/equipment/widgets/equipment_status_chip.dart new file mode 100644 index 0000000..8917d87 --- /dev/null +++ b/lib/screens/equipment/widgets/equipment_status_chip.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:superport/utils/constants.dart'; + +// 장비 상태에 따라 칩(Chip) 위젯을 반환하는 함수형 위젯 +class EquipmentStatusChip extends StatelessWidget { + final String status; + + const EquipmentStatusChip({super.key, required this.status}); + + @override + Widget build(BuildContext context) { + // 상태별 칩 색상 및 텍스트 지정 + Color backgroundColor; + String statusText; + + switch (status) { + case EquipmentStatus.in_: + backgroundColor = Colors.green; + statusText = '입고'; + break; + case EquipmentStatus.out: + backgroundColor = Colors.orange; + statusText = '출고'; + break; + case EquipmentStatus.rent: + backgroundColor = Colors.blue; + statusText = '대여'; + break; + case EquipmentStatus.repair: + backgroundColor = Colors.blue; + statusText = '수리중'; + break; + case EquipmentStatus.damaged: + backgroundColor = Colors.red; + statusText = '손상'; + break; + case EquipmentStatus.lost: + backgroundColor = Colors.purple; + statusText = '분실'; + break; + case EquipmentStatus.etc: + backgroundColor = Colors.grey; + statusText = '기타'; + break; + default: + backgroundColor = Colors.grey; + statusText = '알 수 없음'; + } + + // 칩 위젯 반환 + return Chip( + label: Text( + statusText, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + backgroundColor: backgroundColor, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 5), + ); + } +} diff --git a/lib/screens/equipment/widgets/equipment_summary_card.dart b/lib/screens/equipment/widgets/equipment_summary_card.dart new file mode 100644 index 0000000..8579ff4 --- /dev/null +++ b/lib/screens/equipment/widgets/equipment_summary_card.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/equipment_unified_model.dart'; +import 'package:superport/screens/equipment/widgets/equipment_summary_row.dart'; + +// 다중 선택 장비 요약 카드 +class EquipmentMultiSummaryCard extends StatelessWidget { + final List> selectedEquipments; + const EquipmentMultiSummaryCard({ + super.key, + required this.selectedEquipments, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + '선택된 장비 목록 (${selectedEquipments.length}개)', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ...selectedEquipments.map((equipmentData) { + final equipment = equipmentData['equipment'] as Equipment; + return EquipmentSingleSummaryCard(equipment: equipment); + }).toList(), + ], + ); + } +} + +// 단일 장비 요약 카드 +class EquipmentSingleSummaryCard extends StatelessWidget { + final Equipment equipment; + const EquipmentSingleSummaryCard({super.key, required this.equipment}); + + // 날짜 포맷 유틸리티 + String _formatDate(DateTime? date) { + if (date == null) return '정보 없음'; + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + return Card( + elevation: 3, + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + Icon( + Icons.inventory, + color: Theme.of(context).primaryColor, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + equipment.name.isNotEmpty ? equipment.name : '이름 없음', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.blue.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue.shade300), + ), + child: Text( + '수량: ${equipment.quantity}', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + color: Colors.blue.shade800, + ), + ), + ), + ], + ), + const Divider(thickness: 1.5), + EquipmentSummaryRow( + label: '제조사', + value: + equipment.manufacturer.isNotEmpty + ? equipment.manufacturer + : '정보 없음', + ), + EquipmentSummaryRow( + label: '카테고리', + value: + equipment.category.isNotEmpty + ? '${equipment.category} > ${equipment.subCategory} > ${equipment.subSubCategory}' + : '정보 없음', + ), + EquipmentSummaryRow( + label: '시리얼 번호', + value: + (equipment.serialNumber != null && + equipment.serialNumber!.isNotEmpty) + ? equipment.serialNumber! + : '정보 없음', + ), + EquipmentSummaryRow( + label: '출고 수량', + value: equipment.quantity.toString(), + ), + EquipmentSummaryRow( + label: '입고일', + value: _formatDate(equipment.inDate), + ), + // 워런티 정보 추가 + if (equipment.warrantyLicense != null && + equipment.warrantyLicense!.isNotEmpty) + EquipmentSummaryRow( + label: '워런티 라이센스', + value: equipment.warrantyLicense!, + ), + EquipmentSummaryRow( + label: '워런티 시작일', + value: _formatDate(equipment.warrantyStartDate), + ), + EquipmentSummaryRow( + label: '워런티 종료일', + value: _formatDate(equipment.warrantyEndDate), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/equipment/widgets/equipment_summary_row.dart b/lib/screens/equipment/widgets/equipment_summary_row.dart new file mode 100644 index 0000000..768ba57 --- /dev/null +++ b/lib/screens/equipment/widgets/equipment_summary_row.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +// 장비 요약 정보 행 위젯 (SRP, 재사용성) +class EquipmentSummaryRow extends StatelessWidget { + final String label; + final String value; + + const EquipmentSummaryRow({ + super.key, + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 110, + child: Text( + '$label:', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + fontSize: 15, + color: value == '정보 없음' ? Colors.grey.shade600 : Colors.black, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/equipment/widgets/equipment_table.dart b/lib/screens/equipment/widgets/equipment_table.dart new file mode 100644 index 0000000..71b936b --- /dev/null +++ b/lib/screens/equipment/widgets/equipment_table.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/equipment_unified_model.dart'; +import 'package:superport/screens/equipment/widgets/equipment_status_chip.dart'; +import 'package:superport/screens/equipment/widgets/equipment_out_info.dart'; +import 'package:superport/utils/equipment_display_helper.dart'; + +// 장비 목록 테이블 위젯 (SRP, 재사용성 강화) +class EquipmentTable extends StatelessWidget { + final List equipments; + final Set selectedEquipmentIds; + final bool showDetailedColumns; + final void Function(int? id, String status, bool? isSelected) + onEquipmentSelected; + final String Function(int equipmentId, String infoType) getOutEquipmentInfo; + final Widget Function(UnifiedEquipment equipment) buildCategoryWithTooltip; + final void Function(int id, String status) onEdit; + final void Function(int id, String status) onDelete; + final int Function() getSelectedInStockCount; + + const EquipmentTable({ + super.key, + required this.equipments, + required this.selectedEquipmentIds, + required this.showDetailedColumns, + required this.onEquipmentSelected, + required this.getOutEquipmentInfo, + required this.buildCategoryWithTooltip, + required this.onEdit, + required this.onDelete, + required this.getSelectedInStockCount, + }); + + // 출고 정보(간소화 모드) 위젯 + Widget _buildCompactOutInfo(int equipmentId) { + final company = getOutEquipmentInfo(equipmentId, 'company'); + final manager = getOutEquipmentInfo(equipmentId, 'manager'); + final license = getOutEquipmentInfo(equipmentId, 'license'); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + EquipmentOutInfoIcon(infoType: 'company', text: company), + const SizedBox(height: 2), + EquipmentOutInfoIcon(infoType: 'manager', text: manager), + const SizedBox(height: 2), + EquipmentOutInfoIcon(infoType: 'license', text: license), + ], + ); + } + + // 카테고리 툴팁 위젯 (UI만 담당, 축약 표기 적용) + Widget _buildCategoryWithTooltip(UnifiedEquipment equipment) { + // 한글 라벨로 표기 + final fullCategory = + '대분류: ${equipment.equipment.category} / 중분류: ${equipment.equipment.subCategory} / 소분류: ${equipment.equipment.subSubCategory}'; + final shortCategory = [ + _shortenCategory(equipment.equipment.category), + _shortenCategory(equipment.equipment.subCategory), + _shortenCategory(equipment.equipment.subSubCategory), + ].join(' > '); + return Tooltip(message: fullCategory, child: Text(shortCategory)); + } + + // 카테고리 축약 표기 함수 (예: 컴...) + String _shortenCategory(String category) { + if (category.length <= 2) return category; + return category.substring(0, 2) + '...'; + } + + @override + Widget build(BuildContext context) { + return DataTable( + headingRowHeight: 48, + dataRowMinHeight: 48, + dataRowMaxHeight: 60, + columnSpacing: 10, + horizontalMargin: 16, + columns: [ + const DataColumn(label: SizedBox(width: 32, child: Text('선택'))), + const DataColumn(label: SizedBox(width: 32, child: Text('번호'))), + if (showDetailedColumns) + const DataColumn(label: SizedBox(width: 60, child: Text('제조사'))), + const DataColumn(label: SizedBox(width: 90, child: Text('장비명'))), + if (showDetailedColumns) + const DataColumn(label: SizedBox(width: 110, child: Text('분류'))), + if (showDetailedColumns) + const DataColumn(label: SizedBox(width: 60, child: Text('장비 유형'))), + if (showDetailedColumns) + const DataColumn(label: SizedBox(width: 70, child: Text('시리얼번호'))), + const DataColumn(label: SizedBox(width: 38, child: Text('수량'))), + const DataColumn(label: SizedBox(width: 80, child: Text('변경 일자'))), + const DataColumn(label: SizedBox(width: 44, child: Text('상태'))), + if (showDetailedColumns) ...[ + const DataColumn(label: SizedBox(width: 90, child: Text('출고 회사'))), + const DataColumn(label: SizedBox(width: 60, child: Text('담당자'))), + const DataColumn(label: SizedBox(width: 60, child: Text('라이센스'))), + ] else + const DataColumn(label: SizedBox(width: 110, child: Text('출고 정보'))), + const DataColumn(label: SizedBox(width: 60, child: Text('관리'))), + ], + rows: + equipments.asMap().entries.map((entry) { + final index = entry.key; + final equipment = entry.value; + final bool isInStock = equipment.status == 'I'; + final bool isOutStock = equipment.status == 'O'; + return DataRow( + color: MaterialStateProperty.resolveWith( + (Set states) => + index % 2 == 0 ? Colors.grey[50] : null, + ), + cells: [ + DataCell( + Checkbox( + value: selectedEquipmentIds.contains( + '${equipment.id}:${equipment.status}', + ), + onChanged: + (isSelected) => onEquipmentSelected( + equipment.id, + equipment.status, + isSelected, + ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + DataCell(Text('${index + 1}')), + if (showDetailedColumns) + DataCell( + Text( + EquipmentDisplayHelper.formatManufacturer( + equipment.equipment.manufacturer, + ), + ), + ), + DataCell( + Text( + EquipmentDisplayHelper.formatEquipmentName( + equipment.equipment.name, + ), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + if (showDetailedColumns) + DataCell(buildCategoryWithTooltip(equipment)), + if (showDetailedColumns) + DataCell( + Text( + equipment.status == 'I' && + equipment is UnifiedEquipment && + equipment.type != null + ? equipment.type! + : '-', + ), + ), + if (showDetailedColumns) + DataCell( + Text( + EquipmentDisplayHelper.formatSerialNumber( + equipment.equipment.serialNumber, + ), + ), + ), + DataCell( + Text( + '${equipment.equipment.quantity}', + textAlign: TextAlign.center, + ), + ), + DataCell( + Text(EquipmentDisplayHelper.formatDate(equipment.date)), + ), + DataCell(EquipmentStatusChip(status: equipment.status)), + if (showDetailedColumns) ...[ + DataCell( + Text( + isOutStock + ? getOutEquipmentInfo(equipment.id!, 'company') + : '-', + ), + ), + DataCell( + Text( + isOutStock + ? getOutEquipmentInfo(equipment.id!, 'manager') + : '-', + ), + ), + DataCell( + Text( + isOutStock + ? getOutEquipmentInfo(equipment.id!, 'license') + : '-', + ), + ), + ] else + DataCell( + isOutStock + ? _buildCompactOutInfo(equipment.id!) + : const Text('-'), + ), + DataCell( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon( + Icons.edit, + color: Colors.blue, + size: 20, + ), + constraints: const BoxConstraints(), + padding: const EdgeInsets.all(5), + onPressed: + () => onEdit(equipment.id!, equipment.status), + ), + IconButton( + icon: const Icon( + Icons.delete, + color: Colors.red, + size: 20, + ), + constraints: const BoxConstraints(), + padding: const EdgeInsets.all(5), + onPressed: + () => onDelete(equipment.id!, equipment.status), + ), + ], + ), + ), + ], + ); + }).toList(), + ); + } +} diff --git a/lib/screens/goods/goods_list.dart b/lib/screens/goods/goods_list.dart new file mode 100644 index 0000000..ada1a98 --- /dev/null +++ b/lib/screens/goods/goods_list.dart @@ -0,0 +1,405 @@ +import 'package:flutter/material.dart'; +import 'package:superport/services/mock_data_service.dart'; +import 'package:superport/utils/constants.dart'; +import 'package:superport/screens/common/main_layout.dart'; +import 'package:superport/screens/common/custom_widgets.dart'; +import 'package:superport/screens/common/widgets/pagination.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/screens/common/widgets/category_autocomplete_field.dart'; + +/// 물품 관리(등록) 화면 +/// 이름, 제조사, 대분류, 중분류, 소분류만 등록/조회 가능 +class GoodsListScreen extends StatefulWidget { + const GoodsListScreen({super.key}); + + @override + State createState() => _GoodsListScreenState(); +} + +class _GoodsListScreenState extends State { + final MockDataService _dataService = MockDataService(); + late List<_GoodsItem> _goodsList; + int _currentPage = 1; + final int _pageSize = 10; + + @override + void initState() { + super.initState(); + _loadGoods(); + } + + void _loadGoods() { + final allEquipments = _dataService.getAllEquipmentIns(); + final goodsSet = {}; + for (final equipmentIn in allEquipments) { + final eq = equipmentIn.equipment; + final key = + '${eq.manufacturer}|${eq.name}|${eq.category}|${eq.subCategory}|${eq.subSubCategory}'; + goodsSet[key] = _GoodsItem( + name: eq.name, + manufacturer: eq.manufacturer, + category: eq.category, + subCategory: eq.subCategory, + subSubCategory: eq.subSubCategory, + ); + } + setState(() { + _goodsList = goodsSet.values.toList(); + }); + } + + void _showAddGoodsDialog() async { + final result = await showDialog<_GoodsItem>( + context: context, + builder: (context) => _GoodsFormDialog(), + ); + if (result != null) { + setState(() { + _goodsList.add(result); + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('물품이 등록되었습니다.'))); + } + } + + void _showEditGoodsDialog(int index) async { + final result = await showDialog<_GoodsItem>( + context: context, + builder: (context) => _GoodsFormDialog(item: _goodsList[index]), + ); + if (result != null) { + setState(() { + _goodsList[index] = result; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('물품 정보가 수정되었습니다.'))); + } + } + + void _deleteGoods(int index) { + showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('삭제 확인'), + content: const Text('이 물품 정보를 삭제하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('취소'), + ), + TextButton( + onPressed: () { + setState(() { + _goodsList.removeAt(index); + }); + Navigator.pop(context); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('물품이 삭제되었습니다.'))); + }, + child: const Text('삭제'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final maxContentWidth = screenWidth > 1200 ? 1200.0 : screenWidth - 32; + final int totalCount = _goodsList.length; + final int startIndex = (_currentPage - 1) * _pageSize; + final int endIndex = + (startIndex + _pageSize) > totalCount + ? totalCount + : (startIndex + _pageSize); + final pagedGoods = _goodsList.sublist(startIndex, endIndex); + + return MainLayout( + title: '물품 관리', + currentRoute: Routes.goods, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadGoods, + color: Colors.grey, + ), + ], + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PageTitle( + title: '물품 목록', + width: maxContentWidth - 32, + rightWidget: ElevatedButton.icon( + onPressed: _showAddGoodsDialog, + icon: const Icon(Icons.add), + label: const Text('추가'), + style: AppThemeTailwind.primaryButtonStyle, + ), + ), + Expanded( + child: DataTableCard( + width: maxContentWidth - 32, + child: + pagedGoods.isEmpty + ? const Center(child: Text('등록된 물품이 없습니다.')) + : SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Container( + constraints: BoxConstraints( + minWidth: maxContentWidth - 64, + ), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: DataTable( + columns: const [ + DataColumn(label: Text('번호')), + DataColumn(label: Text('이름')), + DataColumn(label: Text('제조사')), + DataColumn(label: Text('대분류')), + DataColumn(label: Text('중분류')), + DataColumn(label: Text('소분류')), + DataColumn(label: Text('관리')), + ], + rows: List.generate(pagedGoods.length, (i) { + final item = pagedGoods[i]; + final realIndex = startIndex + i; + return DataRow( + cells: [ + DataCell(Text('${realIndex + 1}')), + DataCell(Text(item.name)), + DataCell(Text(item.manufacturer)), + DataCell(Text(item.category)), + DataCell(Text(item.subCategory)), + DataCell(Text(item.subSubCategory)), + DataCell( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon( + Icons.edit, + color: AppThemeTailwind.primary, + ), + onPressed: + () => _showEditGoodsDialog( + realIndex, + ), + ), + IconButton( + icon: const Icon( + Icons.delete, + color: AppThemeTailwind.danger, + ), + onPressed: + () => _deleteGoods(realIndex), + ), + ], + ), + ), + ], + ); + }), + ), + ), + ), + ), + ), + ), + if (totalCount > _pageSize) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Pagination( + totalCount: totalCount, + currentPage: _currentPage, + pageSize: _pageSize, + onPageChanged: (page) { + setState(() { + _currentPage = page; + }); + }, + ), + ), + ], + ), + ), + ); + } +} + +/// 물품 데이터 모델 (이름, 제조사, 대중소분류) +class _GoodsItem { + final String name; + final String manufacturer; + final String category; + final String subCategory; + final String subSubCategory; + + _GoodsItem({ + required this.name, + required this.manufacturer, + required this.category, + required this.subCategory, + required this.subSubCategory, + }); +} + +/// 물품 등록/수정 폼 다이얼로그 +class _GoodsFormDialog extends StatefulWidget { + final _GoodsItem? item; + const _GoodsFormDialog({this.item}); + @override + State<_GoodsFormDialog> createState() => _GoodsFormDialogState(); +} + +class _GoodsFormDialogState extends State<_GoodsFormDialog> { + final _formKey = GlobalKey(); + late String _name; + late String _manufacturer; + late String _category; + late String _subCategory; + late String _subSubCategory; + + late final MockDataService _dataService; + late final List _manufacturerList; + late final List _nameList; + late final List _categoryList; + late final List _subCategoryList; + late final List _subSubCategoryList; + + @override + void initState() { + super.initState(); + _name = widget.item?.name ?? ''; + _manufacturer = widget.item?.manufacturer ?? ''; + _category = widget.item?.category ?? ''; + _subCategory = widget.item?.subCategory ?? ''; + _subSubCategory = widget.item?.subSubCategory ?? ''; + _dataService = MockDataService(); + _manufacturerList = _dataService.getAllManufacturers(); + _nameList = _dataService.getAllEquipmentNames(); + _categoryList = _dataService.getAllCategories(); + _subCategoryList = _dataService.getAllSubCategories(); + _subSubCategoryList = _dataService.getAllSubSubCategories(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.item == null ? '신상품 등록' : '신상품 정보 수정', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + FormFieldWrapper( + label: '이름', + isRequired: true, + child: CategoryAutocompleteField( + hintText: '이름을 입력 또는 선택하세요', + value: _name, + items: _nameList, + isRequired: true, + onSelect: (v) => setState(() => _name = v), + ), + ), + FormFieldWrapper( + label: '제조사', + isRequired: true, + child: CategoryAutocompleteField( + hintText: '제조사를 입력 또는 선택하세요', + value: _manufacturer, + items: _manufacturerList, + isRequired: true, + onSelect: (v) => setState(() => _manufacturer = v), + ), + ), + FormFieldWrapper( + label: '대분류', + isRequired: true, + child: CategoryAutocompleteField( + hintText: '대분류를 입력 또는 선택하세요', + value: _category, + items: _categoryList, + isRequired: true, + onSelect: (v) => setState(() => _category = v), + ), + ), + FormFieldWrapper( + label: '중분류', + isRequired: true, + child: CategoryAutocompleteField( + hintText: '중분류를 입력 또는 선택하세요', + value: _subCategory, + items: _subCategoryList, + isRequired: true, + onSelect: (v) => setState(() => _subCategory = v), + ), + ), + FormFieldWrapper( + label: '소분류', + isRequired: true, + child: CategoryAutocompleteField( + hintText: '소분류를 입력 또는 선택하세요', + value: _subSubCategory, + items: _subSubCategoryList, + isRequired: true, + onSelect: (v) => setState(() => _subSubCategory = v), + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('취소'), + ), + const SizedBox(width: 8), + ElevatedButton( + style: AppThemeTailwind.primaryButtonStyle, + onPressed: () { + if (_formKey.currentState?.validate() ?? false) { + Navigator.of(context).pop( + _GoodsItem( + name: _name, + manufacturer: _manufacturer, + category: _category, + subCategory: _subCategory, + subSubCategory: _subSubCategory, + ), + ); + } + }, + child: Text(widget.item == null ? '등록' : '수정'), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/license/controllers/license_form_controller.dart b/lib/screens/license/controllers/license_form_controller.dart new file mode 100644 index 0000000..b77fbcb --- /dev/null +++ b/lib/screens/license/controllers/license_form_controller.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/license_model.dart'; +import 'package:superport/services/mock_data_service.dart'; + +// 라이센스 폼의 상태 및 비즈니스 로직을 담당하는 컨트롤러 +class LicenseFormController { + final MockDataService dataService; + final GlobalKey formKey = GlobalKey(); + + bool isEditMode = false; + int? licenseId; + String name = ''; + int durationMonths = 12; // 기본값: 12개월 + String visitCycle = '미방문'; // 기본값: 미방문 + + LicenseFormController({required this.dataService, this.licenseId}); + + // 라이센스 정보 로드 (수정 모드) + void loadLicense() { + if (licenseId == null) return; + final license = dataService.getLicenseById(licenseId!); + if (license != null) { + name = license.name; + durationMonths = license.durationMonths; + visitCycle = license.visitCycle; + } + } + + // 라이센스 저장 (UI에서 호출) + void saveLicense(Function() onSuccess) { + if (formKey.currentState?.validate() != true) return; + formKey.currentState?.save(); + if (isEditMode && licenseId != null) { + final license = dataService.getLicenseById(licenseId!); + if (license != null) { + final updatedLicense = License( + id: license.id, + companyId: license.companyId, + name: name, + durationMonths: durationMonths, + visitCycle: visitCycle, + ); + dataService.updateLicense(updatedLicense); + } + } else { + // 라이센스 추가 시 임시 회사 ID 사용 또는 나중에 설정하도록 변경 + final newLicense = License( + companyId: 1, // 기본값 또는 필요에 따라 수정 + name: name, + durationMonths: durationMonths, + visitCycle: visitCycle, + ); + dataService.addLicense(newLicense); + } + onSuccess(); + } +} diff --git a/lib/screens/license/controllers/license_list_controller.dart b/lib/screens/license/controllers/license_list_controller.dart new file mode 100644 index 0000000..3413445 --- /dev/null +++ b/lib/screens/license/controllers/license_list_controller.dart @@ -0,0 +1,21 @@ +import 'package:superport/models/license_model.dart'; +import 'package:superport/services/mock_data_service.dart'; + +// 라이센스 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러 +class LicenseListController { + final MockDataService dataService; + List licenses = []; + + LicenseListController({required this.dataService}); + + // 데이터 로드 + void loadData() { + licenses = dataService.getAllLicenses(); + } + + // 라이센스 삭제 + void deleteLicense(int id) { + dataService.deleteLicense(id); + loadData(); + } +} diff --git a/lib/screens/license/license_form.dart b/lib/screens/license/license_form.dart new file mode 100644 index 0000000..974d75d --- /dev/null +++ b/lib/screens/license/license_form.dart @@ -0,0 +1,262 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:superport/models/license_model.dart'; +import 'package:superport/screens/license/controllers/license_form_controller.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/screens/common/custom_widgets.dart'; +import 'package:superport/services/mock_data_service.dart'; +import 'package:superport/utils/validators.dart'; + +// 유지보수 등록/수정 화면 (UI만 담당, 상태/로직 분리) +class MaintenanceFormScreen extends StatefulWidget { + final int? maintenanceId; + const MaintenanceFormScreen({Key? key, this.maintenanceId}) : super(key: key); + + @override + _MaintenanceFormScreenState createState() => _MaintenanceFormScreenState(); +} + +class _MaintenanceFormScreenState extends State { + late final LicenseFormController _controller; + // 방문주기 드롭다운 옵션 + final List _visitCycleOptions = [ + '미방문', + '장애시 지원', + '월', + '격월', + '분기', + '반기', + '년', + ]; + // 점검형태 라디오 옵션 + final List _inspectionTypeOptions = ['방문', '원격']; + String _selectedVisitCycle = '미방문'; + String _selectedInspectionType = '방문'; + int _durationMonths = 12; + + @override + void initState() { + super.initState(); + _controller = LicenseFormController( + dataService: MockDataService(), + licenseId: widget.maintenanceId, + ); + _controller.isEditMode = widget.maintenanceId != null; + if (_controller.isEditMode) { + _controller.loadLicense(); + // TODO: 기존 데이터 로딩 시 _selectedVisitCycle, _selectedInspectionType, _durationMonths 값 세팅 필요 + } + } + + @override + Widget build(BuildContext context) { + // 유지보수 명은 유지보수기간, 방문주기, 점검형태를 결합해서 표기 + final String maintenanceName = + '${_durationMonths}개월,${_selectedVisitCycle},${_selectedInspectionType}'; + return Scaffold( + appBar: AppBar( + title: Text(_controller.isEditMode ? '유지보수 수정' : '유지보수 등록'), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _controller.formKey, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 유지보수 명 표기 (입력 불가, 자동 생성) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '유지보수 명', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 8, + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(4), + color: Colors.grey.shade100, + ), + child: Text( + maintenanceName, + style: const TextStyle(fontSize: 16), + ), + ), + ], + ), + ), + // 유지보수 기간 (개월) + _buildTextField( + label: '유지보수 기간 (개월)', + initialValue: _durationMonths.toString(), + hintText: '유지보수 기간을 입력하세요', + suffixText: '개월', + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + validator: (value) => validateNumber(value, '유지보수 기간'), + onChanged: (value) { + setState(() { + _durationMonths = int.tryParse(value ?? '') ?? 0; + }); + }, + ), + // 방문 주기 (드롭다운) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '방문 주기', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + DropdownButtonFormField( + value: _selectedVisitCycle, + items: + _visitCycleOptions + .map( + (option) => DropdownMenuItem( + value: option, + child: Text(option), + ), + ) + .toList(), + onChanged: (value) { + setState(() { + _selectedVisitCycle = value!; + }); + }, + decoration: const InputDecoration( + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 0, + ), + ), + validator: + (value) => + value == null || value.isEmpty + ? '방문 주기를 선택하세요' + : null, + ), + ], + ), + ), + // 점검 형태 (라디오버튼) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '점검 형태', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Row( + children: + _inspectionTypeOptions.map((type) { + return Row( + children: [ + Radio( + value: type, + groupValue: _selectedInspectionType, + onChanged: (value) { + setState(() { + _selectedInspectionType = value!; + }); + }, + ), + Text(type), + ], + ); + }).toList(), + ), + ], + ), + ), + const SizedBox(height: 24), + // 저장 버튼 + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + if (_controller.formKey.currentState!.validate()) { + _controller.formKey.currentState!.save(); + // 유지보수 명 결합하여 저장 + final String saveName = + '${_durationMonths}개월,${_selectedVisitCycle},${_selectedInspectionType}'; + _controller.name = saveName; + _controller.durationMonths = _durationMonths; + _controller.visitCycle = _selectedVisitCycle; + // 점검형태 저장 로직 필요 시 추가 + setState(() { + _controller.saveLicense(() { + Navigator.pop(context, true); + }); + }); + } + }, + style: AppThemeTailwind.primaryButtonStyle, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + _controller.isEditMode ? '수정하기' : '등록하기', + style: const TextStyle(fontSize: 16), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + // 공통 텍스트 필드 위젯 (onSaved → onChanged로 변경) + Widget _buildTextField({ + required String label, + required String initialValue, + required String hintText, + String? suffixText, + TextInputType? keyboardType, + List? inputFormatters, + required String? Function(String?) validator, + required void Function(String?) onChanged, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + TextFormField( + initialValue: initialValue, + decoration: InputDecoration( + hintText: hintText, + suffixText: suffixText, + ), + keyboardType: keyboardType, + inputFormatters: inputFormatters, + validator: validator, + onChanged: onChanged, + ), + ], + ), + ); + } +} diff --git a/lib/screens/license/license_list.dart b/lib/screens/license/license_list.dart new file mode 100644 index 0000000..9a58d25 --- /dev/null +++ b/lib/screens/license/license_list.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/license_model.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/screens/common/main_layout.dart'; +import 'package:superport/screens/common/custom_widgets.dart'; +import 'package:superport/services/mock_data_service.dart'; +import 'package:superport/utils/constants.dart'; +import 'package:superport/screens/license/controllers/license_list_controller.dart'; +import 'package:superport/screens/license/widgets/license_table.dart'; +import 'package:superport/screens/common/widgets/pagination.dart'; + +// 유지보수 목록 화면 (UI만 담당, 상태/로직/테이블 분리) +class MaintenanceListScreen extends StatefulWidget { + const MaintenanceListScreen({super.key}); + + @override + State createState() => _MaintenanceListScreenState(); +} + +// 유지보수 목록 화면의 상태 클래스 +class _MaintenanceListScreenState extends State { + late final LicenseListController _controller; + // 페이지네이션 상태 추가 + int _currentPage = 1; + final int _pageSize = 10; + + @override + void initState() { + super.initState(); + _controller = LicenseListController(dataService: MockDataService()); + _controller.loadData(); + } + + void _reload() { + setState(() { + _controller.loadData(); + }); + } + + void _navigateToAddScreen() async { + final result = await Navigator.pushNamed(context, '/license/add'); + if (result == true) { + _reload(); + } + } + + void _navigateToEditScreen(int id) async { + final result = await Navigator.pushNamed( + context, + '/license/edit', + arguments: id, + ); + if (result == true) { + _reload(); + } + } + + void _deleteLicense(int id) { + showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('삭제 확인'), + content: const Text('이 라이센스 정보를 삭제하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('취소'), + ), + TextButton( + onPressed: () { + setState(() { + _controller.deleteLicense(id); + }); + Navigator.pop(context); + }, + child: const Text('삭제'), + ), + ], + ), + ); + } + + // 회사명 반환 함수 (재사용성 위해 분리) + String _getCompanyName(int companyId) { + return MockDataService().getCompanyById(companyId)?.name ?? '-'; + } + + @override + Widget build(BuildContext context) { + // 대시보드 폭에 맞게 조정 + final screenWidth = MediaQuery.of(context).size.width; + final maxContentWidth = screenWidth > 1200 ? 1200.0 : screenWidth - 32; + + // 페이지네이션 데이터 슬라이싱 + final int totalCount = _controller.licenses.length; + final int startIndex = (_currentPage - 1) * _pageSize; + final int endIndex = + (startIndex + _pageSize) > totalCount + ? totalCount + : (startIndex + _pageSize); + final pagedLicenses = _controller.licenses.sublist(startIndex, endIndex); + + return MainLayout( + title: '유지보수 관리', + currentRoute: Routes.license, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _reload, + color: Colors.grey, + ), + ], + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PageTitle( + title: '유지보수 목록', + width: maxContentWidth - 32, + rightWidget: ElevatedButton.icon( + onPressed: _navigateToAddScreen, + icon: const Icon(Icons.add), + label: const Text('추가'), + style: AppThemeTailwind.primaryButtonStyle, + ), + ), + Expanded( + child: DataTableCard( + width: maxContentWidth - 32, + child: + pagedLicenses.isEmpty + ? const Center(child: Text('등록된 라이센스 정보가 없습니다.')) + : SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Container( + constraints: BoxConstraints( + minWidth: maxContentWidth - 64, + ), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: LicenseTable( + licenses: pagedLicenses, + getCompanyName: _getCompanyName, + onEdit: _navigateToEditScreen, + onDelete: _deleteLicense, + ), + ), + ), + ), + ), + ), + // 페이지네이션 위젯 추가 + if (totalCount > _pageSize) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Pagination( + totalCount: totalCount, + currentPage: _currentPage, + pageSize: _pageSize, + onPageChanged: (page) { + setState(() { + _currentPage = page; + }); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/license/widgets/license_table.dart b/lib/screens/license/widgets/license_table.dart new file mode 100644 index 0000000..3c3d5dc --- /dev/null +++ b/lib/screens/license/widgets/license_table.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/license_model.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; + +// 라이센스 목록 테이블 위젯 (SRP, 재사용성) +class LicenseTable extends StatelessWidget { + final List licenses; + final String Function(int companyId) getCompanyName; + final void Function(int id) onEdit; + final void Function(int id) onDelete; + + const LicenseTable({ + super.key, + required this.licenses, + required this.getCompanyName, + required this.onEdit, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return DataTable( + columns: const [ + DataColumn(label: Text('번호')), + DataColumn(label: Text('유지보수명')), + DataColumn(label: Text('기간')), + DataColumn(label: Text('방문주기')), + DataColumn(label: Text('점검형태')), + DataColumn(label: Text('관리')), + ], + rows: + licenses.map((license) { + // name에서 기간, 방문주기, 점검형태 파싱 (예: '12개월,격월,방문') + final parts = license.name.split(','); + final period = parts.isNotEmpty ? parts[0] : '-'; + final visit = parts.length > 1 ? parts[1] : '-'; + final inspection = parts.length > 2 ? parts[2] : '-'; + return DataRow( + cells: [ + DataCell(Text('${license.id}')), + DataCell(Text(license.name)), + DataCell(Text(period)), + DataCell(Text(visit)), + DataCell(Text(inspection)), + DataCell( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon( + Icons.edit, + color: AppThemeTailwind.primary, + ), + onPressed: () => onEdit(license.id!), + ), + IconButton( + icon: const Icon( + Icons.delete, + color: AppThemeTailwind.danger, + ), + onPressed: () => onDelete(license.id!), + ), + ], + ), + ), + ], + ); + }).toList(), + ); + } +} diff --git a/lib/screens/login/controllers/login_controller.dart b/lib/screens/login/controllers/login_controller.dart new file mode 100644 index 0000000..68ef78f --- /dev/null +++ b/lib/screens/login/controllers/login_controller.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +/// 로그인 화면의 상태 및 비즈니스 로직을 담당하는 ChangeNotifier 기반 컨트롤러 +class LoginController extends ChangeNotifier { + /// 아이디 입력 컨트롤러 + final TextEditingController idController = TextEditingController(); + + /// 비밀번호 입력 컨트롤러 + final TextEditingController pwController = TextEditingController(); + + /// 아이디 입력란 포커스 + final FocusNode idFocus = FocusNode(); + + /// 비밀번호 입력란 포커스 + final FocusNode pwFocus = FocusNode(); + + /// 아이디 저장 여부 + bool saveId = false; + + /// 아이디 저장 체크박스 상태 변경 + void setSaveId(bool value) { + saveId = value; + notifyListeners(); + } + + /// 로그인 처리 (샘플) + bool login() { + // 실제 인증 로직은 구현하지 않음 + // 항상 true 반환 (샘플) + return true; + } + + @override + void dispose() { + idController.dispose(); + pwController.dispose(); + idFocus.dispose(); + pwFocus.dispose(); + super.dispose(); + } +} diff --git a/lib/screens/login/login_screen.dart b/lib/screens/login/login_screen.dart new file mode 100644 index 0000000..aa191de --- /dev/null +++ b/lib/screens/login/login_screen.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/login/controllers/login_controller.dart'; +import 'package:superport/screens/login/widgets/login_view.dart'; + +/// 로그인 화면 진입점 (상태/로직은 controller, UI는 LoginView 위젯에 위임) +class LoginScreen extends StatefulWidget { + const LoginScreen({Key? key}) : super(key: key); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + late final LoginController _controller; + + @override + void initState() { + super.initState(); + // 로그인 컨트롤러 초기화 (필요시 DI 적용) + _controller = LoginController(); + } + + // 로그인 성공 시 콜백 (예: overview로 이동) + void _onLoginSuccess() { + Navigator.of(context).pushReplacementNamed('/home'); + } + + @override + Widget build(BuildContext context) { + return LoginView(controller: _controller, onLoginSuccess: _onLoginSuccess); + } +} diff --git a/lib/screens/login/widgets/login_view.dart b/lib/screens/login/widgets/login_view.dart new file mode 100644 index 0000000..9705c68 --- /dev/null +++ b/lib/screens/login/widgets/login_view.dart @@ -0,0 +1,301 @@ +import 'package:flutter/material.dart'; +import 'package:superport/utils/constants.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'dart:math' as math; +import 'package:wave/wave.dart'; +import 'package:wave/config.dart'; +import 'package:superport/screens/login/controllers/login_controller.dart'; +import 'package:provider/provider.dart'; + +/// 로그인 화면 진입점 위젯 (controller를 ChangeNotifierProvider로 주입) +class LoginView extends StatelessWidget { + final LoginController controller; + final VoidCallback onLoginSuccess; + const LoginView({ + Key? key, + required this.controller, + required this.onLoginSuccess, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: controller, + child: const _LoginViewBody(), + ); + } +} + +/// 로그인 화면 전체 레이아웃 및 애니메이션 배경 +class _LoginViewBody extends StatelessWidget { + const _LoginViewBody({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + // wave 패키지로 wavy liquid 애니메이션 배경 적용 + Positioned.fill( + child: WaveWidget( + config: CustomConfig( + gradients: [ + [Color(0xFFF7FAFC), Color(0xFFB6E0FE)], + [Color(0xFFB6E0FE), Color(0xFF3182CE)], + [Color(0xFF3182CE), Color(0xFF243B53)], + ], + durations: [4200, 5000, 7000], + heightPercentages: [0.18, 0.25, 0.38], + blur: const MaskFilter.blur(BlurStyle.solid, 8), + gradientBegin: Alignment.topLeft, + gradientEnd: Alignment.bottomRight, + ), + waveAmplitude: 18, + size: Size.infinite, + ), + ), + Center( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 40, + horizontal: 32, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.95), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 32, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: const [ + AnimatedBoatIcon(), + SizedBox(height: 32), + Text('supERPort', style: AppThemeTailwind.headingStyle), + SizedBox(height: 24), + LoginForm(), + SizedBox(height: 16), + SaveIdCheckbox(), + SizedBox(height: 32), + LoginButton(), + SizedBox(height: 48), + ], + ), + ), + ), + ), + ), + // 카피라이트를 화면 중앙 하단에 고정 + Positioned( + left: 0, + right: 0, + bottom: 32, + child: Center( + child: Opacity( + opacity: 0.7, + child: Text( + 'Copyright 2025 CClabs. All rights reserved.', + style: AppThemeTailwind.smallText.copyWith(fontSize: 13), + ), + ), + ), + ), + ], + ), + ); + } +} + +/// 요트 아이콘 애니메이션 위젯 +class AnimatedBoatIcon extends StatefulWidget { + final Color color; + final double size; + const AnimatedBoatIcon({ + Key? key, + this.color = const Color(0xFF3182CE), + this.size = 80, + }) : super(key: key); + @override + State createState() => _AnimatedBoatIconState(); +} + +class _AnimatedBoatIconState extends State + with TickerProviderStateMixin { + late AnimationController _boatGrowController; + late Animation _boatScaleAnim; + late AnimationController _boatFloatController; + late Animation _boatFloatAnim; + + @override + void initState() { + super.initState(); + _boatGrowController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + ); + _boatScaleAnim = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _boatGrowController, curve: Curves.elasticOut), + ); + _boatFloatController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1800), + ); + _boatFloatAnim = Tween(begin: -0.08, end: 0.08).animate( + CurvedAnimation(parent: _boatFloatController, curve: Curves.easeInOut), + ); + _boatGrowController.forward().then((_) { + _boatFloatController.repeat(reverse: true); + }); + } + + @override + void dispose() { + _boatGrowController.dispose(); + _boatFloatController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: Listenable.merge([_boatGrowController, _boatFloatController]), + builder: (context, child) { + final double scale = _boatScaleAnim.value; + final double angle = + (_boatGrowController.isCompleted) ? _boatFloatAnim.value : 0.0; + return Transform.translate( + offset: Offset( + (_boatGrowController.isCompleted) ? math.sin(angle) * 8 : 0, + 0, + ), + child: Transform.rotate( + angle: angle, + child: Transform.scale(scale: scale, child: child), + ), + ); + }, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: widget.color.withOpacity(0.18), + blurRadius: widget.size * 0.3, + offset: Offset(0, widget.size * 0.1), + ), + ], + ), + child: Icon( + Icons.directions_boat, + size: widget.size, + color: widget.color, + ), + ), + ); + } +} + +/// 로그인 입력 폼 위젯 (ID, PW) +class LoginForm extends StatelessWidget { + const LoginForm({Key? key}) : super(key: key); + @override + Widget build(BuildContext context) { + final controller = Provider.of(context); + return Column( + children: [ + TextField( + controller: controller.idController, + focusNode: controller.idFocus, + decoration: const InputDecoration( + labelText: 'ID', + border: OutlineInputBorder(), + ), + style: AppThemeTailwind.bodyStyle, + textInputAction: TextInputAction.next, + onSubmitted: (_) { + FocusScope.of(context).requestFocus(controller.pwFocus); + }, + ), + const SizedBox(height: 16), + TextField( + controller: controller.pwController, + focusNode: controller.pwFocus, + decoration: const InputDecoration( + labelText: 'PW', + border: OutlineInputBorder(), + ), + style: AppThemeTailwind.bodyStyle, + obscureText: true, + textInputAction: TextInputAction.done, + onSubmitted: (_) { + // 엔터 시 로그인 버튼에 포커스 이동 또는 로그인 시도 가능 + }, + ), + ], + ); + } +} + +/// 아이디 저장 체크박스 위젯 +class SaveIdCheckbox extends StatelessWidget { + const SaveIdCheckbox({Key? key}) : super(key: key); + @override + Widget build(BuildContext context) { + final controller = Provider.of(context); + return Row( + children: [ + Checkbox( + value: controller.saveId, + onChanged: (bool? value) { + controller.setSaveId(value ?? false); + }, + ), + Text('아이디 저장', style: AppThemeTailwind.bodyStyle), + ], + ); + } +} + +/// 로그인 버튼 위젯 +class LoginButton extends StatelessWidget { + const LoginButton({Key? key}) : super(key: key); + @override + Widget build(BuildContext context) { + final controller = Provider.of(context, listen: false); + final onLoginSuccess = + (context.findAncestorWidgetOfExactType() as LoginView) + .onLoginSuccess; + return SizedBox( + width: double.infinity, + child: ElevatedButton( + style: AppThemeTailwind.primaryButtonStyle.copyWith( + elevation: MaterialStateProperty.all(4), + shadowColor: MaterialStateProperty.all( + const Color(0xFF3182CE).withOpacity(0.18), + ), + ), + onPressed: () async { + final bool result = controller.login(); + if (!result) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('로그인에 실패했습니다.'))); + return; + } + // 로그인 성공 시 애니메이션 등은 필요시 별도 처리 + onLoginSuccess(); + }, + child: const Text('로그인'), + ), + ); + } +} diff --git a/lib/screens/overview/controllers/overview_controller.dart b/lib/screens/overview/controllers/overview_controller.dart new file mode 100644 index 0000000..9961fe4 --- /dev/null +++ b/lib/screens/overview/controllers/overview_controller.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:superport/services/mock_data_service.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; + +// 대시보드(Overview) 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러 +class OverviewController { + final MockDataService dataService; + + int totalCompanies = 0; + int totalUsers = 0; + int totalEquipmentIn = 0; + int totalEquipmentOut = 0; + int totalLicenses = 0; + + // 최근 활동 데이터 + List> recentActivities = []; + + OverviewController({required this.dataService}); + + // 데이터 로드 및 통계 계산 + void loadData() { + totalCompanies = dataService.getAllCompanies().length; + totalUsers = dataService.getAllUsers().length; + // 실제 서비스에서는 아래 메서드 구현 필요 + totalEquipmentIn = 32; // 임시 데이터 + totalEquipmentOut = 18; // 임시 데이터 + totalLicenses = dataService.getAllLicenses().length; + _loadRecentActivities(); + } + + // 최근 활동 데이터 로드 (임시 데이터) + void _loadRecentActivities() { + recentActivities = [ + { + 'type': '장비 입고', + 'title': '라우터 입고 처리 완료', + 'time': '30분 전', + 'user': '홍길동', + 'icon': Icons.input, + 'color': AppThemeTailwind.success, + }, + { + 'type': '사용자 추가', + 'title': '새 관리자 등록', + 'time': '1시간 전', + 'user': '김철수', + 'icon': Icons.person_add, + 'color': AppThemeTailwind.primary, + }, + { + 'type': '장비 출고', + 'title': '모니터 5대 출고 처리', + 'time': '2시간 전', + 'user': '이영희', + 'icon': Icons.output, + 'color': AppThemeTailwind.warning, + }, + ]; + } +} diff --git a/lib/screens/overview/overview_screen.dart b/lib/screens/overview/overview_screen.dart new file mode 100644 index 0000000..48886f3 --- /dev/null +++ b/lib/screens/overview/overview_screen.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/layout_components.dart'; +import 'package:superport/screens/common/main_layout.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/services/mock_data_service.dart'; +import 'package:superport/utils/constants.dart'; +import 'package:superport/screens/overview/controllers/overview_controller.dart'; +import 'package:superport/screens/overview/widgets/stats_grid.dart'; +import 'package:superport/screens/overview/widgets/recent_activities_list.dart'; + +// 대시보드(Overview) 화면 (UI만 담당, 상태/로직/위젯 분리) +class OverviewScreen extends StatefulWidget { + const OverviewScreen({Key? key}) : super(key: key); + + @override + _OverviewScreenState createState() => _OverviewScreenState(); +} + +class _OverviewScreenState extends State { + late final OverviewController _controller; + + @override + void initState() { + super.initState(); + _controller = OverviewController(dataService: MockDataService()); + _controller.loadData(); + } + + void _reload() { + setState(() { + _controller.loadData(); + }); + } + + @override + Widget build(BuildContext context) { + // 전체 배경색을 회색(AppThemeTailwind.surface)으로 지정 + return Container( + color: AppThemeTailwind.surface, // 회색 배경 + child: MainLayout( + title: '', // 타이틀 없음 + currentRoute: Routes.home, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _reload, + color: AppThemeTailwind.muted, + ), + IconButton( + icon: const Icon(Icons.notifications_none), + onPressed: () {}, + color: AppThemeTailwind.muted, + ), + IconButton( + icon: const Icon(Icons.logout), + tooltip: '로그아웃', + onPressed: () { + Navigator.of(context).pushReplacementNamed('/login'); + }, + color: AppThemeTailwind.muted, + ), + ], + child: SingleChildScrollView( + padding: EdgeInsets.zero, // 여백 0 + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 상단 경로 표기 완전 삭제 + // 하단부 전체를 감싸는 라운드 흰색 박스 + Container( + margin: const EdgeInsets.all(4), // 외부 여백만 적용 + decoration: BoxDecoration( + color: Colors.white, // 흰색 배경 + borderRadius: BorderRadius.circular(24), // 라운드 처리 + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + padding: const EdgeInsets.all(32), // 내부 여백 유지 + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 통계 카드 그리드 + Container( + margin: const EdgeInsets.only(bottom: 32), + child: StatsGrid( + totalCompanies: _controller.totalCompanies, + totalUsers: _controller.totalUsers, + totalLicenses: _controller.totalLicenses, + totalEquipmentIn: _controller.totalEquipmentIn, + totalEquipmentOut: _controller.totalEquipmentOut, + ), + ), + _buildActivitySection(), + const SizedBox(height: 32), + _buildRecentItemsSection(), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildActivitySection() { + // MetronicCard로 감싸고, 섹션 헤더 스타일 통일 + return MetronicCard( + title: '시스템 활동', + margin: const EdgeInsets.only(bottom: 32), + child: Column( + children: [ + _buildActivityChart(), + const SizedBox(height: 20), + const Divider(color: Color(0xFFF3F6F9)), + const SizedBox(height: 20), + _buildActivityLegend(), + ], + ), + ); + } + + Widget _buildActivityChart() { + // Metronic 스타일: 카드 내부 차트 영역, 라운드, 밝은 배경, 컬러 강조 + return Container( + height: 200, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: AppThemeTailwind.light, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.bar_chart, + size: 56, + color: AppThemeTailwind.primary, + ), + const SizedBox(height: 18), + Text('월별 장비 입/출고 추이', style: AppThemeTailwind.subheadingStyle), + const SizedBox(height: 10), + Text( + '실제 구현 시 차트 라이브러리 (fl_chart 등) 사용', + style: AppThemeTailwind.smallText, + ), + ], + ), + ); + } + + Widget _buildActivityLegend() { + // Metronic 스타일: 라운드, 컬러, 폰트 통일 + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildLegendItem('장비 입고', AppThemeTailwind.success), + const SizedBox(width: 32), + _buildLegendItem('장비 출고', AppThemeTailwind.warning), + const SizedBox(width: 32), + _buildLegendItem('라이센스 등록', AppThemeTailwind.info), + ], + ); + } + + Widget _buildLegendItem(String text, Color color) { + // Metronic 스타일: 컬러 원, 텍스트, 여백 + return Row( + children: [ + Container( + width: 14, + height: 14, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 10), + Text( + text, + style: AppThemeTailwind.smallText.copyWith( + fontWeight: FontWeight.w600, + color: AppThemeTailwind.dark, + ), + ), + ], + ); + } + + Widget _buildRecentItemsSection() { + // Metronic 스타일: 카드, 섹션 헤더, 리스트 여백/컬러 통일 + return MetronicCard( + title: '최근 활동', + child: Column( + children: [ + const Divider(indent: 0, endIndent: 0, color: Color(0xFFF3F6F9)), + const SizedBox(height: 16), + RecentActivitiesList(recentActivities: _controller.recentActivities), + const SizedBox(height: 8), + ], + ), + ); + } +} diff --git a/lib/screens/overview/widgets/recent_activities_list.dart b/lib/screens/overview/widgets/recent_activities_list.dart new file mode 100644 index 0000000..9e3ec6e --- /dev/null +++ b/lib/screens/overview/widgets/recent_activities_list.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; + +// 최근 활동 리스트 위젯 (SRP, 재사용성) +class RecentActivitiesList extends StatelessWidget { + final List> recentActivities; + const RecentActivitiesList({super.key, required this.recentActivities}); + + @override + Widget build(BuildContext context) { + return Column( + children: + recentActivities.map((activity) { + return Column( + children: [ + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: activity['color'] as Color, + shape: BoxShape.circle, + ), + child: Icon( + activity['icon'] as IconData, + color: Colors.white, + size: 20, + ), + ), + title: Text( + activity['title'] as String, + style: AppThemeTailwind.subheadingStyle, + ), + subtitle: Text( + '${activity['type']} • ${activity['user']}', + style: AppThemeTailwind.smallText, + ), + trailing: Text( + activity['time'] as String, + style: AppThemeTailwind.smallText.copyWith( + color: AppThemeTailwind.muted, + ), + ), + ), + if (activity != recentActivities.last) + const Divider( + height: 1, + indent: 68, + endIndent: 16, + color: (Color(0xFFEEEEF2)), + ), + ], + ); + }).toList(), + ); + } +} diff --git a/lib/screens/overview/widgets/stats_grid.dart b/lib/screens/overview/widgets/stats_grid.dart new file mode 100644 index 0000000..c7988a8 --- /dev/null +++ b/lib/screens/overview/widgets/stats_grid.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/screens/common/layout_components.dart'; + +// 대시보드 통계 카드 그리드 위젯 (SRP, 재사용성) +class StatsGrid extends StatelessWidget { + final int totalCompanies; + final int totalUsers; + final int totalLicenses; + final int totalEquipmentIn; + final int totalEquipmentOut; + + const StatsGrid({ + super.key, + required this.totalCompanies, + required this.totalUsers, + required this.totalLicenses, + required this.totalEquipmentIn, + required this.totalEquipmentOut, + }); + + @override + Widget build(BuildContext context) { + return GridView.count( + crossAxisCount: 3, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + shrinkWrap: true, + childAspectRatio: 2.5, + physics: const NeverScrollableScrollPhysics(), + children: [ + MetronicStatsCard( + title: '등록된 회사', + value: '$totalCompanies', + icon: Icons.business, + iconBackgroundColor: AppThemeTailwind.info, + showTrend: true, + trendPercentage: 2.5, + isPositiveTrend: true, + ), + MetronicStatsCard( + title: '등록된 사용자', + value: '$totalUsers', + icon: Icons.person, + iconBackgroundColor: AppThemeTailwind.primary, + showTrend: true, + trendPercentage: 3.7, + isPositiveTrend: true, + ), + MetronicStatsCard( + title: '유효 라이센스', + value: '$totalLicenses', + icon: Icons.vpn_key, + iconBackgroundColor: AppThemeTailwind.secondary, + ), + MetronicStatsCard( + title: '총 장비 입고', + value: '$totalEquipmentIn', + icon: Icons.input, + iconBackgroundColor: AppThemeTailwind.success, + showTrend: true, + trendPercentage: 1.8, + isPositiveTrend: true, + ), + MetronicStatsCard( + title: '총 장비 출고', + value: '$totalEquipmentOut', + icon: Icons.output, + iconBackgroundColor: AppThemeTailwind.warning, + ), + MetronicStatsCard( + title: '현재 재고', + value: '${totalEquipmentIn - totalEquipmentOut}', + icon: Icons.inventory_2, + iconBackgroundColor: AppThemeTailwind.danger, + showTrend: true, + trendPercentage: 0.7, + isPositiveTrend: false, + ), + ], + ); + } +} diff --git a/lib/screens/sidebar/sidebar_screen.dart b/lib/screens/sidebar/sidebar_screen.dart new file mode 100644 index 0000000..5ab8c47 --- /dev/null +++ b/lib/screens/sidebar/sidebar_screen.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:superport/utils/constants.dart'; +import 'package:superport/screens/sidebar/widgets/sidebar_menu_header.dart'; +import 'package:superport/screens/sidebar/widgets/sidebar_menu_footer.dart'; +import 'package:superport/screens/sidebar/widgets/sidebar_menu_item.dart'; +import 'package:superport/screens/sidebar/widgets/sidebar_menu_submenu.dart'; +import 'package:superport/screens/sidebar/widgets/sidebar_menu_types.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/screens/login/widgets/login_view.dart'; // AnimatedBoatIcon import +import 'package:wave/wave.dart'; +import 'package:wave/config.dart'; + +// 사이드바 메뉴 메인 위젯 (조립만 담당) +class SidebarMenu extends StatefulWidget { + final String currentRoute; + final Function(String) onRouteChanged; + + const SidebarMenu({ + super.key, + required this.currentRoute, + required this.onRouteChanged, + }); + + @override + State createState() => _SidebarMenuState(); +} + +class _SidebarMenuState extends State { + // 장비 관리 메뉴 확장 상태 + bool _isEquipmentMenuExpanded = false; + // hover 상태 관리 + String? _hoveredRoute; + + @override + void initState() { + super.initState(); + _updateExpandedState(); + } + + @override + void didUpdateWidget(SidebarMenu oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.currentRoute != widget.currentRoute) { + _updateExpandedState(); + } + } + + // 현재 경로에 따라 장비 관리 메뉴 확장 상태 업데이트 + void _updateExpandedState() { + final bool isEquipmentRoute = + widget.currentRoute == Routes.equipment || + widget.currentRoute == Routes.equipmentInList || + widget.currentRoute == Routes.equipmentOutList || + widget.currentRoute == Routes.equipmentRentList; + setState(() { + _isEquipmentMenuExpanded = isEquipmentRoute; + }); + } + + // 장비 관리 메뉴 확장/축소 토글 + void _toggleEquipmentMenu() { + setState(() { + _isEquipmentMenuExpanded = !_isEquipmentMenuExpanded; + }); + } + + @override + Widget build(BuildContext context) { + // SRP 분할: 각 역할별 위젯 조립 + return Container( + width: 260, + color: const Color(0xFFF4F6F8), // 연회색 배경 + child: Column( + children: [ + const SidebarMenuHeader(), + Expanded( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SidebarMenuItem( + icon: Icons.dashboard, + title: '대시보드', + route: Routes.home, + isActive: widget.currentRoute == Routes.home, + isHovered: _hoveredRoute == Routes.home, + onTap: () => widget.onRouteChanged(Routes.home), + ), + const SizedBox(height: 4), + SidebarMenuWithSubmenu( + icon: Icons.inventory, + title: '장비 관리', + route: Routes.equipment, + subItems: const [ + SidebarSubMenuItem( + title: '입고', + route: Routes.equipmentInList, + ), + SidebarSubMenuItem( + title: '출고', + route: Routes.equipmentOutList, + ), + SidebarSubMenuItem( + title: '대여', + route: Routes.equipmentRentList, + ), + ], + isExpanded: _isEquipmentMenuExpanded, + isMenuActive: widget.currentRoute == Routes.equipment, + isSubMenuActive: [ + Routes.equipmentInList, + Routes.equipmentOutList, + Routes.equipmentRentList, + ].contains(widget.currentRoute), + isHovered: _hoveredRoute == Routes.equipment, + onToggleExpanded: _toggleEquipmentMenu, + currentRoute: widget.currentRoute, + onRouteChanged: widget.onRouteChanged, + ), + const SizedBox(height: 4), + SidebarMenuItem( + icon: Icons.location_on, + title: '입고지 관리', + route: Routes.warehouseLocation, + isActive: widget.currentRoute == Routes.warehouseLocation, + isHovered: _hoveredRoute == Routes.warehouseLocation, + onTap: + () => widget.onRouteChanged(Routes.warehouseLocation), + ), + const SizedBox(height: 4), + SidebarMenuItem( + icon: Icons.business, + title: '회사 관리', + route: Routes.company, + isActive: widget.currentRoute == Routes.company, + isHovered: _hoveredRoute == Routes.company, + onTap: () => widget.onRouteChanged(Routes.company), + ), + const SizedBox(height: 4), + SidebarMenuItem( + icon: Icons.vpn_key, + title: '유지보수 관리', + route: Routes.license, + isActive: widget.currentRoute == Routes.license, + isHovered: _hoveredRoute == Routes.license, + onTap: () => widget.onRouteChanged(Routes.license), + ), + const SizedBox(height: 4), + SidebarMenuItem( + icon: Icons.category, + title: '물품 관리', + route: Routes.goods, + isActive: widget.currentRoute == Routes.goods, + isHovered: _hoveredRoute == Routes.goods, + onTap: () => widget.onRouteChanged(Routes.goods), + ), + ], + ), + ), + ), + ), + const SidebarMenuFooter(), + ], + ), + ); + } +} diff --git a/lib/screens/sidebar/widgets/sidebar_menu_footer.dart b/lib/screens/sidebar/widgets/sidebar_menu_footer.dart new file mode 100644 index 0000000..f06b439 --- /dev/null +++ b/lib/screens/sidebar/widgets/sidebar_menu_footer.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +// 사이드바 푸터 위젯 +class SidebarMenuFooter extends StatelessWidget { + const SidebarMenuFooter({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + height: 48, + alignment: Alignment.center, + child: const Text( + '© 2025 CClabs. All rights reserved.', + style: TextStyle(fontSize: 11, color: Colors.black), // 블랙으로 변경 + ), + ); + } +} diff --git a/lib/screens/sidebar/widgets/sidebar_menu_header.dart b/lib/screens/sidebar/widgets/sidebar_menu_header.dart new file mode 100644 index 0000000..2c2e9d1 --- /dev/null +++ b/lib/screens/sidebar/widgets/sidebar_menu_header.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:wave/wave.dart'; +import 'package:wave/config.dart'; +import 'package:superport/screens/login/widgets/login_view.dart'; // AnimatedBoatIcon import + +// 사이드바 헤더 위젯 +class SidebarMenuHeader extends StatelessWidget { + const SidebarMenuHeader({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + height: 88, + width: double.infinity, + padding: const EdgeInsets.only(left: 0, right: 0), // 아이콘을 더 좌측으로 + child: Stack( + alignment: Alignment.centerLeft, + children: [ + // Wave 배경 + Positioned.fill( + child: Opacity( + opacity: 0.50, // subtle하게 + child: WaveWidget( + config: CustomConfig( + gradients: [ + [Color(0xFFB6E0FE), Color(0xFF3182CE)], + [ + Color.fromARGB(255, 31, 83, 132), + Color.fromARGB(255, 9, 49, 92), + ], + ], + durations: [4800, 6000], + heightPercentages: [0.48, 0.38], + blur: const MaskFilter.blur(BlurStyle.solid, 6), + gradientBegin: Alignment.topLeft, + gradientEnd: Alignment.bottomRight, + ), + waveAmplitude: 8, + size: Size.infinite, + ), + ), + ), + // 아이콘+텍스트 + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(width: 24), // 아이콘을 더 좌측으로 + SizedBox( + width: 36, + height: 36, + child: AnimatedBoatIcon(color: Colors.white, size: 60), + ), + const SizedBox(width: 24), + const Text( + 'supERPort', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + letterSpacing: -2.5, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/screens/sidebar/widgets/sidebar_menu_item.dart b/lib/screens/sidebar/widgets/sidebar_menu_item.dart new file mode 100644 index 0000000..afac107 --- /dev/null +++ b/lib/screens/sidebar/widgets/sidebar_menu_item.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; + +// 단일 메뉴 아이템 위젯 +class SidebarMenuItem extends StatelessWidget { + final IconData icon; + final String title; + final String route; + final bool isActive; + final bool isHovered; + final bool isSubItem; + final VoidCallback onTap; + + const SidebarMenuItem({ + super.key, + required this.icon, + required this.title, + required this.route, + required this.isActive, + required this.isHovered, + this.isSubItem = false, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Container( + height: 44, + alignment: Alignment.centerLeft, + margin: const EdgeInsets.symmetric( + vertical: 2, + horizontal: 6, + ), // 외부 여백 + padding: EdgeInsets.only(left: isSubItem ? 48 : 24, right: 24), + decoration: BoxDecoration( + color: + isActive + ? Colors.white + : (isHovered + ? const Color(0xFFE9EDF2) + : Colors.transparent), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Icon( + icon, + size: 18, + color: + isActive ? AppThemeTailwind.primary : AppThemeTailwind.dark, + ), + const SizedBox(width: 10), + Text( + title, + style: TextStyle( + color: + isActive + ? AppThemeTailwind.primary + : AppThemeTailwind.dark, + fontWeight: isActive ? FontWeight.bold : FontWeight.normal, + fontSize: 14, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/sidebar/widgets/sidebar_menu_submenu.dart b/lib/screens/sidebar/widgets/sidebar_menu_submenu.dart new file mode 100644 index 0000000..9f7b408 --- /dev/null +++ b/lib/screens/sidebar/widgets/sidebar_menu_submenu.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/sidebar/widgets/sidebar_menu_item.dart'; +import 'package:superport/screens/sidebar/widgets/sidebar_menu_types.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; + +// 서브메뉴(확장/축소, 하위 아이템) 위젯 +class SidebarMenuWithSubmenu extends StatelessWidget { + final IconData icon; + final String title; + final String route; + final List subItems; + final bool isExpanded; + final bool isMenuActive; + final bool isSubMenuActive; + final bool isHovered; + final VoidCallback onToggleExpanded; + final String currentRoute; + final void Function(String) onRouteChanged; + + const SidebarMenuWithSubmenu({ + super.key, + required this.icon, + required this.title, + required this.route, + required this.subItems, + required this.isExpanded, + required this.isMenuActive, + required this.isSubMenuActive, + required this.isHovered, + required this.onToggleExpanded, + required this.currentRoute, + required this.onRouteChanged, + }); + + @override + Widget build(BuildContext context) { + final bool isHighlighted = isMenuActive || isSubMenuActive; + return Column( + children: [ + MouseRegion( + cursor: SystemMouseCursors.click, + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () { + onToggleExpanded(); + onRouteChanged(route); + }, + child: Container( + height: 44, + margin: const EdgeInsets.symmetric(vertical: 2, horizontal: 6), + padding: const EdgeInsets.only(left: 24, right: 24), + decoration: BoxDecoration( + color: + isMenuActive + ? Colors.white + : (isHovered + ? const Color(0xFFE9EDF2) + : Colors.transparent), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Icon( + icon, + size: 18, + color: + isHighlighted + ? AppThemeTailwind.primary + : AppThemeTailwind.dark, + ), + const SizedBox(width: 10), + Text( + title, + style: TextStyle( + color: + isHighlighted + ? AppThemeTailwind.primary + : AppThemeTailwind.dark, + fontWeight: + isHighlighted ? FontWeight.bold : FontWeight.normal, + fontSize: 14, + ), + ), + const Spacer(), + Icon( + isExpanded + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + size: 20, + color: AppThemeTailwind.muted, + ), + ], + ), + ), + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: isExpanded ? 1 : 0, + child: Column( + children: + subItems.map((item) { + return SidebarMenuItem( + icon: Icons.circle, + title: item.title, + route: item.route, + isActive: currentRoute == item.route, + isHovered: false, // hover는 상위에서 관리 + isSubItem: true, + onTap: () => onRouteChanged(item.route), + ); + }).toList(), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/sidebar/widgets/sidebar_menu_types.dart b/lib/screens/sidebar/widgets/sidebar_menu_types.dart new file mode 100644 index 0000000..43cd7ad --- /dev/null +++ b/lib/screens/sidebar/widgets/sidebar_menu_types.dart @@ -0,0 +1,11 @@ +// 서브메뉴 아이템 타입 정의 파일 +// 이 파일은 사이드바 메뉴에서 사용하는 서브메뉴 아이템 타입만 정의합니다. + +class SidebarSubMenuItem { + // 서브메뉴의 제목 + final String title; + // 서브메뉴의 라우트 + final String route; + + const SidebarSubMenuItem({required this.title, required this.route}); +} diff --git a/lib/screens/user/controllers/user_form_controller.dart b/lib/screens/user/controllers/user_form_controller.dart new file mode 100644 index 0000000..e0ce99e --- /dev/null +++ b/lib/screens/user/controllers/user_form_controller.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/company_model.dart'; +import 'package:superport/models/user_model.dart'; +import 'package:superport/services/mock_data_service.dart'; +import 'package:superport/utils/constants.dart'; +import 'package:superport/models/user_phone_field.dart'; + +// 사용자 폼의 상태 및 비즈니스 로직을 담당하는 컨트롤러 +class UserFormController { + final MockDataService dataService; + final GlobalKey formKey = GlobalKey(); + + bool isEditMode = false; + int? userId; + String name = ''; + int? companyId; + int? branchId; + String role = UserRoles.member; + String position = ''; + String email = ''; + + // 전화번호 관련 상태 + final List phoneFields = []; + final List phoneTypes = ['휴대폰', '사무실', '팩스', '기타']; + + List companies = []; + List branches = []; + + UserFormController({required this.dataService, this.userId}); + + // 회사 목록 로드 + void loadCompanies() { + companies = dataService.getAllCompanies(); + } + + // 회사 ID에 따라 지점 목록 로드 + void loadBranches(int companyId) { + final company = dataService.getCompanyById(companyId); + branches = company?.branches ?? []; + // 지점 변경 시 이전 선택 지점이 새 회사에 없으면 초기화 + if (branchId != null && !branches.any((b) => b.id == branchId)) { + branchId = null; + } + } + + // 사용자 정보 로드 (수정 모드) + void loadUser() { + if (userId == null) return; + final user = dataService.getUserById(userId!); + if (user != null) { + name = user.name; + companyId = user.companyId; + branchId = user.branchId; + role = user.role; + position = user.position ?? ''; + email = user.email ?? ''; + if (companyId != null) { + loadBranches(companyId!); + } + phoneFields.clear(); + if (user.phoneNumbers.isNotEmpty) { + for (var phone in user.phoneNumbers) { + phoneFields.add( + UserPhoneField( + type: phone['type'] ?? '휴대폰', + initialValue: phone['number'] ?? '', + ), + ); + } + } else { + addPhoneField(); + } + } + } + + // 전화번호 필드 추가 + void addPhoneField() { + phoneFields.add(UserPhoneField(type: '휴대폰')); + } + + // 전화번호 필드 삭제 + void removePhoneField(int index) { + if (phoneFields.length > 1) { + phoneFields[index].dispose(); + phoneFields.removeAt(index); + } + } + + // 사용자 저장 (UI에서 호출) + void saveUser(Function(String? error) onResult) { + if (formKey.currentState?.validate() != true) { + onResult('폼 유효성 검사 실패'); + return; + } + formKey.currentState?.save(); + if (companyId == null) { + onResult('소속 회사를 선택해주세요'); + return; + } + // 전화번호 목록 준비 (UserPhoneField 기반) + List> phoneNumbersList = []; + for (var phoneField in phoneFields) { + if (phoneField.number.isNotEmpty) { + phoneNumbersList.add({ + 'type': phoneField.type, + 'number': phoneField.number, + }); + } + } + if (isEditMode && userId != null) { + final user = dataService.getUserById(userId!); + if (user != null) { + final updatedUser = User( + id: user.id, + companyId: companyId!, + branchId: branchId, + name: name, + role: role, + position: position.isNotEmpty ? position : null, + email: email.isNotEmpty ? email : null, + phoneNumbers: phoneNumbersList, + ); + dataService.updateUser(updatedUser); + } + } else { + final newUser = User( + companyId: companyId!, + branchId: branchId, + name: name, + role: role, + position: position.isNotEmpty ? position : null, + email: email.isNotEmpty ? email : null, + phoneNumbers: phoneNumbersList, + ); + dataService.addUser(newUser); + } + onResult(null); + } + + // 컨트롤러 해제 + void dispose() { + for (var phoneField in phoneFields) { + phoneField.dispose(); + } + } +} diff --git a/lib/screens/user/controllers/user_list_controller.dart b/lib/screens/user/controllers/user_list_controller.dart new file mode 100644 index 0000000..d02ef80 --- /dev/null +++ b/lib/screens/user/controllers/user_list_controller.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/user_model.dart'; +import 'package:superport/models/company_model.dart'; +import 'package:superport/services/mock_data_service.dart'; +import 'package:superport/utils/constants.dart'; +import 'package:superport/utils/user_utils.dart'; + +/// 담당자 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러 +class UserListController extends ChangeNotifier { + final MockDataService dataService; + List users = []; + + UserListController({required this.dataService}); + + /// 사용자 목록 데이터 로드 + void loadUsers() { + users = dataService.getAllUsers(); + notifyListeners(); + } + + /// 사용자 삭제 + void deleteUser(int id, VoidCallback onDeleted) { + dataService.deleteUser(id); + loadUsers(); + onDeleted(); + } + + /// 권한명 반환 함수는 user_utils.dart의 getRoleName을 사용 + + /// 회사 ID와 지점 ID로 지점명 조회 + String getBranchName(int companyId, int? branchId) { + final company = dataService.getCompanyById(companyId); + if (company == null || company.branches == null || branchId == null) { + return '-'; + } + final branch = company.branches!.firstWhere( + (b) => b.id == branchId, + orElse: () => Branch(companyId: companyId, name: '-'), + ); + return branch.name; + } +} diff --git a/lib/screens/user/user_form.dart b/lib/screens/user/user_form.dart new file mode 100644 index 0000000..1c1df08 --- /dev/null +++ b/lib/screens/user/user_form.dart @@ -0,0 +1,293 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/company_model.dart'; +import 'package:superport/models/user_model.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/screens/common/custom_widgets.dart'; +import 'package:superport/services/mock_data_service.dart'; +import 'package:superport/utils/constants.dart'; +import 'package:superport/utils/validators.dart'; +import 'package:flutter/services.dart'; +import 'package:superport/screens/user/controllers/user_form_controller.dart'; +import 'package:superport/models/user_phone_field.dart'; +import 'package:superport/screens/common/widgets/company_branch_dropdown.dart'; + +// 사용자 등록/수정 화면 (UI만 담당, 상태/로직 분리) +class UserFormScreen extends StatefulWidget { + final int? userId; + const UserFormScreen({super.key, this.userId}); + + @override + State createState() => _UserFormScreenState(); +} + +class _UserFormScreenState extends State { + late final UserFormController _controller; + + @override + void initState() { + super.initState(); + _controller = UserFormController( + dataService: MockDataService(), + userId: widget.userId, + ); + _controller.isEditMode = widget.userId != null; + _controller.loadCompanies(); + if (_controller.isEditMode) { + _controller.loadUser(); + } else if (_controller.phoneFields.isEmpty) { + _controller.addPhoneField(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(_controller.isEditMode ? '사용자 수정' : '사용자 등록')), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _controller.formKey, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 이름 + _buildTextField( + label: '이름', + initialValue: _controller.name, + hintText: '사용자 이름을 입력하세요', + validator: (value) => validateRequired(value, '이름'), + onSaved: (value) => _controller.name = value!, + ), + // 직급 + _buildTextField( + label: '직급', + initialValue: _controller.position, + hintText: '직급을 입력하세요', + onSaved: (value) => _controller.position = value ?? '', + ), + // 소속 회사/지점 + CompanyBranchDropdown( + companies: _controller.companies, + selectedCompanyId: _controller.companyId, + selectedBranchId: _controller.branchId, + branches: _controller.branches, + onCompanyChanged: (value) { + setState(() { + _controller.companyId = value; + _controller.branchId = null; + if (value != null) { + _controller.loadBranches(value); + } else { + _controller.branches = []; + } + }); + }, + onBranchChanged: (value) { + setState(() { + _controller.branchId = value; + }); + }, + ), + // 이메일 + _buildTextField( + label: '이메일', + initialValue: _controller.email, + hintText: '이메일을 입력하세요', + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.isEmpty) return null; + return validateEmail(value); + }, + onSaved: (value) => _controller.email = value ?? '', + ), + // 전화번호 + _buildPhoneFieldsSection(), + // 권한 + _buildRoleRadio(), + const SizedBox(height: 24), + // 저장 버튼 + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _onSaveUser, + style: AppThemeTailwind.primaryButtonStyle, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + _controller.isEditMode ? '수정하기' : '등록하기', + style: const TextStyle(fontSize: 16), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + // 이름/직급/이메일 등 공통 텍스트 필드 위젯 + Widget _buildTextField({ + required String label, + required String initialValue, + required String hintText, + TextInputType? keyboardType, + List? inputFormatters, + String? Function(String?)? validator, + void Function(String?)? onSaved, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + TextFormField( + initialValue: initialValue, + decoration: InputDecoration(hintText: hintText), + keyboardType: keyboardType, + inputFormatters: inputFormatters, + validator: validator, + onSaved: onSaved, + ), + ], + ), + ); + } + + // 전화번호 입력 필드 섹션 위젯 (UserPhoneField 기반) + Widget _buildPhoneFieldsSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('전화번호', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + ..._controller.phoneFields.asMap().entries.map((entry) { + final i = entry.key; + final phoneField = entry.value; + return Row( + children: [ + // 종류 드롭다운 + DropdownButton( + value: phoneField.type, + items: + _controller.phoneTypes + .map( + (type) => + DropdownMenuItem(value: type, child: Text(type)), + ) + .toList(), + onChanged: (value) { + setState(() { + phoneField.type = value!; + }); + }, + ), + const SizedBox(width: 8), + // 번호 입력 + Expanded( + child: TextFormField( + controller: phoneField.controller, + keyboardType: TextInputType.phone, + decoration: const InputDecoration(hintText: '전화번호'), + onSaved: (value) {}, // 값은 controller에서 직접 추출 + ), + ), + IconButton( + icon: const Icon(Icons.remove_circle, color: Colors.red), + onPressed: + _controller.phoneFields.length > 1 + ? () { + setState(() { + _controller.removePhoneField(i); + }); + } + : null, + ), + ], + ); + }), + // 추가 버튼 + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () { + setState(() { + _controller.addPhoneField(); + }); + }, + icon: const Icon(Icons.add), + label: const Text('전화번호 추가'), + ), + ), + ], + ); + } + + // 권한(관리등급) 라디오 위젯 + Widget _buildRoleRadio() { + return Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('권한', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: RadioListTile( + title: const Text('관리자'), + value: UserRoles.admin, + groupValue: _controller.role, + onChanged: (value) { + setState(() { + _controller.role = value!; + }); + }, + ), + ), + Expanded( + child: RadioListTile( + title: const Text('일반 사용자'), + value: UserRoles.member, + groupValue: _controller.role, + onChanged: (value) { + setState(() { + _controller.role = value!; + }); + }, + ), + ), + ], + ), + ], + ), + ); + } + + // 저장 버튼 클릭 시 사용자 저장 + void _onSaveUser() { + setState(() { + _controller.saveUser((error) { + if (error != null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + } else { + Navigator.pop(context, true); + } + }); + }); + } +} diff --git a/lib/screens/user/user_list.dart b/lib/screens/user/user_list.dart new file mode 100644 index 0000000..be56002 --- /dev/null +++ b/lib/screens/user/user_list.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/company_model.dart'; +import 'package:superport/models/user_model.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/screens/common/main_layout.dart'; +import 'package:superport/screens/common/custom_widgets.dart'; +import 'package:superport/services/mock_data_service.dart'; +import 'package:superport/utils/constants.dart'; +import 'package:superport/screens/user/controllers/user_list_controller.dart'; +import 'package:superport/screens/user/widgets/user_table.dart'; +import 'package:superport/utils/user_utils.dart'; +import 'package:superport/screens/common/widgets/pagination.dart'; + +// 담당자 목록 화면 (UI만 담당) +class UserListScreen extends StatefulWidget { + const UserListScreen({super.key}); + + @override + State createState() => _UserListScreenState(); +} + +class _UserListScreenState extends State { + late final UserListController _controller; + final MockDataService _dataService = MockDataService(); + // 페이지네이션 상태 추가 + int _currentPage = 1; + final int _pageSize = 10; + + @override + void initState() { + super.initState(); + _controller = UserListController(dataService: _dataService); + _controller.loadUsers(); + _controller.addListener(_refresh); + } + + @override + void dispose() { + _controller.removeListener(_refresh); + super.dispose(); + } + + // 상태 갱신용 setState 래퍼 + void _refresh() { + setState(() {}); + } + + // 사용자 추가 화면 이동 + void _navigateToAddScreen() async { + final result = await Navigator.pushNamed(context, '/user/add'); + if (result == true) { + _controller.loadUsers(); + } + } + + // 사용자 수정 화면 이동 + void _navigateToEditScreen(int id) async { + final result = await Navigator.pushNamed( + context, + '/user/edit', + arguments: id, + ); + if (result == true) { + _controller.loadUsers(); + } + } + + // 사용자 삭제 다이얼로그 + void _showDeleteDialog(int id) { + showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('삭제 확인'), + content: const Text('이 사용자 정보를 삭제하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('취소'), + ), + TextButton( + onPressed: () { + _controller.deleteUser(id, () { + Navigator.pop(context); + }); + }, + child: const Text('삭제'), + ), + ], + ), + ); + } + + // 회사명 반환 함수 (내부에서만 사용) + String _getCompanyName(int companyId) { + final company = _dataService.getCompanyById(companyId); + return company?.name ?? '-'; + } + + @override + Widget build(BuildContext context) { + // 대시보드 폭에 맞게 조정 + final screenWidth = MediaQuery.of(context).size.width; + final maxContentWidth = screenWidth > 1200 ? 1200.0 : screenWidth - 32; + + // 페이지네이션 데이터 슬라이싱 + final int totalCount = _controller.users.length; + final int startIndex = (_currentPage - 1) * _pageSize; + final int endIndex = + (startIndex + _pageSize) > totalCount + ? totalCount + : (startIndex + _pageSize); + final pagedUsers = _controller.users.sublist(startIndex, endIndex); + + return MainLayout( + title: '담당자 관리', + currentRoute: Routes.user, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _controller.loadUsers, + color: Colors.grey, + ), + ], + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PageTitle( + title: '담당자 목록', + width: maxContentWidth - 32, + rightWidget: ElevatedButton.icon( + onPressed: _navigateToAddScreen, + icon: const Icon(Icons.add), + label: const Text('추가'), + style: AppThemeTailwind.primaryButtonStyle, + ), + ), + Expanded( + child: DataTableCard( + width: maxContentWidth - 32, + child: UserTable( + users: pagedUsers, + width: maxContentWidth - 32, + getRoleName: getRoleName, + getBranchName: _controller.getBranchName, + getCompanyName: _getCompanyName, + onEdit: _navigateToEditScreen, + onDelete: _showDeleteDialog, + ), + ), + ), + // 페이지네이션 위젯 추가 + if (totalCount > _pageSize) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Pagination( + totalCount: totalCount, + currentPage: _currentPage, + pageSize: _pageSize, + onPageChanged: (page) { + setState(() { + _currentPage = page; + }); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/user/widgets/user_table.dart b/lib/screens/user/widgets/user_table.dart new file mode 100644 index 0000000..de3f712 --- /dev/null +++ b/lib/screens/user/widgets/user_table.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/user_model.dart'; + +/// 사용자 목록 테이블 위젯 (SRP, 재사용성 중심) +class UserTable extends StatelessWidget { + final List users; + final double width; + final String Function(String role) getRoleName; + final String Function(int companyId, int? branchId) getBranchName; + final String Function(int companyId) getCompanyName; + final void Function(int userId) onEdit; + final void Function(int userId) onDelete; + + const UserTable({ + super.key, + required this.users, + required this.width, + required this.getRoleName, + required this.getBranchName, + required this.getCompanyName, + required this.onEdit, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return users.isEmpty + ? const Center(child: Text('등록된 사용자 정보가 없습니다.')) + : SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Container( + constraints: BoxConstraints(minWidth: width - 32), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: DataTable( + columns: const [ + DataColumn(label: Text('번호')), + DataColumn(label: Text('이름')), + DataColumn(label: Text('직급')), + DataColumn(label: Text('소속 회사')), + DataColumn(label: Text('소속 지점')), + DataColumn(label: Text('이메일')), + DataColumn(label: Text('전화번호')), + DataColumn(label: Text('권한')), + DataColumn(label: Text('관리')), + ], + rows: + users.map((user) { + return DataRow( + cells: [ + DataCell(Text('${user.id}')), + DataCell(Text(user.name)), + DataCell(Text(user.position ?? '-')), + DataCell(Text(getCompanyName(user.companyId))), + DataCell( + Text( + user.branchId != null + ? getBranchName(user.companyId, user.branchId) + : '-', + ), + ), + DataCell(Text(user.email ?? '-')), + DataCell( + user.phoneNumbers.isNotEmpty + ? Text(user.phoneNumbers.first['number'] ?? '-') + : const Text('-'), + ), + DataCell(Text(getRoleName(user.role))), + DataCell( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon( + Icons.edit, + color: Colors.blue, + ), + onPressed: () => onEdit(user.id!), + ), + IconButton( + icon: const Icon( + Icons.delete, + color: Colors.red, + ), + onPressed: () => onDelete(user.id!), + ), + ], + ), + ), + ], + ); + }).toList(), + ), + ), + ), + ); + } +} diff --git a/lib/screens/warehouse_location/controllers/warehouse_location_form_controller.dart b/lib/screens/warehouse_location/controllers/warehouse_location_form_controller.dart new file mode 100644 index 0000000..45ac38c --- /dev/null +++ b/lib/screens/warehouse_location/controllers/warehouse_location_form_controller.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/warehouse_location_model.dart'; +import 'package:superport/models/address_model.dart'; +import 'package:superport/services/mock_data_service.dart'; + +/// 입고지 폼 상태 및 저장/수정 로직을 담당하는 컨트롤러 +class WarehouseLocationFormController { + /// 폼 키 + final GlobalKey formKey = GlobalKey(); + + /// 입고지명 입력 컨트롤러 + final TextEditingController nameController = TextEditingController(); + + /// 비고 입력 컨트롤러 + final TextEditingController remarkController = TextEditingController(); + + /// 주소 정보 + Address address = const Address(); + + /// 저장 중 여부 + bool isSaving = false; + + /// 수정 모드 여부 + bool isEditMode = false; + + /// 입고지 id (수정 모드) + int? id; + + /// 기존 데이터 세팅 (수정 모드) + void initialize(int? locationId) { + id = locationId; + if (id != null) { + final location = MockDataService().getWarehouseLocationById(id!); + if (location != null) { + isEditMode = true; + nameController.text = location.name; + address = location.address; + remarkController.text = location.remark ?? ''; + } + } + } + + /// 주소 변경 처리 + void updateAddress(Address newAddress) { + address = newAddress; + } + + /// 저장 처리 (추가/수정) + Future save(BuildContext context) async { + if (!formKey.currentState!.validate()) return false; + isSaving = true; + if (isEditMode) { + // 수정 + MockDataService().updateWarehouseLocation( + WarehouseLocation( + id: id!, + name: nameController.text.trim(), + address: address, + remark: remarkController.text.trim(), + ), + ); + } else { + // 추가 + MockDataService().addWarehouseLocation( + WarehouseLocation( + id: 0, + name: nameController.text.trim(), + address: address, + remark: remarkController.text.trim(), + ), + ); + } + isSaving = false; + Navigator.pop(context, true); + return true; + } + + /// 취소 처리 + void cancel(BuildContext context) { + Navigator.pop(context, false); + } + + /// 컨트롤러 해제 + void dispose() { + nameController.dispose(); + remarkController.dispose(); + } +} diff --git a/lib/screens/warehouse_location/controllers/warehouse_location_list_controller.dart b/lib/screens/warehouse_location/controllers/warehouse_location_list_controller.dart new file mode 100644 index 0000000..3920b2d --- /dev/null +++ b/lib/screens/warehouse_location/controllers/warehouse_location_list_controller.dart @@ -0,0 +1,36 @@ +import 'package:superport/models/warehouse_location_model.dart'; +import 'package:superport/services/mock_data_service.dart'; + +/// 입고지 리스트 상태 및 CRUD만 담당하는 컨트롤러 클래스 (SRP 적용) +/// UI, 네비게이션, 다이얼로그 등은 포함하지 않음 +/// 향후 서비스/리포지토리 DI 구조로 확장 가능 +class WarehouseLocationListController { + /// 입고지 데이터 서비스 (mock) + final MockDataService _dataService = MockDataService(); + + /// 전체 입고지 목록 + List warehouseLocations = []; + + /// 데이터 로드 + void loadWarehouseLocations() { + warehouseLocations = _dataService.getAllWarehouseLocations(); + } + + /// 입고지 추가 + void addWarehouseLocation(WarehouseLocation location) { + _dataService.addWarehouseLocation(location); + loadWarehouseLocations(); + } + + /// 입고지 수정 + void updateWarehouseLocation(WarehouseLocation location) { + _dataService.updateWarehouseLocation(location); + loadWarehouseLocations(); + } + + /// 입고지 삭제 + void deleteWarehouseLocation(int id) { + _dataService.deleteWarehouseLocation(id); + loadWarehouseLocations(); + } +} diff --git a/lib/screens/warehouse_location/warehouse_location_form.dart b/lib/screens/warehouse_location/warehouse_location_form.dart new file mode 100644 index 0000000..3528984 --- /dev/null +++ b/lib/screens/warehouse_location/warehouse_location_form.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/address_model.dart'; +import 'package:superport/screens/common/widgets/address_input.dart'; +import 'package:superport/screens/common/widgets/remark_input.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; +import 'package:superport/utils/constants.dart'; +import 'controllers/warehouse_location_form_controller.dart'; + +/// 입고지 추가/수정 폼 화면 (SRP 적용, 상태/로직 분리) +class WarehouseLocationFormScreen extends StatefulWidget { + final int? id; // 수정 모드 지원을 위한 id 파라미터 + const WarehouseLocationFormScreen({Key? key, this.id}) : super(key: key); + + @override + State createState() => + _WarehouseLocationFormScreenState(); +} + +class _WarehouseLocationFormScreenState + extends State { + /// 폼 컨트롤러 (상태 및 저장/수정 로직 위임) + late final WarehouseLocationFormController _controller; + + @override + void initState() { + super.initState(); + // 컨트롤러 생성 및 초기화 + _controller = WarehouseLocationFormController(); + _controller.initialize(widget.id); + } + + @override + void dispose() { + // 컨트롤러 해제 + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_controller.isEditMode ? '입고지 수정' : '입고지 추가'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).maybePop(), + ), + ), + body: SafeArea( + child: Form( + key: _controller.formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 입고지명 입력 + TextFormField( + controller: _controller.nameController, + decoration: const InputDecoration( + labelText: '입고지명', + hintText: '입고지명을 입력하세요', + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '입고지명을 입력하세요'; + } + return null; + }, + ), + const SizedBox(height: 24), + // 주소 입력 (공통 위젯) + AddressInput( + initialZipCode: _controller.address.zipCode, + initialRegion: _controller.address.region, + initialDetailAddress: _controller.address.detailAddress, + isRequired: true, + onAddressChanged: (zip, region, detail) { + setState(() { + _controller.updateAddress( + Address( + zipCode: zip, + region: region, + detailAddress: detail, + ), + ); + }); + }, + ), + const SizedBox(height: 24), + // 비고 입력 + RemarkInput(controller: _controller.remarkController), + const SizedBox(height: 80), // 하단 버튼 여백 확보 + ], + ), + ), + ), + ), + bottomNavigationBar: Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + child: SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + onPressed: + _controller.isSaving + ? null + : () async { + setState(() {}); // 저장 중 상태 갱신 + await _controller.save(context); + setState(() {}); // 저장 완료 후 상태 갱신 + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppThemeTailwind.primary, + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: + _controller.isSaving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text( + '저장', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/warehouse_location/warehouse_location_form_controller.dart b/lib/screens/warehouse_location/warehouse_location_form_controller.dart new file mode 100644 index 0000000..45ac38c --- /dev/null +++ b/lib/screens/warehouse_location/warehouse_location_form_controller.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/warehouse_location_model.dart'; +import 'package:superport/models/address_model.dart'; +import 'package:superport/services/mock_data_service.dart'; + +/// 입고지 폼 상태 및 저장/수정 로직을 담당하는 컨트롤러 +class WarehouseLocationFormController { + /// 폼 키 + final GlobalKey formKey = GlobalKey(); + + /// 입고지명 입력 컨트롤러 + final TextEditingController nameController = TextEditingController(); + + /// 비고 입력 컨트롤러 + final TextEditingController remarkController = TextEditingController(); + + /// 주소 정보 + Address address = const Address(); + + /// 저장 중 여부 + bool isSaving = false; + + /// 수정 모드 여부 + bool isEditMode = false; + + /// 입고지 id (수정 모드) + int? id; + + /// 기존 데이터 세팅 (수정 모드) + void initialize(int? locationId) { + id = locationId; + if (id != null) { + final location = MockDataService().getWarehouseLocationById(id!); + if (location != null) { + isEditMode = true; + nameController.text = location.name; + address = location.address; + remarkController.text = location.remark ?? ''; + } + } + } + + /// 주소 변경 처리 + void updateAddress(Address newAddress) { + address = newAddress; + } + + /// 저장 처리 (추가/수정) + Future save(BuildContext context) async { + if (!formKey.currentState!.validate()) return false; + isSaving = true; + if (isEditMode) { + // 수정 + MockDataService().updateWarehouseLocation( + WarehouseLocation( + id: id!, + name: nameController.text.trim(), + address: address, + remark: remarkController.text.trim(), + ), + ); + } else { + // 추가 + MockDataService().addWarehouseLocation( + WarehouseLocation( + id: 0, + name: nameController.text.trim(), + address: address, + remark: remarkController.text.trim(), + ), + ); + } + isSaving = false; + Navigator.pop(context, true); + return true; + } + + /// 취소 처리 + void cancel(BuildContext context) { + Navigator.pop(context, false); + } + + /// 컨트롤러 해제 + void dispose() { + nameController.dispose(); + remarkController.dispose(); + } +} diff --git a/lib/screens/warehouse_location/warehouse_location_list.dart b/lib/screens/warehouse_location/warehouse_location_list.dart new file mode 100644 index 0000000..44c7c52 --- /dev/null +++ b/lib/screens/warehouse_location/warehouse_location_list.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import 'package:superport/models/warehouse_location_model.dart'; +import 'controllers/warehouse_location_list_controller.dart'; +import 'package:superport/screens/common/widgets/address_input.dart'; +import 'package:superport/utils/constants.dart'; +import 'package:superport/screens/common/main_layout.dart'; +import 'package:superport/screens/common/widgets/pagination.dart'; +import 'package:superport/screens/common/custom_widgets.dart'; +import 'package:superport/screens/common/theme_tailwind.dart'; + +/// 입고지 관리 리스트 화면 (SRP 적용, UI만 담당) +class WarehouseLocationListScreen extends StatefulWidget { + const WarehouseLocationListScreen({Key? key}) : super(key: key); + + @override + State createState() => + _WarehouseLocationListScreenState(); +} + +class _WarehouseLocationListScreenState + extends State { + /// 리스트 컨트롤러 (상태 및 CRUD 위임) + final WarehouseLocationListController _controller = + WarehouseLocationListController(); + int _currentPage = 1; + final int _pageSize = 10; + + @override + void initState() { + super.initState(); + _controller.loadWarehouseLocations(); + } + + /// 리스트 새로고침 + void _reload() { + setState(() { + _controller.loadWarehouseLocations(); + }); + } + + /// 입고지 추가 폼으로 이동 + void _navigateToAdd() async { + final result = await Navigator.pushNamed( + context, + Routes.warehouseLocationAdd, + ); + if (result == true) { + _reload(); + } + } + + /// 입고지 수정 폼으로 이동 + void _navigateToEdit(WarehouseLocation location) async { + final result = await Navigator.pushNamed( + context, + Routes.warehouseLocationEdit, + arguments: location.id, + ); + if (result == true) { + _reload(); + } + } + + /// 삭제 다이얼로그 (별도 위젯으로 분리 가능) + void _showDeleteDialog(int id) { + showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('입고지 삭제'), + content: const Text('정말로 삭제하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('취소'), + ), + TextButton( + onPressed: () { + setState(() { + _controller.deleteWarehouseLocation(id); + }); + Navigator.of(context).pop(); + }, + child: const Text('삭제'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + // 대시보드 폭에 맞게 조정 + final screenWidth = MediaQuery.of(context).size.width; + final maxContentWidth = screenWidth > 1200 ? 1200.0 : screenWidth - 32; + + final int totalCount = _controller.warehouseLocations.length; + final int startIndex = (_currentPage - 1) * _pageSize; + final int endIndex = + (startIndex + _pageSize) > totalCount + ? totalCount + : (startIndex + _pageSize); + final List pagedLocations = _controller + .warehouseLocations + .sublist(startIndex, endIndex); + + return MainLayout( + title: '입고지 관리', + currentRoute: Routes.warehouseLocation, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _reload, + color: Colors.grey, + ), + ], + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PageTitle( + title: '입고지 목록', + width: maxContentWidth - 32, + rightWidget: ElevatedButton.icon( + onPressed: _navigateToAdd, + icon: const Icon(Icons.add), + label: const Text('입고지 추가'), + style: AppThemeTailwind.primaryButtonStyle, + ), + ), + Expanded( + child: DataTableCard( + width: maxContentWidth - 32, + child: + pagedLocations.isEmpty + ? const Center(child: Text('등록된 입고지가 없습니다.')) + : SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Container( + width: maxContentWidth - 32, + constraints: BoxConstraints( + minWidth: maxContentWidth - 64, + ), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: DataTable( + columns: const [ + DataColumn(label: Text('번호')), + DataColumn(label: Text('입고지명')), + DataColumn(label: Text('주소')), + DataColumn(label: Text('관리')), + ], + rows: List.generate(pagedLocations.length, (i) { + final location = pagedLocations[i]; + return DataRow( + cells: [ + DataCell(Text('${startIndex + i + 1}')), + DataCell(Text(location.name)), + DataCell( + AddressInput.readonly( + address: location.address, + ), + ), + DataCell( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon( + Icons.edit, + color: AppThemeTailwind.primary, + ), + tooltip: '수정', + onPressed: + () => + _navigateToEdit(location), + ), + IconButton( + icon: const Icon( + Icons.delete, + color: AppThemeTailwind.danger, + ), + tooltip: '삭제', + onPressed: + () => _showDeleteDialog( + location.id, + ), + ), + ], + ), + ), + ], + ); + }), + ), + ), + ), + ), + ), + ), + const SizedBox(height: 16), + Pagination( + currentPage: _currentPage, + totalCount: totalCount, + pageSize: _pageSize, + onPageChanged: (page) { + setState(() { + _currentPage = page; + }); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/services/mock_data_service.dart b/lib/services/mock_data_service.dart new file mode 100644 index 0000000..7e675ee --- /dev/null +++ b/lib/services/mock_data_service.dart @@ -0,0 +1,1133 @@ +import 'package:superport/models/equipment_unified_model.dart'; +import 'package:superport/models/company_model.dart'; +import 'package:superport/models/user_model.dart'; +import 'package:superport/models/license_model.dart'; +import 'package:superport/models/address_model.dart'; +import 'package:superport/models/warehouse_location_model.dart'; +import 'package:superport/utils/constants.dart'; // 장비 상태/유형 상수 import + +class MockDataService { + // 싱글톤 패턴 + static final MockDataService _instance = MockDataService._internal(); + + // 정적 초기화 플래그 + static bool _isInitialized = false; + + factory MockDataService() => _instance; + + MockDataService._internal() { + // 정적 플래그를 사용하여 딱 한 번만 초기화 + if (!_isInitialized) { + initialize(); + _isInitialized = true; + } + } + + // 모의 데이터 저장소 + final List _equipmentIns = []; + final List _equipmentOuts = []; + final List _companies = []; + final List _users = []; + final List _licenses = []; + final List _warehouseLocations = []; + + // ID 카운터 + int _equipmentInIdCounter = 1; + int _equipmentOutIdCounter = 1; + int _companyIdCounter = 1; + int _userIdCounter = 1; + int _licenseIdCounter = 1; + int _warehouseLocationIdCounter = 1; + + // 초기 데이터 생성 + void initialize() { + // 본사 지점명 및 주소 변경을 위한 함수 + Branch _convertHeadOfficeBranch(Branch branch, int companyId) { + // 이름이 '본사'인 경우만 변환 + if (branch.name == '본사') { + // 본사 지점의 이름을 '중앙지점'으로, 주소를 임의의 다른 값으로 변경 + return Branch( + id: branch.id, + companyId: companyId, + name: '중앙지점', // 본사 → 중앙지점으로 변경 + address: Address( + zipCode: '04533', // 임의의 다른 우편번호 + region: '서울특별시', // 임의의 다른 지역 + detailAddress: '중구 을지로 100', // 임의의 다른 상세주소 + ), + contactName: branch.contactName, + contactPosition: branch.contactPosition, + contactPhone: branch.contactPhone, + contactEmail: branch.contactEmail, + ); + } + // 본사가 아니면 그대로 반환 + return branch; + } + + // 모의 회사 데이터 추가 + addCompany( + Company( + name: 'LG전자', + address: Address( + zipCode: '03184', + region: '서울특별시', + detailAddress: '종로구 새문안로 58', + ), + contactName: '김영수', + contactPosition: '팀장', + contactPhone: '010-1234-5678', + contactEmail: 'kim.youngsoo@lg.com', + companyTypes: [CompanyType.customer, CompanyType.partner], // 고객사+파트너사 + branches: [ + _convertHeadOfficeBranch( + Branch( + companyId: 1, + name: '본사', + address: Address( + zipCode: '03184', + region: '서울특별시', + detailAddress: '종로구 새문안로 58', + ), + contactName: '박지은', + contactPosition: '과장', + contactPhone: '010-2345-6789', + contactEmail: 'park.jieun@lg.com', + ), + 1, + ), + Branch( + companyId: 1, + name: '강남지점', + address: Address( + zipCode: '06194', + region: '서울특별시', + detailAddress: '강남구 테헤란로 534', + ), + contactName: '이민호', + contactPosition: '대리', + contactPhone: '010-3456-7890', + contactEmail: 'lee.minho@lg.com', + ), + Branch( + companyId: 1, + name: '판교지점', + address: Address( + zipCode: '13494', + region: '경기도', + detailAddress: '성남시 분당구 판교로 160', + ), + contactName: '정수진', + contactPosition: '사원', + contactPhone: '010-4567-8901', + contactEmail: 'jung.soojin@lg.com', + ), + ], + ), + ); + + addCompany( + Company( + name: '삼성전자', + address: Address( + zipCode: '06620', + region: '서울특별시', + detailAddress: '서초구 서초대로 74길 11', + ), + contactName: '최동욱', + contactPosition: '부장', + contactPhone: '010-5678-9012', + contactEmail: 'choi.dongwook@samsung.com', + companyTypes: [CompanyType.partner], // 파트너사 + branches: [ + _convertHeadOfficeBranch( + Branch( + companyId: 2, + name: '본사', + address: Address( + zipCode: '06620', + region: '서울특별시', + detailAddress: '서초구 서초대로 74길 11', + ), + contactName: '한미영', + contactPosition: '팀장', + contactPhone: '010-6789-0123', + contactEmail: 'han.miyoung@samsung.com', + ), + 2, + ), + Branch( + companyId: 2, + name: '수원사업장', + address: Address( + zipCode: '16677', + region: '경기도', + detailAddress: '수원시 영통구 삼성로 129', + ), + contactName: '장현우', + contactPosition: '과장', + contactPhone: '010-7890-1234', + contactEmail: 'jang.hyunwoo@samsung.com', + ), + ], + ), + ); + + addCompany( + Company( + name: '현대자동차', + address: Address( + zipCode: '06797', + region: '서울특별시', + detailAddress: '서초구 헌릉로 12', + ), + contactName: '김태희', + contactPosition: '상무', + contactPhone: '010-8901-2345', + contactEmail: 'kim.taehee@hyundai.com', + companyTypes: [CompanyType.customer], // 고객사 + branches: [ + _convertHeadOfficeBranch( + Branch( + companyId: 3, + name: '본사', + address: Address( + zipCode: '06797', + region: '서울특별시', + detailAddress: '서초구 헌릉로 12', + ), + contactName: '이준호', + contactPosition: '팀장', + contactPhone: '010-9012-3456', + contactEmail: 'lee.junho@hyundai.com', + ), + 3, + ), + Branch( + companyId: 3, + name: '울산공장', + address: Address( + zipCode: '44100', + region: '울산광역시', + detailAddress: '북구 산업로 1000', + ), + contactName: '송지원', + contactPosition: '대리', + contactPhone: '010-0123-4567', + contactEmail: 'song.jiwon@hyundai.com', + ), + ], + ), + ); + + addCompany( + Company( + name: 'SK하이닉스', + address: Address( + zipCode: '17084', + region: '경기도', + detailAddress: '이천시 부발읍 경충대로 2091', + ), + contactName: '박서준', + contactPosition: '이사', + contactPhone: '010-1122-3344', + contactEmail: 'park.seojoon@sk.com', + companyTypes: [CompanyType.partner, CompanyType.customer], // 파트너사+고객사 + branches: [ + _convertHeadOfficeBranch( + Branch( + companyId: 4, + name: '본사', + address: Address( + zipCode: '17084', + region: '경기도', + detailAddress: '이천시 부발읍 경충대로 2091', + ), + contactName: '강지영', + contactPosition: '팀장', + contactPhone: '010-2233-4455', + contactEmail: 'kang.jiyoung@sk.com', + ), + 4, + ), + Branch( + companyId: 4, + name: '청주사업장', + address: Address( + zipCode: '28422', + region: '충청북도', + detailAddress: '청주시 흥덕구 대신로 215', + ), + contactName: '윤성민', + contactPosition: '과장', + contactPhone: '010-3344-5566', + contactEmail: 'yoon.sungmin@sk.com', + ), + ], + ), + ); + + // 모의 사용자 데이터 추가 + addUser( + User( + companyId: 1, + name: '홍길동', + role: 'S', // 관리자 + ), + ); + + addUser( + User( + companyId: 1, + name: '김철수', + role: 'M', // 멤버 + ), + ); + + // ===== 실제 네트워크/IT 장비 및 소모품 기반 입고 샘플 데이터 20개 추가 ===== + final List> realEquipments = [ + // 시리얼넘버가 있는 네트워크/IT 장비 (수량 1) + { + 'manufacturer': 'Cisco', + 'name': 'Catalyst 9300', + 'category': '네트워크', + 'subCategory': '스위치', + 'subSubCategory': 'Layer3', + 'serialNumber': 'FDO1234A1BC', + 'barcode': 'CISCO9300-001', + }, + { + 'manufacturer': 'HPE', + 'name': 'Aruba 2930F', + 'category': '네트워크', + 'subCategory': '스위치', + 'subSubCategory': 'Layer2', + 'serialNumber': 'CN12345678', + 'barcode': 'ARUBA2930F-001', + }, + { + 'manufacturer': 'Dell', + 'name': 'PowerEdge R740', + 'category': '서버', + 'subCategory': '랙마운트', + 'subSubCategory': '2U', + 'serialNumber': '6JH1234', + 'barcode': 'DELLR740-001', + }, + { + 'manufacturer': 'Juniper', + 'name': 'EX4300', + 'category': '네트워크', + 'subCategory': '스위치', + 'subSubCategory': 'Layer3', + 'serialNumber': 'JNPR123456', + 'barcode': 'JUNEX4300-001', + }, + { + 'manufacturer': 'Fortinet', + 'name': 'FortiGate 100F', + 'category': '보안', + 'subCategory': '방화벽', + 'subSubCategory': 'UTM', + 'serialNumber': 'FGT100F1234', + 'barcode': 'FORTI100F-001', + }, + { + 'manufacturer': 'Mikrotik', + 'name': 'CCR1009', + 'category': '네트워크', + 'subCategory': '라우터', + 'subSubCategory': 'Cloud Core', + 'serialNumber': 'MKTKCCR1009', + 'barcode': 'MIKROCCR1009-001', + }, + { + 'manufacturer': 'Ubiquiti', + 'name': 'UniFi AP AC Pro', + 'category': '네트워크', + 'subCategory': '무선AP', + 'subSubCategory': 'WiFi5', + 'serialNumber': 'UBNTUAPACPRO', + 'barcode': 'UBNTUAPACPRO-001', + }, + { + 'manufacturer': 'Netgear', + 'name': 'GS108', + 'category': '네트워크', + 'subCategory': '스위치', + 'subSubCategory': 'SOHO', + 'serialNumber': 'NGGS108SN01', + 'barcode': 'NETGEARGS108-001', + }, + { + 'manufacturer': 'Aruba', + 'name': 'Instant On 1930', + 'category': '네트워크', + 'subCategory': '스위치', + 'subSubCategory': 'SMB', + 'serialNumber': 'ARUBA1930SN', + 'barcode': 'ARUBA1930-001', + }, + { + 'manufacturer': 'TP-Link', + 'name': 'TL-SG3428', + 'category': '네트워크', + 'subCategory': '스위치', + 'subSubCategory': 'L2+', + 'serialNumber': 'TPLSG3428SN', + 'barcode': 'TPLINKSG3428-001', + }, + { + 'manufacturer': '삼성', + 'name': 'Galaxy Book Pro', + 'category': '노트북', + 'subCategory': '울트라북', + 'subSubCategory': 'i7', + 'serialNumber': 'SMSGALBOOKPRO', + 'barcode': 'SMSGALBOOKPRO-001', + }, + { + 'manufacturer': 'LG', + 'name': 'Gram 16', + 'category': '노트북', + 'subCategory': '경량', + 'subSubCategory': 'i5', + 'serialNumber': 'LGGRAM16SN', + 'barcode': 'LGGRAM16-001', + }, + { + 'manufacturer': 'Apple', + 'name': 'MacBook Pro 14', + 'category': '노트북', + 'subCategory': 'Mac', + 'subSubCategory': 'M1 Pro', + 'serialNumber': 'MBP14M1PRO', + 'barcode': 'APPLEMBP14-001', + }, + { + 'manufacturer': 'Lenovo', + 'name': 'ThinkPad X1 Carbon', + 'category': '노트북', + 'subCategory': '비즈니스', + 'subSubCategory': 'Gen9', + 'serialNumber': 'LNVX1CARBON', + 'barcode': 'LENOVOX1-001', + }, + { + 'manufacturer': 'HP', + 'name': 'EliteBook 840', + 'category': '노트북', + 'subCategory': '비즈니스', + 'subSubCategory': 'G8', + 'serialNumber': 'HPEB840G8', + 'barcode': 'HPEB840-001', + }, + // 시리얼넘버 없는 소모품 (수량만 존재) + { + 'manufacturer': 'Logitech', + 'name': 'M720 Triathlon', + 'category': '입력장치', + 'subCategory': '마우스', + 'subSubCategory': '무선', + 'quantity': 15, + }, + { + 'manufacturer': 'Samsung', + 'name': 'AA-SK2PWBB', + 'category': '입력장치', + 'subCategory': '키보드', + 'subSubCategory': '유선', + 'quantity': 10, + }, + { + 'manufacturer': 'Anker', + 'name': 'PowerCore 10000', + 'category': '액세서리', + 'subCategory': '보조배터리', + 'subSubCategory': '10000mAh', + 'quantity': 8, + }, + { + 'manufacturer': 'Xiaomi', + 'name': 'Mi Power Bank 3', + 'category': '액세서리', + 'subCategory': '보조배터리', + 'subSubCategory': '20000mAh', + 'quantity': 12, + }, + { + 'manufacturer': 'LG', + 'name': 'MK430', + 'category': '입력장치', + 'subCategory': '키보드', + 'subSubCategory': '무선', + 'quantity': 7, + }, + ]; + + // 입고 데이터 생성 + for (int i = 0; i < 20; i++) { + final eq = realEquipments[i % realEquipments.length]; + addEquipmentIn( + EquipmentIn( + equipment: Equipment( + manufacturer: eq['manufacturer'], + name: eq['name'], + category: eq['category'], + subCategory: eq['subCategory'], + subSubCategory: eq['subSubCategory'], + serialNumber: eq['serialNumber'], + barcode: eq['barcode'], + quantity: eq['serialNumber'] != null ? 1 : eq['quantity'], + ), + inDate: DateTime.now().subtract(Duration(days: 2 * i)), + warehouseLocation: '입고지${i % 3 + 1}', + partnerCompany: '파트너사${i % 4 + 1}', + type: i % 2 == 0 ? EquipmentType.new_ : EquipmentType.used, + remark: + eq['serialNumber'] != null + ? '실제 네트워크/IT 장비 입고 샘플' + : '실제 소모품 입고 샘플', + ), + ); + } + + // 출고 데이터 생성 + for (int i = 0; i < 20; i++) { + final eq = realEquipments[(i + 3) % realEquipments.length]; + addEquipmentOut( + EquipmentOut( + equipment: Equipment( + manufacturer: eq['manufacturer'], + name: eq['name'], + category: eq['category'], + subCategory: eq['subCategory'], + subSubCategory: eq['subSubCategory'], + serialNumber: eq['serialNumber'], + barcode: eq['barcode'], + quantity: eq['serialNumber'] != null ? 1 : eq['quantity'], + ), + outDate: DateTime.now().subtract(Duration(days: 3 * i)), + status: EquipmentStatus.out, + company: '출고회사${i % 4 + 1}', + manager: '담당자${i % 6 + 1}', + license: '라이센스${i % 2 + 1}', + returnDate: null, + returnType: null, + remark: + eq['serialNumber'] != null + ? '실제 네트워크/IT 장비 출고 샘플' + : '실제 소모품 출고 샘플', + ), + ); + } + + // 대여 데이터 생성 + for (int i = 0; i < 20; i++) { + final eq = realEquipments[(i + 5) % realEquipments.length]; + addEquipmentOut( + EquipmentOut( + equipment: Equipment( + manufacturer: eq['manufacturer'], + name: eq['name'], + category: eq['category'], + subCategory: eq['subCategory'], + subSubCategory: eq['subSubCategory'], + serialNumber: eq['serialNumber'], + barcode: eq['barcode'], + quantity: eq['serialNumber'] != null ? 1 : eq['quantity'], + ), + outDate: DateTime.now().subtract(Duration(days: 4 * i)), + status: EquipmentStatus.rent, // 대여 상태 코드 'T' + company: '대여회사${i % 5 + 1}', + manager: '대여담당자${i % 7 + 1}', + license: '대여라이센스${i % 3 + 1}', + returnDate: + eq['serialNumber'] != null + ? DateTime.now().subtract(Duration(days: 4 * i - 2)) + : null, + returnType: eq['serialNumber'] != null ? '정상반납' : null, + remark: + eq['serialNumber'] != null + ? '실제 네트워크/IT 장비 대여 샘플' + : '실제 소모품 대여 샘플', + ), + ); + } + + // 유지보수 샘플 데이터(12개월, 모든 방문주기/점검형태 조합) 추가 + final List visitCycles = [ + '미방문', + '장애시 지원', + '월', + '격월', + '분기', + '반기', + '년', + ]; + final List inspectionTypes = ['방문', '원격']; + for (final visit in visitCycles) { + for (final inspection in inspectionTypes) { + addLicense( + License( + companyId: 1, + name: '12개월,$visit,$inspection', + durationMonths: 12, + visitCycle: visit, + ), + ); + } + } + // 1번 유지보수 샘플 아이템 삭제 + deleteLicense(1); + + // 입고지 mock 데이터 추가 + addWarehouseLocation( + WarehouseLocation( + id: _warehouseLocationIdCounter, + name: '당사', + address: Address( + zipCode: '01234', + region: '서울특별시', + detailAddress: '강남구 테헤란로 1', + ), + remark: '본사 전용 입고지', + ), + ); + addWarehouseLocation( + WarehouseLocation( + id: _warehouseLocationIdCounter, + name: '서울 입고지', + address: Address( + zipCode: '04524', + region: '서울특별시', + detailAddress: '중구 퇴계로 100', + ), + remark: '본사 창고', + ), + ); + addWarehouseLocation( + WarehouseLocation( + id: _warehouseLocationIdCounter, + name: '부산 입고지', + address: Address( + zipCode: '48942', + region: '부산광역시', + detailAddress: '중구 중앙대로 50', + ), + remark: '부산지점 창고', + ), + ); + } + + // 장비 입고 관련 메소드 + List getAllEquipmentIns() { + return _equipmentIns; + } + + EquipmentIn? getEquipmentInById(int id) { + try { + return _equipmentIns.firstWhere((e) => e.id == id); + } catch (e) { + return null; + } + } + + void addEquipmentIn(EquipmentIn equipmentIn) { + final newEquipmentIn = EquipmentIn( + id: _equipmentInIdCounter++, + equipment: equipmentIn.equipment, + inDate: equipmentIn.inDate, + status: equipmentIn.status, + type: equipmentIn.type, + warehouseLocation: equipmentIn.warehouseLocation, + partnerCompany: equipmentIn.partnerCompany, + ); + _equipmentIns.add(newEquipmentIn); + } + + void updateEquipmentIn(EquipmentIn equipmentIn) { + final index = _equipmentIns.indexWhere((e) => e.id == equipmentIn.id); + if (index != -1) { + _equipmentIns[index] = equipmentIn; + } + } + + void deleteEquipmentIn(int id) { + _equipmentIns.removeWhere((e) => e.id == id); + } + + // 장비 출고 관련 메소드 + List getAllEquipmentOuts() { + return _equipmentOuts; + } + + EquipmentOut? getEquipmentOutById(int id) { + try { + return _equipmentOuts.firstWhere((e) => e.id == id); + } catch (e) { + return null; + } + } + + // 기존 입고 장비를 출고 상태로 변경 + void changeEquipmentStatus(int equipmentInId, EquipmentOut equipmentOut) { + print('장비 상태 변경 시작: 입고 ID $equipmentInId'); + + // 입고된 장비를 찾습니다 + final index = _equipmentIns.indexWhere((e) => e.id == equipmentInId); + if (index != -1) { + print('장비를 찾음: ${_equipmentIns[index].equipment.name}'); + + // 입고 장비의 상태를 출고(O)로 변경 + final equipment = _equipmentIns[index].equipment; + _equipmentIns[index] = EquipmentIn( + id: _equipmentIns[index].id, + equipment: equipment, + inDate: _equipmentIns[index].inDate, + status: 'O', // 상태를 출고로 변경 + ); + print('입고 장비 상태를 "O"로 변경: ID ${_equipmentIns[index].id}'); + + // 출고 정보 저장 + final newEquipmentOut = EquipmentOut( + id: _equipmentOutIdCounter++, + equipment: equipment, + outDate: equipmentOut.outDate, + status: equipmentOut.status, + company: equipmentOut.company, + manager: equipmentOut.manager, + license: equipmentOut.license, + returnDate: equipmentOut.returnDate, + returnType: equipmentOut.returnType, + ); + _equipmentOuts.add(newEquipmentOut); + print('출고 정보 추가: ID ${newEquipmentOut.id}'); + + print('장비 상태 변경 완료'); + } else { + print('오류: ID $equipmentInId인 입고 장비를 찾을 수 없음'); + } + } + + void addEquipmentOut(EquipmentOut equipmentOut) { + final newEquipmentOut = EquipmentOut( + id: _equipmentOutIdCounter++, + equipment: equipmentOut.equipment, + outDate: equipmentOut.outDate, + status: equipmentOut.status, + company: equipmentOut.company, + manager: equipmentOut.manager, + license: equipmentOut.license, + returnDate: equipmentOut.returnDate, + returnType: equipmentOut.returnType, + ); + _equipmentOuts.add(newEquipmentOut); + } + + void updateEquipmentOut(EquipmentOut equipmentOut) { + final index = _equipmentOuts.indexWhere((e) => e.id == equipmentOut.id); + if (index != -1) { + _equipmentOuts[index] = equipmentOut; + } + } + + void deleteEquipmentOut(int id) { + _equipmentOuts.removeWhere((e) => e.id == id); + } + + // 제조사명 목록 반환 + List getAllManufacturers() { + final manufacturers = []; + + for (final equipment in _equipmentIns) { + if (equipment.equipment.manufacturer.isNotEmpty && + !manufacturers.contains(equipment.equipment.manufacturer)) { + manufacturers.add(equipment.equipment.manufacturer); + } + } + + for (final equipment in _equipmentOuts) { + if (equipment.equipment.manufacturer.isNotEmpty && + !manufacturers.contains(equipment.equipment.manufacturer)) { + manufacturers.add(equipment.equipment.manufacturer); + } + } + + // 기본 제조사 추가 (초기 데이터가 없을 경우) + manufacturers.addAll([ + '삼성', + '삼성전자', + '삼성디스플레이', + 'LG', + 'LG전자', + 'LG디스플레이', + 'Apple', + 'HP', + 'Dell', + 'Lenovo', + 'Asus', + 'Acer', + '한성컴퓨터', + '기가바이트', + 'MSI', + 'Intel', + 'AMD', + ]); + + return manufacturers..sort(); + } + + // 모든 장비명 가져오기 + List getAllEquipmentNames() { + final equipmentNames = []; + + for (final equipment in _equipmentIns) { + if (equipment.equipment.name.isNotEmpty && + !equipmentNames.contains(equipment.equipment.name)) { + equipmentNames.add(equipment.equipment.name); + } + } + + for (final equipment in _equipmentOuts) { + if (equipment.equipment.name.isNotEmpty && + !equipmentNames.contains(equipment.equipment.name)) { + equipmentNames.add(equipment.equipment.name); + } + } + + return equipmentNames..sort(); + } + + // 회사명 목록 가져오기 + List getAllCompanyNames() { + return _companies.map((company) => company.name).toList(); + } + + // 지점명 목록 가져오기 + List getAllBranchNames() { + final branchNames = []; + + for (final company in _companies) { + if (company.branches != null) { + for (final branch in company.branches!) { + if (branch.name.isNotEmpty && !branchNames.contains(branch.name)) { + branchNames.add(branch.name); + } + } + } + } + + return branchNames..sort(); + } + + // 회사 관련 메소드 + List getAllCompanies() { + return List.from(_companies); + } + + Company? getCompanyById(int id) { + try { + return _companies.firstWhere((company) => company.id == id); + } catch (e) { + return null; + } + } + + // 이름으로 회사를 찾는 메서드 추가 + Company? findCompanyByName(String name) { + if (name.isEmpty) return null; + + try { + return _companies.firstWhere( + (company) => company.name.toLowerCase() == name.toLowerCase(), + ); + } catch (e) { + return null; + } + } + + void addCompany(Company company) { + final newCompany = Company( + id: _companyIdCounter++, + name: company.name, + address: company.address, + contactName: company.contactName, + contactPosition: company.contactPosition, + contactPhone: company.contactPhone, + contactEmail: company.contactEmail, + companyTypes: company.companyTypes, + remark: company.remark, + branches: + company.branches?.map((branch) { + return Branch( + id: + branch.id ?? + (_companyIdCounter * 100 + + (company.branches?.indexOf(branch) ?? 0)), + companyId: _companyIdCounter - 1, + name: branch.name, + address: branch.address, + contactName: branch.contactName, + contactPosition: branch.contactPosition, + contactPhone: branch.contactPhone, + contactEmail: branch.contactEmail, + remark: branch.remark, + ); + }).toList(), + ); + _companies.add(newCompany); + } + + void updateCompany(Company company) { + final index = _companies.indexWhere((c) => c.id == company.id); + if (index != -1) { + _companies[index] = company; + } + } + + void deleteCompany(int id) { + _companies.removeWhere((c) => c.id == id); + } + + // 사용자 관련 메소드 + List getAllUsers() { + return _users; + } + + User? getUserById(int id) { + try { + return _users.firstWhere((u) => u.id == id); + } catch (e) { + return null; + } + } + + void addUser(User user) { + final newUser = User( + id: _userIdCounter++, + companyId: user.companyId, + branchId: user.branchId, + name: user.name, + role: user.role, + position: user.position, + email: user.email, + phoneNumbers: user.phoneNumbers, + ); + _users.add(newUser); + } + + void updateUser(User user) { + final index = _users.indexWhere((u) => u.id == user.id); + if (index != -1) { + _users[index] = user; + } + } + + void deleteUser(int id) { + _users.removeWhere((u) => u.id == id); + } + + // 라이센스 관련 메소드 + List getAllLicenses() { + return _licenses; + } + + License? getLicenseById(int id) { + try { + return _licenses.firstWhere((l) => l.id == id); + } catch (e) { + return null; + } + } + + void addLicense(License license) { + final newLicense = License( + id: _licenseIdCounter++, + companyId: license.companyId, + name: license.name, + durationMonths: license.durationMonths, + visitCycle: license.visitCycle, + ); + _licenses.add(newLicense); + } + + void updateLicense(License license) { + final index = _licenses.indexWhere((l) => l.id == license.id); + if (index != -1) { + _licenses[index] = license; + } + } + + void deleteLicense(int id) { + _licenses.removeWhere((l) => l.id == id); + } + + // 장비 통합 관련 메소드 + List getAllEquipments() { + final List allEquipments = []; + + // 입고 장비를 통합 목록에 추가 (출고 상태가 아닌 장비만) + for (var equipmentIn in _equipmentIns) { + // 상태가 'O'(출고)가 아닌 장비만 목록에 포함 + if (equipmentIn.status != 'O') { + allEquipments.add( + UnifiedEquipment.fromEquipmentIn( + equipmentIn.id, + equipmentIn.equipment, + equipmentIn.inDate, + equipmentIn.status, + type: equipmentIn.type, + ), + ); + } + } + + // 출고 장비를 통합 목록에 추가 + for (var equipmentOut in _equipmentOuts) { + allEquipments.add( + UnifiedEquipment( + id: equipmentOut.id, + equipment: equipmentOut.equipment, + date: equipmentOut.outDate, + status: equipmentOut.status, + ), + ); + } + + // 날짜 기준 내림차순 정렬 (최신 항목이 먼저 표시) + allEquipments.sort((a, b) => b.date.compareTo(a.date)); + + return allEquipments; + } + + // ID로 통합 장비 조회 + UnifiedEquipment? getEquipmentById(int id, String status) { + // 상태가 입고인 경우 + if (status == 'I') { + final equipmentIn = getEquipmentInById(id); + if (equipmentIn != null) { + return UnifiedEquipment( + id: equipmentIn.id, + equipment: equipmentIn.equipment, + date: equipmentIn.inDate, + status: equipmentIn.status, + ); + } + } + // 상태가 출고인 경우 + else if (status == 'O') { + final equipmentOut = getEquipmentOutById(id); + if (equipmentOut != null) { + return UnifiedEquipment( + id: equipmentOut.id, + equipment: equipmentOut.equipment, + date: equipmentOut.outDate, + status: equipmentOut.status, + ); + } + } + return null; + } + + // 통합 장비 삭제 + void deleteEquipment(int id, String status) { + if (status == 'I') { + deleteEquipmentIn(id); + } else if (status == 'O') { + deleteEquipmentOut(id); + } + } + + // 입고지 전체 조회 + List getAllWarehouseLocations() { + return _warehouseLocations; + } + + // 입고지 ID로 조회 + WarehouseLocation? getWarehouseLocationById(int id) { + try { + return _warehouseLocations.firstWhere((w) => w.id == id); + } catch (e) { + return null; + } + } + + // 입고지 추가 + void addWarehouseLocation(WarehouseLocation location) { + final newLocation = WarehouseLocation( + id: _warehouseLocationIdCounter++, + name: location.name, + address: location.address, + remark: location.remark, + ); + _warehouseLocations.add(newLocation); + } + + // 입고지 수정 + void updateWarehouseLocation(WarehouseLocation location) { + final index = _warehouseLocations.indexWhere((w) => w.id == location.id); + if (index != -1) { + _warehouseLocations[index] = location; + } + } + + // 입고지 삭제 + void deleteWarehouseLocation(int id) { + _warehouseLocations.removeWhere((w) => w.id == id); + } + + // 카테고리명 목록 반환 + List getAllCategories() { + final categories = []; + for (final equipment in _equipmentIns) { + if (equipment.equipment.category.isNotEmpty && + !categories.contains(equipment.equipment.category)) { + categories.add(equipment.equipment.category); + } + } + for (final equipment in _equipmentOuts) { + if (equipment.equipment.category.isNotEmpty && + !categories.contains(equipment.equipment.category)) { + categories.add(equipment.equipment.category); + } + } + return categories..sort(); + } + + // 서브카테고리명 목록 반환 + List getAllSubCategories() { + final subCategories = []; + for (final equipment in _equipmentIns) { + if (equipment.equipment.subCategory.isNotEmpty && + !subCategories.contains(equipment.equipment.subCategory)) { + subCategories.add(equipment.equipment.subCategory); + } + } + for (final equipment in _equipmentOuts) { + if (equipment.equipment.subCategory.isNotEmpty && + !subCategories.contains(equipment.equipment.subCategory)) { + subCategories.add(equipment.equipment.subCategory); + } + } + return subCategories..sort(); + } + + // 서브서브카테고리명 목록 반환 + List getAllSubSubCategories() { + final subSubCategories = []; + for (final equipment in _equipmentIns) { + if (equipment.equipment.subSubCategory.isNotEmpty && + !subSubCategories.contains(equipment.equipment.subSubCategory)) { + subSubCategories.add(equipment.equipment.subSubCategory); + } + } + for (final equipment in _equipmentOuts) { + if (equipment.equipment.subSubCategory.isNotEmpty && + !subSubCategories.contains(equipment.equipment.subSubCategory)) { + subSubCategories.add(equipment.equipment.subSubCategory); + } + } + return subSubCategories..sort(); + } +} diff --git a/lib/utils/address_constants.dart b/lib/utils/address_constants.dart new file mode 100644 index 0000000..42f53a5 --- /dev/null +++ b/lib/utils/address_constants.dart @@ -0,0 +1,37 @@ +/// 주소 관련 상수 및 레이블 정의 파일 +/// +/// 한국의 시/도(광역시/도) 및 주소 입력 UI 레이블을 구분하여 관리합니다. + +/// 한국의 시/도(광역시/도) 상수 클래스 (불변성 보장) +class KoreanRegions { + /// 최상위 행정구역(시/도) + static const List topLevel = [ + '서울특별시', + '부산광역시', + '대구광역시', + '인천광역시', + '광주광역시', + '대전광역시', + '울산광역시', + '세종특별자치시', + '경기도', + '강원특별자치도', + '충청북도', + '충청남도', + '전라북도', + '전라남도', + '경상북도', + '경상남도', + '제주특별자치도', + ]; +} + +/// 주소 입력 관련 UI 레이블 상수 클래스 +class AddressLabels { + static const String zipCode = '우편번호'; + static const String region = '시/도'; + static const String detail = '상세주소'; + static const String zipCodeHint = '우편번호를 입력하세요'; + static const String regionHint = '시/도를 선택하세요'; + static const String detailHint = '나머지 주소를 입력하세요'; +} diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart new file mode 100644 index 0000000..f063ceb --- /dev/null +++ b/lib/utils/constants.dart @@ -0,0 +1,59 @@ +/// 앱 전역에서 사용하는 상수 정의 파일 +/// +/// 라우트, 장비 상태, 장비 유형, 사용자 권한 등 도메인별로 구분하여 관리합니다. + +/// 라우트 이름 상수 클래스 +class Routes { + static const String home = '/'; + static const String equipment = '/equipment'; // 통합 장비 관리 + static const String equipmentIn = '/equipment-in'; // 입고 목록(미사용) + static const String equipmentInAdd = '/equipment-in/add'; // 장비 입고 폼 + static const String equipmentInEdit = '/equipment-in/edit'; // 장비 입고 편집 + static const String equipmentOut = '/equipment-out'; // 출고 목록(미사용) + static const String equipmentOutAdd = '/equipment-out/add'; // 장비 출고 폼 + static const String equipmentOutEdit = '/equipment-out/edit'; // 장비 출고 편집 + static const String equipmentInList = '/equipment/in'; // 입고 장비 목록 + static const String equipmentOutList = '/equipment/out'; // 출고 장비 목록 + static const String equipmentRentList = '/equipment/rent'; // 대여 장비 목록 + static const String company = '/company'; + static const String companyAdd = '/company/add'; + static const String companyEdit = '/company/edit'; + static const String user = '/user'; + static const String userAdd = '/user/add'; + static const String userEdit = '/user/edit'; + static const String license = '/license'; + static const String licenseAdd = '/license/add'; + static const String licenseEdit = '/license/edit'; + static const String warehouseLocation = '/warehouse-location'; // 입고지 관리 목록 + static const String warehouseLocationAdd = + '/warehouse-location/add'; // 입고지 추가 + static const String warehouseLocationEdit = + '/warehouse-location/edit'; // 입고지 수정 + static const String goods = '/goods'; // 물품 관리(등록) + static const String goodsAdd = '/goods/add'; // 물품 등록 폼 + static const String goodsEdit = '/goods/edit'; // 물품 수정 폼 +} + +/// 장비 상태 코드 상수 클래스 +class EquipmentStatus { + static const String in_ = 'I'; // 입고 + static const String out = 'O'; // 출고 + static const String rent = 'T'; // 대여 + static const String repair = 'R'; // 수리 + static const String damaged = 'D'; // 손상 + static const String lost = 'L'; // 분실 + static const String etc = 'E'; // 기타 +} + +/// 장비 유형 상수 클래스 +class EquipmentType { + static const String new_ = '신제품'; // 신제품 + static const String used = '중고'; // 중고 + static const String contract = '계약'; // 계약(입고후 즉각 출고) +} + +/// 사용자 권한 상수 클래스 +class UserRoles { + static const String admin = 'S'; // 관리자 + static const String member = 'M'; // 멤버 +} diff --git a/lib/utils/equipment_display_helper.dart b/lib/utils/equipment_display_helper.dart new file mode 100644 index 0000000..f2e1ae9 --- /dev/null +++ b/lib/utils/equipment_display_helper.dart @@ -0,0 +1,40 @@ +/// 장비 정보 표시를 위한 헬퍼 클래스 (SRP, 재사용성, 테스트 용이성 중심) +class EquipmentDisplayHelper { + /// 제조사명 포맷팅 (빈 값은 대시로 표시) + static String formatManufacturer(String? manufacturer) { + if (manufacturer == null || manufacturer.isEmpty) return '-'; + return manufacturer; + } + + /// 장비명 포맷팅 (빈 값은 대시로 표시) + static String formatEquipmentName(String? name) { + if (name == null || name.isEmpty) return '-'; + return name; + } + + /// 카테고리 포맷팅 (비어있지 않은 카테고리만 합침) + static String formatCategory( + String? category, + String? subCategory, + String? subSubCategory, + ) { + final parts = [ + if (category != null && category.isNotEmpty) category, + if (subCategory != null && subCategory.isNotEmpty) subCategory, + if (subSubCategory != null && subSubCategory.isNotEmpty) subSubCategory, + ]; + if (parts.isEmpty) return '-'; + return parts.join(' > '); + } + + /// 시리얼 번호 포맷팅 (없으면 대시) + static String formatSerialNumber(String? serialNumber) { + return serialNumber?.isNotEmpty == true ? serialNumber! : '-'; + } + + /// 날짜 포맷팅 (YYYY-MM-DD, null이면 대시) + static String formatDate(DateTime? date) { + if (date == null) return '-'; + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/utils/phone_utils.dart b/lib/utils/phone_utils.dart new file mode 100644 index 0000000..dd8a15f --- /dev/null +++ b/lib/utils/phone_utils.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// 전화번호 관련 유틸리티 클래스 (SRP, 재사용성, 테스트 용이성 중심) +class PhoneUtils { + /// 전화번호 입력 형식 지정용 InputFormatter + static final TextInputFormatter phoneInputFormatter = + _PhoneTextInputFormatter(); + + /// 전화번호 포맷팅 (뒤 4자리 하이픈) + static String formatPhoneNumber(String phoneNumber) { + final digitsOnly = phoneNumber.replaceAll(RegExp(r'[^\d]'), ''); + if (digitsOnly.isEmpty) return ''; + if (digitsOnly.length > 8) { + return formatPhoneNumber(digitsOnly.substring(0, 8)); + } + if (digitsOnly.length > 4) { + final frontPart = digitsOnly.substring(0, digitsOnly.length - 4); + final backPart = digitsOnly.substring(digitsOnly.length - 4); + return '$frontPart-$backPart'; + } + return digitsOnly; + } + + /// 포맷된 전화번호에서 숫자만 추출 + static String extractDigitsOnly(String formattedPhoneNumber) { + return formattedPhoneNumber.replaceAll(RegExp(r'[^\d]'), ''); + } + + /// 전체 전화번호에서 접두사 추출 (없으면 기본값) + static String extractPhonePrefix( + String fullNumber, + List phonePrefixes, + ) { + if (fullNumber.isEmpty) return '010'; + String digitsOnly = fullNumber.replaceAll(RegExp(r'[^\d]'), ''); + for (String prefix in phonePrefixes) { + if (digitsOnly.startsWith(prefix)) { + return prefix; + } + } + return '010'; + } + + /// 접두사 제외한 번호 추출 + static String extractPhoneNumberWithoutPrefix( + String fullNumber, + List phonePrefixes, + ) { + if (fullNumber.isEmpty) return ''; + String digitsOnly = fullNumber.replaceAll(RegExp(r'[^\d]'), ''); + for (String prefix in phonePrefixes) { + if (digitsOnly.startsWith(prefix)) { + return digitsOnly.substring(prefix.length); + } + } + return digitsOnly; + } + + /// 접두사와 번호를 합쳐 전체 전화번호 생성 + static String getFullPhoneNumber(String prefix, String number) { + final remainingNumber = number.replaceAll(RegExp(r'[^\d]'), ''); + if (remainingNumber.isEmpty) return ''; + return '$prefix-$remainingNumber'; + } + + /// 자주 사용되는 전화번호 접두사 목록 반환 + static List getCommonPhonePrefixes() { + return [ + '010', + '011', + '016', + '017', + '018', + '019', + '070', + '080', + '02', + '031', + '032', + '033', + '041', + '042', + '043', + '044', + '051', + '052', + '053', + '054', + '055', + '061', + '062', + '063', + '064', + ]; + } +} + +/// 전화번호 입력 형식 지정용 TextInputFormatter (내부 전용) +class _PhoneTextInputFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final digitsOnly = newValue.text.replaceAll(RegExp(r'[^\d]+'), ''); + final trimmed = + digitsOnly.length > 11 ? digitsOnly.substring(0, 11) : digitsOnly; + String formatted = ''; + if (trimmed.length > 7) { + formatted = + '${trimmed.substring(0, 3)}-${trimmed.substring(3, 7)}-${trimmed.substring(7)}'; + } else if (trimmed.length > 3) { + formatted = '${trimmed.substring(0, 3)}-${trimmed.substring(3)}'; + } else { + formatted = trimmed; + } + int selectionIndex = + newValue.selection.end + (formatted.length - newValue.text.length); + if (selectionIndex < 0) selectionIndex = 0; + if (selectionIndex > formatted.length) selectionIndex = formatted.length; + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: selectionIndex), + ); + } +} diff --git a/lib/utils/user_utils.dart b/lib/utils/user_utils.dart new file mode 100644 index 0000000..870a252 --- /dev/null +++ b/lib/utils/user_utils.dart @@ -0,0 +1,16 @@ +// 사용자 관련 유틸리티 함수 모음 +// 역할명 변환 등 공통 로직을 관리 + +import '../utils/constants.dart'; + +// 역할 코드 → 한글명 변환 함수 +String getRoleName(String role) { + switch (role) { + case UserRoles.admin: + return '관리자'; + case UserRoles.member: + return '일반 사용자'; + default: + return '알 수 없음'; + } +} diff --git a/lib/utils/validators.dart b/lib/utils/validators.dart new file mode 100644 index 0000000..3e4cadb --- /dev/null +++ b/lib/utils/validators.dart @@ -0,0 +1,136 @@ +/// 폼 필드 검증 함수 및 유틸리티 (SRP, 재사용성, 테스트 용이성 중심) + +/// 필수 입력값 검증 +String? validateRequired(String? value, String fieldName) { + if (value == null || value.isEmpty) { + return '$fieldName을(를) 입력해주세요'; + } + return null; +} + +/// 이메일 형식 검증 +String? validateEmail(String? value) { + if (value == null || value.isEmpty) { + return '이메일을 입력해주세요'; + } + final emailRegex = RegExp( + r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', + ); + if (!emailRegex.hasMatch(value)) { + return '유효한 이메일 주소를 입력해주세요'; + } + return null; +} + +/// 전화번호 형식 검증 (숫자, 하이픈만 허용) +String? validatePhoneNumber(String? value) { + if (value == null || value.isEmpty) { + return null; // 필수 입력 아님 + } + final phoneRegex = RegExp(r'^[0-9\-]+$'); + if (!phoneRegex.hasMatch(value)) { + return '전화번호는 숫자와 하이픈(-)만 입력 가능합니다'; + } + return null; +} + +/// 숫자 검증 +String? validateNumber(String? value, String fieldName) { + if (value == null || value.isEmpty) { + return '$fieldName을(를) 입력해주세요'; + } + final numberRegex = RegExp(r'^[0-9]+$'); + if (!numberRegex.hasMatch(value)) { + return '$fieldName은(는) 숫자만 입력 가능합니다'; + } + return null; +} + +/// 최소 길이 검증 +String? validateMinLength(String? value, String fieldName, int minLength) { + if (value == null || value.isEmpty) { + return '$fieldName을(를) 입력해주세요'; + } + if (value.length < minLength) { + return '$fieldName은(는) 최소 $minLength자 이상이어야 합니다'; + } + return null; +} + +/// 최대 길이 검증 +String? validateMaxLength(String? value, String fieldName, int maxLength) { + if (value == null || value.isEmpty) { + return null; // 필수 입력 아님 + } + if (value.length > maxLength) { + return '$fieldName은(는) 최대 $maxLength자 이하여야 합니다'; + } + return null; +} + +/// 시리얼 넘버 검증 (알파벳, 숫자, 하이픈만 허용) +String? validateSerialNumber(String? value) { + if (value == null || value.isEmpty) { + return null; // 필수 입력 아님 + } + final serialRegex = RegExp(r'^[a-zA-Z0-9\-]+$'); + if (!serialRegex.hasMatch(value)) { + return '시리얼 번호는 알파벳, 숫자, 하이픈(-)만 입력 가능합니다'; + } + return null; +} + +/// 바코드 검증 (숫자만 허용) +String? validateBarcode(String? value) { + if (value == null || value.isEmpty) { + return null; // 필수 입력 아님 + } + final barcodeRegex = RegExp(r'^[0-9]+$'); + if (!barcodeRegex.hasMatch(value)) { + return '바코드는 숫자만 입력 가능합니다'; + } + return null; +} + +/// FormValidator: 폼 필드 검증 유틸리티 클래스 +class FormValidator { + /// 필수 입력 검증 + static String? Function(String?) required(String errorMessage) { + return (String? value) { + if (value == null || value.trim().isEmpty) { + return errorMessage; + } + return null; + }; + } + + /// 이메일 형식 검증 + static String? Function(String?) email([String? errorMessage]) { + return (String? value) { + if (value == null || value.isEmpty) { + return null; // 빈 값은 허용 + } + final bool emailValid = RegExp( + r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', + ).hasMatch(value); + if (!emailValid) { + return errorMessage ?? '유효한 이메일 주소를 입력하세요'; + } + return null; + }; + } + + /// 전화번호 형식 검증 + static String? Function(String?) phone([String? errorMessage]) { + return (String? value) { + if (value == null || value.isEmpty) { + return null; // 빈 값은 허용 + } + final bool phoneValid = RegExp(r'^[0-9\-\s]+$').hasMatch(value); + if (!phoneValid) { + return errorMessage ?? '유효한 전화번호를 입력하세요'; + } + return null; + }; + } +} 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..5f0572f --- /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") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.superport") + +# 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..ce0e550 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) printing_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin"); + printing_plugin_register_with_registrar(printing_registrar); +} 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..0c2c3c3 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + printing +) + +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..50419bf --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,130 @@ +#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) + +// 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"); + 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"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + 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); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), 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..96cc75f --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,12 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import printing + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..29c8eb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.14' + +# 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..f2309b9 --- /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.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "superport.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.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.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.superport.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/superport.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/superport"; + }; + 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.superport.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/superport.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/superport"; + }; + 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.superport.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/superport.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/superport"; + }; + 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.14; + 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.14; + 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.14; + 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..68ef6ce --- /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..a379783 --- /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 + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.superport + +// 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..b77cad7 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,399 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + async: + dependency: transitive + description: + name: async + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + url: "https://pub.dev" + source: hosted + version: "2.12.0" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + 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" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + url: "https://pub.dev" + source: hosted + version: "1.3.2" + 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_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_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" + http: + dependency: transitive + description: + name: http + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" + intl: + dependency: transitive + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + url: "https://pub.dev" + source: hosted + version: "10.0.8" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + 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" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.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" + pdf: + dependency: "direct main" + description: + name: pdf + sha256: "28eacad99bffcce2e05bba24e50153890ad0255294f4dd78a17075a2ba5c8416" + url: "https://pub.dev" + source: hosted + version: "3.11.3" + pdf_widget_wrapper: + dependency: transitive + description: + name: pdf_widget_wrapper + sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + printing: + dependency: "direct main" + description: + name: printing + sha256: "482cd5a5196008f984bb43ed0e47cbfdca7373490b62f3b27b3299275bf22a93" + url: "https://pub.dev" + source: hosted + version: "5.14.2" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + 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: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + url: "https://pub.dev" + source: hosted + version: "14.3.1" + wave: + dependency: "direct main" + description: + name: wave + sha256: "01ebccb4caa9b150cbe4763aa2a23501bb582843a1f96281868bbb23cb4db1f9" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" +sdks: + dart: ">=3.7.2 <4.0.0" + flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..b8fdf8e --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,27 @@ +name: superport +description: "A new Flutter project." +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: ^3.7.2 + +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + pdf: ^3.10.4 + printing: ^5.11.0 + provider: ^6.1.5 + wave: ^0.2.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + uses-material-design: true + assets: + - lib/assets/fonts/NotoSansKR-VariableFont_wght.ttf diff --git a/superport_20250524.zip b/superport_20250524.zip new file mode 100644 index 0000000..50ecaed Binary files /dev/null and b/superport_20250524.zip differ 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..c80aeec --- /dev/null +++ b/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + superport + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..da26dd5 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "superport", + "short_name": "superport", + "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..5ddfd09 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(superport 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") + +# 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..3dea03b --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + PrintingPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PrintingPlugin")); +} 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..e685eaf --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + printing +) + +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..8fea004 --- /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" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "superport" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "superport.exe" "\0" + VALUE "ProductName", "superport" "\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..aca6495 --- /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", 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_