commit d1acc1d367a98a73f0b6331825db14d26835ee90 Author: egecankomur Date: Wed Jun 10 23:22:15 2026 +0300 Initial commit — DLS lab-app Flutter project diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87d5858 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# 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/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# MCP server config — contains API keys and credentials +.mcp.json + +# Claude Code project settings — local dev tooling only +.claude/ diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..83b34eb --- /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: "f6ff1529fd6d8af5f706051d9251ac9231c83407" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + - platform: android + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + - platform: ios + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + - platform: linux + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + - platform: macos + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + - platform: web + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + - platform: windows + create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + + # 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..8e5bfde --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# lab_app + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..f8fd821 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + id("kotlin-android") + id("com.google.gms.google-services") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.kovakyazilim.labapp" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.kovakyazilim.labapp" + // 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/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..0e06756 --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "751114036897", + "project_id": "dlslabapp", + "storage_bucket": "dlslabapp.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:751114036897:android:5da96f1a9691458099f2e0", + "android_client_info": { + "package_name": "com.kovakyazilim.labapp" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBjRvRur8zmczbDNOad0PGuFy19XRGS8QE" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file 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..4bff20e --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/kovakyazilim/labapp/MainActivity.kt b/android/app/src/main/kotlin/com/kovakyazilim/labapp/MainActivity.kt new file mode 100644 index 0000000..a5f71e5 --- /dev/null +++ b/android/app/src/main/kotlin/com/kovakyazilim/labapp/MainActivity.kt @@ -0,0 +1,5 @@ +package com.kovakyazilim.labapp + +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..20a7924 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-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..20a7924 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.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..e04dca9 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-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..e04dca9 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.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..17151ac 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-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..17151ac Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.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..ded3046 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-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..ded3046 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.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..36b5574 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/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..36b5574 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.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/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..522466a --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + DLS + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /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.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..e4e86fb --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,27 @@ +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.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false + id("com.google.gms.google-services") version "4.4.2" apply false +} + +include(":app") diff --git a/database.md b/database.md new file mode 100644 index 0000000..8a73e3e --- /dev/null +++ b/database.md @@ -0,0 +1,418 @@ +# DLS — Veritabanı Referansı (Directus) + +> **Backend:** Appwrite → **Directus** migrasyonu. Kaynak gerçek bu dokümandır. +> Oluşturulma: 2026-06-05 (Directus MCP ile canlıdan inşa edildi). + +## 1. Bağlantı bilgileri + +| | Değer | +|---|---| +| Directus URL | (Coolify'da set edilecek) | +| Auth | Directus built-in JWT (email + şifre) | +| Storage | Directus Files (directus_files) | +| Tenant izolasyonu | `tenants` + `tenant_members` tabloları | + +> **Appwrite → Directus eşlemesi:** +> - `Appwrite Team` → `tenants` koleksiyonu +> - `Appwrite Team membership` → `tenant_members` koleksiyonu +> - `Appwrite Auth` → `directus_users` (Directus built-in) +> - `Appwrite Storage buckets` → `directus_files` (tek bucket, `kind` alanıyla ayrım) +> - Row-level security → Directus policies + `$CURRENT_USER` + Flutter tarafında `tenant_id` filtresi + +## 2. Uygulama özeti (DLS — Dental Lab System) + +**Diş klinikleri ↔ diş laboratuvarları** arasındaki protez iş alışverişini dijitalleştirir. +- Klinik bir hasta için protez işi açar → bağlı laboratuvara yollar. +- Lab gelen kutusundan görür, durum adımlarını işler: **Ölçü → Alt Yapı Prova → Üst Yapı Prova → Cila/Bitim**. +- Tamamlanınca iş `sent` → klinik teslim alınca `delivered`. +- Her iki taraf finansal akışı kendi defterinde izler. + +## 3. Multi-tenancy & yetki modeli + +- **Tenant = `tenants` satırı.** Her tenant'ın bir `kind`'ı var: `clinic` veya `lab`. +- Kullanıcı → `tenant_members` üzerinden bir veya birden fazla tenant'a bağlanır. +- **`member_number`** (12 hane, unique): tenant'ın **bağlantı kodu**. Login'de kullanılmaz; sadece iki tenant'ı eşlemek için. +- Flutter tarafında her sorguda `tenant_id` (veya `clinic_tenant_id`/`lab_tenant_id`) filtresi **zorunlu**. +- Cross-tenant tablolar (`connections`, `jobs`, `job_files`, `job_status_history`): hem `clinic_tenant_id` hem `lab_tenant_id` taşır — iki taraf da erişir. + +## 4. Koleksiyonlar (17) + +> Notasyon: `IDX`=index, `UNQ`=unique, `FK`=foreign key, `DEF`=default. +> Tüm tablolarda sistem alanları: `id` (uuid PK), `date_created`, `date_updated` (varsa). + +### `tenants` — tenant profili (Appwrite Team karşılığı) +| Alan | Tip | Not | +|---|---|---| +| id | uuid | PK | +| kind | enum[lab\|clinic] | tenant türü · **IDX** | +| member_number | string(12) | bağlantı kodu · **UNQ** | +| company_name | string(255) | | +| company_tax_id | string(50) | | +| company_address | text | | +| company_email | string(255) | | +| company_phone | string(30) | | +| logo | uuid | → `directus_files` | +| default_currency | string(8) | DEF: `TRY` | +| status | enum[active\|suspended] | DEF: `active` | + +### `tenant_members` — kullanıcı ↔ tenant üyeliği +| Alan | Tip | Not | +|---|---|---| +| id | uuid | PK | +| tenant_id | uuid | → `tenants` CASCADE · **IDX** | +| user_id | uuid | → `directus_users` CASCADE | +| role | enum[owner\|admin\|member] | DEF: `member` | + +### `profiles` — kullanıcı başına ek bilgi +| Alan | Tip | Not | +|---|---|---| +| id | uuid | PK | +| tenant_id | uuid | → `tenants` | +| user_id | uuid | → `directus_users` | +| display_name | string(255) | | +| phone | string(30) | | +| title | string(100) | | + +### `connections` — iki tenant arası bağlantı +| Alan | Tip | Not | +|---|---|---| +| id | uuid | PK | +| clinic_tenant_id | uuid | → `tenants` · **IDX** | +| lab_tenant_id | uuid | → `tenants` · **IDX** | +| status | enum[pending\|approved\|rejected] | DEF: `pending` · **IDX** | +| requested_by | uuid | → `directus_users` | +| requested_at | timestamp | | +| approved_at | timestamp | | +| rejected_at | timestamp | | + +> **Unique constraint:** `(clinic_tenant_id, lab_tenant_id)` — aynı çift iki kez bağlanamaz. Flutter tarafında kontrol edilmeli; DB constraint Directus MCP ile eklenemez, migration SQL ile ekle. + +### `patients` — klinik hasta kayıtları +| Alan | Tip | Not | +|---|---|---| +| id | uuid | PK | +| clinic_tenant_id | uuid | → `tenants` · **IDX** | +| created_by | uuid | → `directus_users` | +| patient_code | string(50) | klinik içinde unique (soft) | +| first_name | string(100) | | +| last_name | string(100) | | +| phone | string(30) | | +| date_of_birth | date | | +| notes | text | | +| archived | boolean | DEF: false | + +### `jobs` — protez işi (çekirdek tablo — en ağır) +| Alan | Tip | Not | +|---|---|---| +| id | uuid | PK | +| clinic_tenant_id | uuid | → `tenants` · **IDX** | +| lab_tenant_id | uuid | → `tenants` · **IDX** | +| created_by | uuid | → `directus_users` | +| patient_id | uuid | → `patients` SET NULL · **IDX** | +| patient_code | string(50) | | +| prosthetic_id | uuid | → `prosthetics` SET NULL | +| prosthetic_type | enum[metal_porselen\|zirkonyum\|implant_ustu_zirkonyum\|gecici\|e_max\|diger] | | +| member_count | integer | üye (diş) sayısı | +| teeth | json | diş numaraları dizisi `List` | +| color | string(20) | Vita renk kodu | +| description | text | | +| price | decimal(10,2) | | +| currency | string(8) | | +| status | enum[pending\|in_progress\|sent\|delivered\|cancelled] | DEF: `pending` · **IDX** | +| current_step | enum[olcu\|alt_yapi_prova\|ust_yapi_prova\|cila_bitim] | | +| location | enum[at_clinic\|at_lab] | DEF: `at_clinic` | +| due_date | timestamp | | + +**Query pattern (Flutter):** +``` +// Lab gelen kutusu +GET /items/jobs?filter[lab_tenant_id][_eq]=$tenantId&filter[status][_eq]=pending + &sort=-date_created&limit=50&page=1 + +// Klinik giden işler +GET /items/jobs?filter[clinic_tenant_id][_eq]=$tenantId + &sort=-date_created&limit=50&page=1 + +// Filtre + arama +&filter[status][_in]=pending,in_progress +&search=hasta_kodu +``` + +### `job_files` — işe bağlı dosyalar +| Alan | Tip | Not | +|---|---|---| +| id | uuid | PK | +| job_id | uuid | → `jobs` CASCADE · **IDX** | +| clinic_tenant_id | uuid | → `tenants` CASCADE · **IDX** | +| lab_tenant_id | uuid | → `tenants` CASCADE · **IDX** | +| uploaded_by | uuid | → `directus_users` | +| file_id | uuid | → `directus_files` SET NULL | +| kind | enum[scan\|image\|document] | | +| name | string(255) | | +| size | integer | bayt | +| mime_type | string(100) | | +| archived_at | timestamp | soft-delete; set → download disabled | + +### `job_status_history` — stepper denetim izi +| Alan | Tip | Not | +|---|---|---| +| id | uuid | PK | +| job_id | uuid | → `jobs` CASCADE · **IDX** | +| clinic_tenant_id | uuid | → `tenants` | +| lab_tenant_id | uuid | → `tenants` | +| step | enum[olcu\|alt_yapi_prova\|ust_yapi_prova\|cila_bitim] | | +| completed_by | uuid | → `directus_users` | +| completed_at | timestamp | | +| note | text | | + +### `prosthetics` — lab ürün kataloğu +| Alan | Tip | Not | +|---|---|---| +| id | uuid | PK | +| tenant_id | uuid | → `tenants` · **IDX** | +| created_by | uuid | → `directus_users` | +| name | string(255) | | +| type | enum[...prosthetic_types...] | | +| unit_price | decimal(10,2) | | +| currency | string(8) | DEF: `TRY` | +| archived | boolean | DEF: false · **IDX** | + +### `finance_entries` — tek-taraflı defter +| Alan | Tip | Not | +|---|---|---| +| id | uuid | PK | +| tenant_id | uuid | → `tenants` · **IDX** | +| created_by | uuid | → `directus_users` | +| job_id | uuid | → `jobs` SET NULL | +| counterpart_tenant_id | uuid | → `tenants` SET NULL | +| type | enum[income\|expense\|receivable\|payable] | | +| amount | decimal(12,2) | | +| currency | string(8) | | +| status | enum[pending\|paid\|cancelled] | DEF: `pending` · **IDX** | +| date | timestamp | **IDX** | +| description | text | | + +> **Cross-tenant sync (Directus Flow):** İş `sent`/`delivered` olunca: +> - Lab tarafı → `receivable/pending` satır +> - Klinik tarafı → `payable/pending` satır +> Idempotent: `(job_id, tenant_id, type)` kombinasyonu varsa atla. + +### `payments` — ödeme kayıtları +| Alan | Tip | Not | +|---|---|---| +| id | uuid | PK | +| tenant_id | uuid | → `tenants` | +| counterpart_tenant_id | uuid | → `tenants` | +| direction | enum[inflow\|outflow] | | +| amount | decimal(12,2) | | +| currency | string(8) | | +| payment_date | timestamp | | +| method | string(30) | | +| notes | text | | +| recorded_by | uuid | → `directus_users` | +| status | enum[pending\|confirmed\|rejected] | DEF: `confirmed` | + +### `clinic_pricing` — kliniğe özel lab fiyatı +| Alan | Tip | Not | +|---|---|---| +| id | uuid | PK | +| lab_tenant_id | uuid | → `tenants` CASCADE | +| clinic_tenant_id | uuid | → `tenants` CASCADE | +| prosthetic_type | enum[...] | | +| custom_unit_price | decimal(10,2) | | +| discount_percent | decimal(5,2) | | +| currency | string(8) | | +| created_by | uuid | → `directus_users` | + +> **Unique:** `(lab_tenant_id, clinic_tenant_id, prosthetic_type)` — Flutter tarafında kontrol et. + +### `notifications` +| Alan | Tip | Not | +|---|---|---| +| id | uuid | PK | +| tenant_id | uuid | → `tenants` · **IDX** | +| user_id | uuid | → `directus_users` CASCADE | +| job_id | uuid | → `jobs` SET NULL | +| connection_id | uuid | → `connections` SET NULL | +| message | string(500) | | +| read | boolean | DEF: false · **IDX** | +| severity | enum[info\|warning] | DEF: `info` | + +### `audit_logs` +| Alan | Tip | Not | +|---|---|---| +| id | uuid | PK | +| tenant_id | uuid | → `tenants` | +| user_id | uuid | → `directus_users` | +| action | enum[create\|update\|delete] | | +| entity_type | string(50) | | +| entity_id | string(36) | | +| changes | json | | +| ip_address | string(50) | | +| user_agent | string(500) | | + +### `invite_links` +| Alan | Tip | Not | +|---|---|---| +| id | uuid | PK | +| tenant_id | uuid | → `tenants` CASCADE | +| code | string(32) | **UNQ** | +| email | string(255) | | +| role | enum[admin\|member] | | +| status | enum[pending\|accepted\|cancelled\|expired] | DEF: `pending` | +| invited_by | uuid | → `directus_users` | +| expires_at | timestamp | | +| accepted_at | timestamp | | +| accepted_by | uuid | → `directus_users` | + +### `user_preferences` +| Alan | Tip | Not | +|---|---|---| +| id | uuid | PK | +| user_id | uuid | → `directus_users` CASCADE | +| theme | enum[light\|dark\|system] | DEF: `system` | +| color_theme | string(50) | | + +## 5. İş akışı (jobs lifecycle) + +``` +Klinik açar (pending) + → Lab "İşleme Al" (in_progress, currentStep: alt_yapi_prova, location: at_lab) + → Lab "Kliniğe Gönder" (location: at_clinic) — prova için + → Klinik "Provayı Onayla" (currentStep++, location: at_lab) + → Klinik "Düzeltme İste" (location: at_lab, step aynı) + → [Cila/Bitim sonrası] Lab "Kliniğe Gönder" (status: sent, location: at_clinic) + → Klinik "Teslim Al" (status: delivered) + → finance-sync tetiklenir + → job_files arşivlenir (archived_at set) +``` + +**Adım sırası:** `olcu → alt_yapi_prova → ust_yapi_prova → cila_bitim` + +> Not: `acceptJob` = olcu tamamlandı anlamına gelir; `currentStep` direkt `alt_yapi_prova`'ya atlar. + +## 6. Query Optimizasyon Kuralları (Flutter) + +### Zorunlu filtreler — hiç ihmal etme +```dart +// Her jobs sorgusunda tenant filtresi ZORUNLU +filter: { + 'lab_tenant_id': {'_eq': tenantId}, // lab tarafı + // veya + 'clinic_tenant_id': {'_eq': tenantId}, // klinik tarafı +} +``` + +### Sayfalama — sonsuz liste / cursor +```dart +// İlk yükleme +limit: 30, page: 1 + +// Sonraki sayfa (offset bazlı) +limit: 30, offset: 30 + +// Toplam sayı için ayrı istek (pahalı — sadece gerektiğinde) +meta: 'filter_count' +``` + +### Field projection — sadece gerekeni çek +```dart +// Jobs listesi — detay alanlarını çekme +fields: ['id', 'patient_code', 'prosthetic_type', 'status', + 'current_step', 'location', 'date_created', 'due_date', + 'clinic_tenant_id.company_name', 'lab_tenant_id.company_name'] + +// Jobs detay — tam veri +fields: ['*', 'patient_id.*', 'job_files.*'] +``` + +### Relation join — N+1 önleme +```dart +// Kötü: jobs çek → her iş için ayrı tenant sorgusu +// İyi: nested fields ile tek sorguda +fields: ['*', 'clinic_tenant_id.company_name', 'lab_tenant_id.company_name'] +``` + +### Finance listesi — tarih aralığı ile kes +```dart +filter: { + 'tenant_id': {'_eq': tenantId}, + 'date': {'_gte': '2026-01-01'}, // son 6 ay gibi + 'status': {'_neq': 'cancelled'}, +} +sort: ['-date'] +limit: 50 +``` + +## 7. Klinik vs Lab — Ayrı Akışlar + +| Ekran | Klinik | Lab | +|---|---|---| +| Ana sayfa | Gönderilen işler özeti, bekleyen ödemeler | Gelen işler özeti, işlemdeki işler | +| İşler ana liste | Giden işler (`clinic_tenant_id`) | Gelen işler (`lab_tenant_id`) | +| İş aksiyon butonu | Provayı Onayla / Düzeltme İste / Teslim Al | İşleme Al / Kliniğe Gönder | +| Ürünler | — | Katalog CRUD | +| Hastalar | Hasta kayıtları | — | +| Bağlantı kur | Lab'ı `member_number` ile ara | Gelen talepleri onayla/reddet | +| Finans | Borçlar (payable) | Alacaklar (receivable) | + +## 8. Flutter → Directus API pattern + +```dart +// Auth +POST /auth/login + body: { email, password } + returns: { access_token, refresh_token, expires } + +// Token yenileme +POST /auth/refresh + body: { refresh_token } + +// Koleksiyon okuma +GET /items/{collection}?filter[field][_eq]=value&limit=30&sort=-date_created + +// Tekil kayıt +GET /items/{collection}/{id}?fields=*,relation.* + +// Oluşturma +POST /items/{collection} + body: { field: value, ... } + +// Güncelleme +PATCH /items/{collection}/{id} + body: { field: value } + +// Dosya yükleme +POST /files + Content-Type: multipart/form-data + field: file (binary) + returns: { id, filename_download, ... } +``` + +## 9. Directus Flows (server-side otomasyonlar) + +Aşağıdaki iş mantıkları Flutter client'tan değil Directus Flow'dan tetiklenmeli: + +| Tetikleyici | Flow | Açıklama | +|---|---|---| +| `jobs.status → sent\|delivered` | finance-sync | Lab receivable + klinik payable oluştur (idempotent) | +| `jobs.status → delivered` | archive-job-files | `job_files.archived_at` set et | +| `jobs` UPDATE | audit-log | `audit_logs` satır yaz | +| `connections.status → approved\|rejected` | notify-connection | İlgili tarafa bildirim gönder | + +> Flows henüz oluşturulmadı — bir sonraki adım. + +## 10. Eksik kısıtlamalar (SQL ile eklenecek) + +Directus MCP composite unique constraint desteklemiyor; veritabanına direkt SQL ile eklenecek: + +```sql +-- connections çifti unique +ALTER TABLE connections + ADD CONSTRAINT connections_pair_unique UNIQUE (clinic_tenant_id, lab_tenant_id); + +-- clinic_pricing üçlüsü unique +ALTER TABLE clinic_pricing + ADD CONSTRAINT clinic_pricing_triple_unique + UNIQUE (lab_tenant_id, clinic_tenant_id, prosthetic_type); +``` diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..f11ec86 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,46 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['DEBUG_INFORMATION_FORMAT'] = 'dwarf-with-dsym' if config.name == 'Release' + end + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..bb6c76d --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,147 @@ +PODS: + - DKImagePickerController/Core (4.3.9): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.9) + - DKImagePickerController/PhotoGallery (4.3.9): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.9) + - DKPhotoGallery (0.0.19): + - DKPhotoGallery/Core (= 0.0.19) + - DKPhotoGallery/Model (= 0.0.19) + - DKPhotoGallery/Preview (= 0.0.19) + - DKPhotoGallery/Resource (= 0.0.19) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.19): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.19): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter + - Flutter (1.0.0) + - onesignal_flutter (5.5.8): + - Flutter + - OneSignalXCFramework (= 5.5.2) + - OneSignalXCFramework (5.5.2): + - OneSignalXCFramework/OneSignalComplete (= 5.5.2) + - OneSignalXCFramework/OneSignal (5.5.2): + - OneSignalXCFramework/OneSignalCore + - OneSignalXCFramework/OneSignalExtension + - OneSignalXCFramework/OneSignalLiveActivities + - OneSignalXCFramework/OneSignalNotifications + - OneSignalXCFramework/OneSignalOSCore + - OneSignalXCFramework/OneSignalOutcomes + - OneSignalXCFramework/OneSignalUser + - OneSignalXCFramework/OneSignalComplete (5.5.2): + - OneSignalXCFramework/OneSignal + - OneSignalXCFramework/OneSignalInAppMessages + - OneSignalXCFramework/OneSignalLocation + - OneSignalXCFramework/OneSignalCore (5.5.2) + - OneSignalXCFramework/OneSignalExtension (5.5.2): + - OneSignalXCFramework/OneSignalCore + - OneSignalXCFramework/OneSignalOSCore + - OneSignalXCFramework/OneSignalOutcomes + - OneSignalXCFramework/OneSignalInAppMessages (5.5.2): + - OneSignalXCFramework/OneSignalCore + - OneSignalXCFramework/OneSignalNotifications + - OneSignalXCFramework/OneSignalOSCore + - OneSignalXCFramework/OneSignalOutcomes + - OneSignalXCFramework/OneSignalUser + - OneSignalXCFramework/OneSignalLiveActivities (5.5.2): + - OneSignalXCFramework/OneSignalCore + - OneSignalXCFramework/OneSignalOSCore + - OneSignalXCFramework/OneSignalUser + - OneSignalXCFramework/OneSignalLocation (5.5.2): + - OneSignalXCFramework/OneSignalCore + - OneSignalXCFramework/OneSignalNotifications + - OneSignalXCFramework/OneSignalOSCore + - OneSignalXCFramework/OneSignalUser + - OneSignalXCFramework/OneSignalNotifications (5.5.2): + - OneSignalXCFramework/OneSignalCore + - OneSignalXCFramework/OneSignalExtension + - OneSignalXCFramework/OneSignalOSCore + - OneSignalXCFramework/OneSignalOutcomes + - OneSignalXCFramework/OneSignalOSCore (5.5.2): + - OneSignalXCFramework/OneSignalCore + - OneSignalXCFramework/OneSignalOutcomes (5.5.2): + - OneSignalXCFramework/OneSignalCore + - OneSignalXCFramework/OneSignalOSCore + - OneSignalXCFramework/OneSignalUser (5.5.2): + - OneSignalXCFramework/OneSignalCore + - OneSignalXCFramework/OneSignalNotifications + - OneSignalXCFramework/OneSignalOSCore + - OneSignalXCFramework/OneSignalOutcomes + - SDWebImage (5.21.7): + - SDWebImage/Core (= 5.21.7) + - SDWebImage/Core (5.21.7) + - share_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - SwiftyGif (5.4.5) + +DEPENDENCIES: + - file_picker (from `.symlinks/plugins/file_picker/ios`) + - Flutter (from `Flutter`) + - onesignal_flutter (from `.symlinks/plugins/onesignal_flutter/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - OneSignalXCFramework + - SDWebImage + - SwiftyGif + +EXTERNAL SOURCES: + file_picker: + :path: ".symlinks/plugins/file_picker/ios" + Flutter: + :path: Flutter + onesignal_flutter: + :path: ".symlinks/plugins/onesignal_flutter/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + +SPEC CHECKSUMS: + DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c + DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 + file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + onesignal_flutter: 75c70a45a8d97e685273a14f04521ec121611458 + OneSignalXCFramework: 2f46ff87ccefd9afe8e3b5f9fe357072191205ff + SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 + +PODFILE CHECKSUM: d106a30630b099793eceddfd6e3c64af8abe90f1 + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..8df3ce8 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,746 @@ +// !$*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 */; }; + 604C3E861E9F106B3AA6CCEF /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9221FFF6D01C1D6BF22E5ADA /* Pods_Runner.framework */; }; + 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 */; }; + C9B4075C0256AC482F1CDD77 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AFD5D57E8CDD24E2D84F3E9B /* Pods_RunnerTests.framework */; }; +/* 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 */ + 0E5C1D2C3B5E98A6A4BF3DE7 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 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 = ""; }; + 468E8F6599C9CD0A40663335 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 58AF9370CD56571E93F6935E /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 5EDEE6A633E332DED57944E4 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; 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 = ""; }; + 7A46E2402004ACC00F45B6B1 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9221FFF6D01C1D6BF22E5ADA /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 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 = ""; }; + AFD5D57E8CDD24E2D84F3E9B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + F2CF6120D4E22FEE6FB14E1F /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + F766D9502FD64D820017E914 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = ""; }; + F766D9512FD64D820017E916 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 604C3E861E9F106B3AA6CCEF /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D66E69B2ECA4A5318EDFF043 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C9B4075C0256AC482F1CDD77 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 61E03C992BD404E58CDC96E8 /* Pods */ = { + isa = PBXGroup; + children = ( + 468E8F6599C9CD0A40663335 /* Pods-Runner.debug.xcconfig */, + 0E5C1D2C3B5E98A6A4BF3DE7 /* Pods-Runner.release.xcconfig */, + 5EDEE6A633E332DED57944E4 /* Pods-Runner.profile.xcconfig */, + 7A46E2402004ACC00F45B6B1 /* Pods-RunnerTests.debug.xcconfig */, + F2CF6120D4E22FEE6FB14E1F /* Pods-RunnerTests.release.xcconfig */, + 58AF9370CD56571E93F6935E /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 6F5B57133C9496CF920C3E25 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 9221FFF6D01C1D6BF22E5ADA /* Pods_Runner.framework */, + AFD5D57E8CDD24E2D84F3E9B /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + 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 */, + 61E03C992BD404E58CDC96E8 /* Pods */, + 6F5B57133C9496CF920C3E25 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + F766D9502FD64D820017E914 /* RunnerDebug.entitlements */, + F766D9512FD64D820017E916 /* Runner.entitlements */, + 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 = ( + 416DB929131C2E55EE26F9D1 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + D66E69B2ECA4A5318EDFF043 /* Frameworks */, + ); + 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 = ( + 42D030382DFE9994547FE992 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 7B78FAA6FA9F3F82D81A2E70 /* [CP] Embed Pods Frameworks */, + ); + 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"; + }; + 416DB929131C2E55EE26F9D1 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 42D030382DFE9994547FE992 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 7B78FAA6FA9F3F82D81A2E70 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 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_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + 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; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = XP74C5889V; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kovakyazilim.labapp; + PRODUCT_NAME = DLS; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7A46E2402004ACC00F45B6B1 /* Pods-RunnerTests.debug.xcconfig */; + 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.kovakyazilim.labapp.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; + baseConfigurationReference = F2CF6120D4E22FEE6FB14E1F /* Pods-RunnerTests.release.xcconfig */; + 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.kovakyazilim.labapp.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; + baseConfigurationReference = 58AF9370CD56571E93F6935E /* Pods-RunnerTests.profile.xcconfig */; + 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.kovakyazilim.labapp.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_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + 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_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + 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; + CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = XP74C5889V; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kovakyazilim.labapp; + PRODUCT_NAME = DLS; + PROVISIONING_PROFILE_SPECIFIER = ""; + 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; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = XP74C5889V; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kovakyazilim.labapp; + PRODUCT_NAME = DLS; + 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..a27d68e --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + 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..3b8f85c 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..abe70cc 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..a82d9fd 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..52cf2b1 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..0e9616e 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..aad87ad 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..52bb7ea 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..a82d9fd 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..b01c035 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..7cbbbf0 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..7cbbbf0 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..cab8411 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..5184411 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..72b812f 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..a2a9f95 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/GoogleService-Info.plist b/ios/Runner/GoogleService-Info.plist new file mode 100644 index 0000000..b58464b --- /dev/null +++ b/ios/Runner/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyBoMcoZAInKHTvsmg-4jakJxzP_YnRfQ6k + GCM_SENDER_ID + 751114036897 + PLIST_VERSION + 1 + BUNDLE_ID + com.kovakyazilim.labapp + PROJECT_ID + dlslabapp + STORAGE_BUCKET + dlslabapp.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:751114036897:ios:aae969759ebc15a199f2e0 + + \ No newline at end of file diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..5f331be --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,53 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + DLS + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + DLS + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + remote-notification + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + 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/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/ios/Runner/RunnerDebug.entitlements b/ios/Runner/RunnerDebug.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/ios/Runner/RunnerDebug.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/core/api/pocketbase_client.dart b/lib/core/api/pocketbase_client.dart new file mode 100644 index 0000000..78f2dc2 --- /dev/null +++ b/lib/core/api/pocketbase_client.dart @@ -0,0 +1,25 @@ +import 'package:pocketbase/pocketbase.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _kAuthKey = 'pb_auth'; + +class PocketBaseClient { + PocketBaseClient._({required this.pb}); + static PocketBaseClient? _instance; + static PocketBaseClient get instance => _instance!; + final PocketBase pb; + + static Future init() async { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString(_kAuthKey); + + final store = AsyncAuthStore( + save: (String data) => prefs.setString(_kAuthKey, data), + initial: stored, + ); + + _instance = PocketBaseClient._( + pb: PocketBase('https://pocket.kovaksoft.com', authStore: store), + ); + } +} diff --git a/lib/core/auth/auth_repository.dart b/lib/core/auth/auth_repository.dart new file mode 100644 index 0000000..a9f687b --- /dev/null +++ b/lib/core/auth/auth_repository.dart @@ -0,0 +1,100 @@ +import 'package:pocketbase/pocketbase.dart'; +import '../api/pocketbase_client.dart'; +import '../../models/tenant.dart'; +import '../../models/user_profile.dart'; + +class AuthRepository { + AuthRepository._(); + static final instance = AuthRepository._(); + + PocketBase get _pb => PocketBaseClient.instance.pb; + + Future login(String email, String password) async { + await _pb.collection('users').authWithPassword(email, password); + return _buildAuthResult(); + } + + Future logout() async { + _pb.authStore.clear(); + } + + Future isLoggedIn() async { + if (!_pb.authStore.isValid) return false; + try { + await _pb.collection('users').authRefresh(); + return true; + } catch (_) { + _pb.authStore.clear(); + return false; + } + } + + Future register({ + required String email, + required String password, + String? firstName, + String? lastName, + }) async { + await _pb.collection('users').create(body: { + 'email': email, + 'password': password, + 'passwordConfirm': password, + 'emailVisibility': true, + if (firstName != null && firstName.isNotEmpty) 'first_name': firstName, + if (lastName != null && lastName.isNotEmpty) 'last_name': lastName, + }); + return login(email, password); + } + + Future refreshSession() async { + try { + await _pb.collection('users').authRefresh(); + } catch (_) {} + return _buildAuthResult(); + } + + Future updateUserLanguage(String userId, String languageCode) async { + await _pb.collection('users').update(userId, body: { + 'preferred_language': languageCode, + }); + } + + Future updateTenant( + String id, { + String? companyName, + String? defaultCurrency, + }) async { + final body = {}; + if (companyName != null) body['company_name'] = companyName; + if (defaultCurrency != null) body['default_currency'] = defaultCurrency; + if (body.isEmpty) return; + await _pb.collection('tenants').update(id, body: body); + } + + Future _buildAuthResult() async { + final record = _pb.authStore.record!; + final user = UserProfile.fromJson(record.toJson()); + List tenants = []; + try { + tenants = await _fetchUserTenants(record.id); + } catch (_) {} + return AuthResult(user: user, tenants: tenants); + } + + Future> _fetchUserTenants(String userId) async { + final result = await _pb.collection('tenant_members').getList( + filter: 'user_id = "$userId"', + expand: 'tenant_id', + perPage: 50, + ); + return result.items + .map((r) => TenantMembership.fromJson(r.toJson())) + .toList(); + } +} + +class AuthResult { + const AuthResult({required this.user, required this.tenants}); + final UserProfile user; + final List tenants; +} diff --git a/lib/core/l10n/app_strings.dart b/lib/core/l10n/app_strings.dart new file mode 100644 index 0000000..b984a27 --- /dev/null +++ b/lib/core/l10n/app_strings.dart @@ -0,0 +1,777 @@ +// ignore_for_file: lines_longer_than_80_chars +class AppStrings { + const AppStrings({ + required this.settings, + required this.userInfo, + required this.labInfo, + required this.clinicInfo, + required this.labName, + required this.clinicName, + required this.currency, + required this.status, + required this.active, + required this.role, + required this.connections, + required this.clinicConnections, + required this.clinicConnectionsSub, + required this.labConnections, + required this.labConnectionsSub, + required this.otherMemberships, + required this.management, + required this.team, + required this.teamSub, + required this.discounts, + required this.discountsSub, + required this.reports, + required this.reportsSub, + required this.aiAssistant, + required this.aiAssistantSub, + required this.signOut, + required this.signOutTitle, + required this.signOutConfirm, + required this.cancel, + required this.save, + required this.edit, + required this.editLabInfo, + required this.editClinicInfo, + required this.labNameHint, + required this.clinicNameHint, + required this.preferences, + required this.appLanguage, + required this.languageSelection, + required this.currencySelection, + required this.languageTurkish, + required this.languageEnglish, + required this.languageRussian, + required this.languageArabic, + required this.languageGerman, + required this.type, + required this.roleOwner, + required this.roleAdmin, + required this.roleTechnician, + required this.roleDelivery, + required this.roleFinance, + required this.roleDoctor, + required this.roleMember, + required this.tenantKindClinic, + required this.tenantKindLab, + required this.signInWelcome, + required this.signInSubtitle, + required this.emailAddress, + required this.password, + required this.emailRequired, + required this.passwordRequired, + required this.signIn, + required this.noAccount, + required this.signUp, + required this.signInHeadline, + required this.signInTagline, + required this.footerCopyright, + required this.signUpTitle, + required this.signUpSubtitle, + required this.firstName, + required this.lastName, + required this.firstNameHint, + required this.lastNameHint, + required this.emailHint, + required this.passwordHint, + required this.confirmPassword, + required this.confirmPasswordHint, + required this.passwordMismatch, + required this.alreadyHaveAccount, + required this.finance, + required this.pendingReceivable, + required this.collected, + required this.pending, + required this.sortNewest, + required this.sortAmountDesc, + required this.sortAmountAsc, + required this.noPendingEntries, + required this.noPaidEntries, + required this.sort, + required this.retry, + required this.errorPrefix, + required this.laboratoryCategory, + required this.clinicCategory, + required this.jobsTitle, + required this.dashboardTitle, + required this.productsTitle, + required this.patientsTitle, + required this.close, + required this.confirm, + required this.currencyTRY, + required this.currencyUSD, + required this.currencyEUR, + required this.currencyGBP, + required this.currencyAED, + }); + + // ── General ─────────────────────────────────────────────────────────────── + final String cancel; + final String save; + final String edit; + final String preferences; + final String close; + final String confirm; + final String retry; + final String errorPrefix; + final String sort; + + // ── Settings ────────────────────────────────────────────────────────────── + final String settings; + final String userInfo; + final String labInfo; + final String clinicInfo; + final String labName; + final String clinicName; + final String currency; + final String status; + final String active; + final String role; + final String connections; + final String clinicConnections; + final String clinicConnectionsSub; + final String labConnections; + final String labConnectionsSub; + final String otherMemberships; + final String management; + final String team; + final String teamSub; + final String discounts; + final String discountsSub; + final String reports; + final String reportsSub; + final String aiAssistant; + final String aiAssistantSub; + final String signOut; + final String signOutTitle; + final String signOutConfirm; + final String editLabInfo; + final String editClinicInfo; + final String labNameHint; + final String clinicNameHint; + final String appLanguage; + final String languageSelection; + final String currencySelection; + final String languageTurkish; + final String languageEnglish; + final String languageRussian; + final String languageArabic; + final String languageGerman; + final String type; + + // ── Roles & tenant ──────────────────────────────────────────────────────── + final String roleOwner; + final String roleAdmin; + final String roleTechnician; + final String roleDelivery; + final String roleFinance; + final String roleDoctor; + final String roleMember; + final String tenantKindClinic; + final String tenantKindLab; + + // ── Auth ────────────────────────────────────────────────────────────────── + final String signInWelcome; + final String signInSubtitle; + final String emailAddress; + final String password; + final String emailRequired; + final String passwordRequired; + final String signIn; + final String noAccount; + final String signUp; + final String signInHeadline; + final String signInTagline; + final String footerCopyright; + final String signUpTitle; + final String signUpSubtitle; + final String firstName; + final String lastName; + final String firstNameHint; + final String lastNameHint; + final String emailHint; + final String passwordHint; + final String confirmPassword; + final String confirmPasswordHint; + final String passwordMismatch; + final String alreadyHaveAccount; + + // ── Finance ─────────────────────────────────────────────────────────────── + final String finance; + final String pendingReceivable; + final String collected; + final String pending; + final String sortNewest; + final String sortAmountDesc; + final String sortAmountAsc; + final String noPendingEntries; + final String noPaidEntries; + + // ── Navigation / categories ─────────────────────────────────────────────── + final String laboratoryCategory; + final String clinicCategory; + final String jobsTitle; + final String dashboardTitle; + final String productsTitle; + final String patientsTitle; + + // ── Currencies ──────────────────────────────────────────────────────────── + final String currencyTRY; + final String currencyUSD; + final String currencyEUR; + final String currencyGBP; + final String currencyAED; + + // ── Helpers ─────────────────────────────────────────────────────────────── + String tenantSelected(String name) { + if (this == ar) return '$name تم الاختيار.'; + if (this == ru) return '$name выбрана.'; + if (this == de) return '$name ausgewählt.'; + if (this == en) return '$name selected.'; + return '$name seçildi.'; + } + + static AppStrings of(String languageCode) => switch (languageCode) { + 'en' => en, + 'ru' => ru, + 'ar' => ar, + 'de' => de, + _ => tr, + }; + + // ── Turkish ─────────────────────────────────────────────────────────────── + static const tr = AppStrings( + cancel: 'İptal', + save: 'Kaydet', + edit: 'Düzenle', + preferences: 'Tercihler', + close: 'Kapat', + confirm: 'Onayla', + retry: 'Tekrar Dene', + errorPrefix: 'Hata', + sort: 'Sıralama', + settings: 'Ayarlar', + userInfo: 'Kullanıcı Bilgileri', + labInfo: 'Laboratuvar Bilgileri', + clinicInfo: 'Klinik Bilgileri', + labName: 'Laboratuvar Adı', + clinicName: 'Klinik Adı', + currency: 'Para Birimi', + status: 'Durum', + active: 'Aktif', + role: 'Rol', + connections: 'Bağlantılar', + clinicConnections: 'Klinik Bağlantıları', + clinicConnectionsSub: 'Bağlı klinikler ve istekler', + labConnections: 'Laboratuvar Bağlantıları', + labConnectionsSub: 'Bağlı lablar ve talepler', + otherMemberships: 'Diğer Üyelikler', + management: 'Yönetim', + team: 'Ekip', + teamSub: 'Üyeler ve davetler', + discounts: 'İndirimler', + discountsSub: 'Klinik ve ürün bazlı özel indirimler', + reports: 'Raporlar', + reportsSub: 'İş geçmişi, finans ve analiz', + aiAssistant: 'AI Asistan', + aiAssistantSub: 'İşler ve finans hakkında soru sor', + signOut: 'Çıkış Yap', + signOutTitle: 'Çıkış Yap', + signOutConfirm: 'Hesabınızdan çıkış yapmak istiyor musunuz?', + editLabInfo: 'Laboratuvar Bilgilerini Düzenle', + editClinicInfo: 'Klinik Bilgilerini Düzenle', + labNameHint: 'Laboratuvar adını girin', + clinicNameHint: 'Klinik adını girin', + appLanguage: 'Uygulama Dili', + languageSelection: 'Dil Seçimi', + currencySelection: 'Para Birimi Seçimi', + languageTurkish: 'Türkçe', + languageEnglish: 'English', + languageRussian: 'Русский', + languageArabic: 'العربية', + languageGerman: 'Deutsch', + type: 'Tür', + roleOwner: 'Sahibi', + roleAdmin: 'Yönetici', + roleTechnician: 'Teknisyen', + roleDelivery: 'Teslimat Elemanı', + roleFinance: 'Finans Elemanı', + roleDoctor: 'Hekim', + roleMember: 'Üye', + tenantKindClinic: 'Klinik', + tenantKindLab: 'Laboratuvar', + signInWelcome: 'Tekrar hoş geldiniz', + signInSubtitle: 'Hesabınıza giriş yapın', + emailAddress: 'E-posta adresi', + password: 'Şifre', + emailRequired: 'E-posta gereklidir', + passwordRequired: 'Şifre gereklidir', + signIn: 'Giriş Yap', + noAccount: 'Hesabın yok mu?', + signUp: 'Kayıt Ol', + signInHeadline: 'Dental Lab\nYönetimini\nBasitleştirin.', + signInTagline: 'İş takibi, klinik bağlantısı ve\ngerçek zamanlı durum izleme.', + footerCopyright: '© 2025 Dental Lab Sistemi · KovakSoft', + signUpTitle: 'Hesap Oluştur', + signUpSubtitle: 'DLS\'e kaydolun', + firstName: 'Ad', + lastName: 'Soyad', + firstNameHint: 'Adınızı girin', + lastNameHint: 'Soyadınızı girin', + emailHint: 'E-posta adresinizi girin', + passwordHint: 'Şifrenizi girin', + confirmPassword: 'Şifre Tekrar', + confirmPasswordHint: 'Şifrenizi tekrar girin', + passwordMismatch: 'Şifreler eşleşmiyor', + alreadyHaveAccount: 'Zaten hesabın var mı?', + finance: 'Finans', + pendingReceivable: 'Bekleyen Alacak', + collected: 'Tahsil Edilen', + pending: 'Bekleyen', + sortNewest: 'Yeniden Eskiye', + sortAmountDesc: 'Tutara Göre (Büyükten Küçüğe)', + sortAmountAsc: 'Tutara Göre (Küçükten Büyüğe)', + noPendingEntries: 'Bekleyen alacak yok', + noPaidEntries: 'Tahsil edilen kayıt yok', + laboratoryCategory: 'LABORATUVAR', + clinicCategory: 'KLİNİK', + jobsTitle: 'İşler', + dashboardTitle: 'Özet', + productsTitle: 'Ürünler', + patientsTitle: 'Hastalar', + currencyTRY: 'Türk Lirası (₺)', + currencyUSD: 'US Dollar (\$)', + currencyEUR: 'Euro (€)', + currencyGBP: 'British Pound (£)', + currencyAED: 'UAE Dirham (د.إ)', + ); + + // ── English ─────────────────────────────────────────────────────────────── + static const en = AppStrings( + cancel: 'Cancel', + save: 'Save', + edit: 'Edit', + preferences: 'Preferences', + close: 'Close', + confirm: 'Confirm', + retry: 'Retry', + errorPrefix: 'Error', + sort: 'Sort', + settings: 'Settings', + userInfo: 'User Information', + labInfo: 'Laboratory Information', + clinicInfo: 'Clinic Information', + labName: 'Laboratory Name', + clinicName: 'Clinic Name', + currency: 'Currency', + status: 'Status', + active: 'Active', + role: 'Role', + connections: 'Connections', + clinicConnections: 'Clinic Connections', + clinicConnectionsSub: 'Connected clinics and requests', + labConnections: 'Laboratory Connections', + labConnectionsSub: 'Connected labs and requests', + otherMemberships: 'Other Memberships', + management: 'Management', + team: 'Team', + teamSub: 'Members and invitations', + discounts: 'Discounts', + discountsSub: 'Custom discounts by clinic and product', + reports: 'Reports', + reportsSub: 'Job history, finance and analytics', + aiAssistant: 'AI Assistant', + aiAssistantSub: 'Ask about jobs and finance', + signOut: 'Sign Out', + signOutTitle: 'Sign Out', + signOutConfirm: 'Are you sure you want to sign out?', + editLabInfo: 'Edit Laboratory Info', + editClinicInfo: 'Edit Clinic Info', + labNameHint: 'Enter laboratory name', + clinicNameHint: 'Enter clinic name', + appLanguage: 'App Language', + languageSelection: 'Language Selection', + currencySelection: 'Currency Selection', + languageTurkish: 'Türkçe', + languageEnglish: 'English', + languageRussian: 'Русский', + languageArabic: 'العربية', + languageGerman: 'Deutsch', + type: 'Type', + roleOwner: 'Owner', + roleAdmin: 'Admin', + roleTechnician: 'Technician', + roleDelivery: 'Delivery Staff', + roleFinance: 'Finance Staff', + roleDoctor: 'Doctor', + roleMember: 'Member', + tenantKindClinic: 'Clinic', + tenantKindLab: 'Laboratory', + signInWelcome: 'Welcome back', + signInSubtitle: 'Sign in to your account', + emailAddress: 'Email address', + password: 'Password', + emailRequired: 'Email is required', + passwordRequired: 'Password is required', + signIn: 'Sign In', + noAccount: "Don't have an account?", + signUp: 'Sign Up', + signInHeadline: 'Simplify Dental\nLab Management.', + signInTagline: 'Job tracking, clinic connections, and\nreal-time status monitoring.', + footerCopyright: '© 2025 Dental Lab System · KovakSoft', + signUpTitle: 'Create Account', + signUpSubtitle: 'Sign up for DLS', + firstName: 'First Name', + lastName: 'Last Name', + firstNameHint: 'Enter your first name', + lastNameHint: 'Enter your last name', + emailHint: 'Enter your email address', + passwordHint: 'Enter your password', + confirmPassword: 'Confirm Password', + confirmPasswordHint: 'Re-enter your password', + passwordMismatch: 'Passwords do not match', + alreadyHaveAccount: 'Already have an account?', + finance: 'Finance', + pendingReceivable: 'Outstanding Balance', + collected: 'Collected', + pending: 'Pending', + sortNewest: 'Newest to Oldest', + sortAmountDesc: 'By Amount (High to Low)', + sortAmountAsc: 'By Amount (Low to High)', + noPendingEntries: 'No outstanding balance', + noPaidEntries: 'No collected records', + laboratoryCategory: 'LABORATORY', + clinicCategory: 'CLINIC', + jobsTitle: 'Jobs', + dashboardTitle: 'Overview', + productsTitle: 'Products', + patientsTitle: 'Patients', + currencyTRY: 'Turkish Lira (₺)', + currencyUSD: 'US Dollar (\$)', + currencyEUR: 'Euro (€)', + currencyGBP: 'British Pound (£)', + currencyAED: 'UAE Dirham (د.إ)', + ); + + // ── Russian ─────────────────────────────────────────────────────────────── + static const ru = AppStrings( + cancel: 'Отмена', + save: 'Сохранить', + edit: 'Изменить', + preferences: 'Предпочтения', + close: 'Закрыть', + confirm: 'Подтвердить', + retry: 'Повторить', + errorPrefix: 'Ошибка', + sort: 'Сортировка', + settings: 'Настройки', + userInfo: 'Информация о пользователе', + labInfo: 'Информация о лаборатории', + clinicInfo: 'Информация о клинике', + labName: 'Название лаборатории', + clinicName: 'Название клиники', + currency: 'Валюта', + status: 'Статус', + active: 'Активный', + role: 'Роль', + connections: 'Подключения', + clinicConnections: 'Подключения к клиникам', + clinicConnectionsSub: 'Подключённые клиники и запросы', + labConnections: 'Подключения к лабораториям', + labConnectionsSub: 'Подключённые лаборатории и запросы', + otherMemberships: 'Другие членства', + management: 'Управление', + team: 'Команда', + teamSub: 'Участники и приглашения', + discounts: 'Скидки', + discountsSub: 'Специальные скидки по клинике и продукту', + reports: 'Отчёты', + reportsSub: 'История заказов, финансы и аналитика', + aiAssistant: 'ИИ-ассистент', + aiAssistantSub: 'Задавайте вопросы о заказах и финансах', + signOut: 'Выйти', + signOutTitle: 'Выйти', + signOutConfirm: 'Вы уверены, что хотите выйти из аккаунта?', + editLabInfo: 'Редактировать информацию о лаборатории', + editClinicInfo: 'Редактировать информацию о клинике', + labNameHint: 'Введите название лаборатории', + clinicNameHint: 'Введите название клиники', + appLanguage: 'Язык приложения', + languageSelection: 'Выбор языка', + currencySelection: 'Выбор валюты', + languageTurkish: 'Türkçe', + languageEnglish: 'English', + languageRussian: 'Русский', + languageArabic: 'العربية', + languageGerman: 'Deutsch', + type: 'Тип', + roleOwner: 'Владелец', + roleAdmin: 'Администратор', + roleTechnician: 'Техник', + roleDelivery: 'Сотрудник доставки', + roleFinance: 'Финансовый сотрудник', + roleDoctor: 'Врач', + roleMember: 'Участник', + tenantKindClinic: 'Клиника', + tenantKindLab: 'Лаборатория', + signInWelcome: 'Добро пожаловать', + signInSubtitle: 'Войдите в свой аккаунт', + emailAddress: 'Адрес эл. почты', + password: 'Пароль', + emailRequired: 'Эл. почта обязательна', + passwordRequired: 'Пароль обязателен', + signIn: 'Войти', + noAccount: 'Нет аккаунта?', + signUp: 'Зарегистрироваться', + signInHeadline: 'Упростите управление\nзубной лабораторией.', + signInTagline: 'Отслеживание заказов, связь с клиниками\nи мониторинг в реальном времени.', + footerCopyright: '© 2025 Dental Lab System · KovakSoft', + signUpTitle: 'Создать аккаунт', + signUpSubtitle: 'Зарегистрироваться в DLS', + firstName: 'Имя', + lastName: 'Фамилия', + firstNameHint: 'Введите ваше имя', + lastNameHint: 'Введите вашу фамилию', + emailHint: 'Введите адрес эл. почты', + passwordHint: 'Введите ваш пароль', + confirmPassword: 'Подтверждение пароля', + confirmPasswordHint: 'Повторите ваш пароль', + passwordMismatch: 'Пароли не совпадают', + alreadyHaveAccount: 'Уже есть аккаунт?', + finance: 'Финансы', + pendingReceivable: 'Задолженность', + collected: 'Получено', + pending: 'Ожидающие', + sortNewest: 'Сначала новые', + sortAmountDesc: 'По сумме (убывание)', + sortAmountAsc: 'По сумме (возрастание)', + noPendingEntries: 'Нет задолженностей', + noPaidEntries: 'Нет оплаченных записей', + laboratoryCategory: 'ЛАБОРАТОРИЯ', + clinicCategory: 'КЛИНИКА', + jobsTitle: 'Заказы', + dashboardTitle: 'Обзор', + productsTitle: 'Продукты', + patientsTitle: 'Пациенты', + currencyTRY: 'Турецкая лира (₺)', + currencyUSD: 'Доллар США (\$)', + currencyEUR: 'Евро (€)', + currencyGBP: 'Британский фунт (£)', + currencyAED: 'Дирхам ОАЭ (د.إ)', + ); + + // ── Arabic ──────────────────────────────────────────────────────────────── + static const ar = AppStrings( + cancel: 'إلغاء', + save: 'حفظ', + edit: 'تعديل', + preferences: 'التفضيلات', + close: 'إغلاق', + confirm: 'تأكيد', + retry: 'إعادة المحاولة', + errorPrefix: 'خطأ', + sort: 'ترتيب', + settings: 'الإعدادات', + userInfo: 'معلومات المستخدم', + labInfo: 'معلومات المختبر', + clinicInfo: 'معلومات العيادة', + labName: 'اسم المختبر', + clinicName: 'اسم العيادة', + currency: 'العملة', + status: 'الحالة', + active: 'نشط', + role: 'الدور', + connections: 'الاتصالات', + clinicConnections: 'اتصالات العيادة', + clinicConnectionsSub: 'العيادات المتصلة والطلبات', + labConnections: 'اتصالات المختبر', + labConnectionsSub: 'المختبرات المتصلة والطلبات', + otherMemberships: 'عضويات أخرى', + management: 'الإدارة', + team: 'الفريق', + teamSub: 'الأعضاء والدعوات', + discounts: 'الخصومات', + discountsSub: 'خصومات مخصصة حسب العيادة والمنتج', + reports: 'التقارير', + reportsSub: 'تاريخ الأعمال والمالية والتحليلات', + aiAssistant: 'مساعد الذكاء الاصطناعي', + aiAssistantSub: 'اسأل عن الأعمال والمالية', + signOut: 'تسجيل الخروج', + signOutTitle: 'تسجيل الخروج', + signOutConfirm: 'هل أنت متأكد من تسجيل الخروج؟', + editLabInfo: 'تعديل معلومات المختبر', + editClinicInfo: 'تعديل معلومات العيادة', + labNameHint: 'أدخل اسم المختبر', + clinicNameHint: 'أدخل اسم العيادة', + appLanguage: 'لغة التطبيق', + languageSelection: 'اختيار اللغة', + currencySelection: 'اختيار العملة', + languageTurkish: 'Türkçe', + languageEnglish: 'English', + languageRussian: 'Русский', + languageArabic: 'العربية', + languageGerman: 'Deutsch', + type: 'النوع', + roleOwner: 'المالك', + roleAdmin: 'المسؤول', + roleTechnician: 'فني', + roleDelivery: 'موظف توصيل', + roleFinance: 'موظف مالي', + roleDoctor: 'طبيب', + roleMember: 'عضو', + tenantKindClinic: 'عيادة', + tenantKindLab: 'مختبر', + signInWelcome: 'مرحباً بعودتك', + signInSubtitle: 'سجّل دخولك إلى حسابك', + emailAddress: 'البريد الإلكتروني', + password: 'كلمة المرور', + emailRequired: 'البريد الإلكتروني مطلوب', + passwordRequired: 'كلمة المرور مطلوبة', + signIn: 'تسجيل الدخول', + noAccount: 'ليس لديك حساب؟', + signUp: 'إنشاء حساب', + signInHeadline: 'بسّط إدارة\nمختبر الأسنان.', + signInTagline: 'تتبع الأعمال والتواصل مع العيادات\nومراقبة الحالة في الوقت الفعلي.', + footerCopyright: '© 2025 Dental Lab System · KovakSoft', + signUpTitle: 'إنشاء حساب', + signUpSubtitle: 'سجّل في DLS', + firstName: 'الاسم الأول', + lastName: 'اسم العائلة', + firstNameHint: 'أدخل اسمك الأول', + lastNameHint: 'أدخل اسم عائلتك', + emailHint: 'أدخل بريدك الإلكتروني', + passwordHint: 'أدخل كلمة مرورك', + confirmPassword: 'تأكيد كلمة المرور', + confirmPasswordHint: 'أعد إدخال كلمة مرورك', + passwordMismatch: 'كلمتا المرور غير متطابقتين', + alreadyHaveAccount: 'لديك حساب بالفعل؟', + finance: 'المالية', + pendingReceivable: 'المستحقات', + collected: 'المحصّل', + pending: 'معلّق', + sortNewest: 'الأحدث أولاً', + sortAmountDesc: 'حسب المبلغ (تنازلي)', + sortAmountAsc: 'حسب المبلغ (تصاعدي)', + noPendingEntries: 'لا توجد مستحقات', + noPaidEntries: 'لا توجد سجلات محصّلة', + laboratoryCategory: 'المختبر', + clinicCategory: 'العيادة', + jobsTitle: 'الأعمال', + dashboardTitle: 'نظرة عامة', + productsTitle: 'المنتجات', + patientsTitle: 'المرضى', + currencyTRY: 'ليرة تركية (₺)', + currencyUSD: 'دولار أمريكي (\$)', + currencyEUR: 'يورو (€)', + currencyGBP: 'جنيه إسترليني (£)', + currencyAED: 'درهم إماراتي (د.إ)', + ); + + // ── German ──────────────────────────────────────────────────────────────── + static const de = AppStrings( + cancel: 'Abbrechen', + save: 'Speichern', + edit: 'Bearbeiten', + preferences: 'Einstellungen', + close: 'Schließen', + confirm: 'Bestätigen', + retry: 'Wiederholen', + errorPrefix: 'Fehler', + sort: 'Sortieren', + settings: 'Einstellungen', + userInfo: 'Benutzerinformationen', + labInfo: 'Laborinformationen', + clinicInfo: 'Klinikinformationen', + labName: 'Laborname', + clinicName: 'Klinikname', + currency: 'Währung', + status: 'Status', + active: 'Aktiv', + role: 'Rolle', + connections: 'Verbindungen', + clinicConnections: 'Klinikverbindungen', + clinicConnectionsSub: 'Verbundene Kliniken und Anfragen', + labConnections: 'Laborverbindungen', + labConnectionsSub: 'Verbundene Labore und Anfragen', + otherMemberships: 'Andere Mitgliedschaften', + management: 'Verwaltung', + team: 'Team', + teamSub: 'Mitglieder und Einladungen', + discounts: 'Rabatte', + discountsSub: 'Individuelle Rabatte nach Klinik und Produkt', + reports: 'Berichte', + reportsSub: 'Auftragsverlauf, Finanzen und Analysen', + aiAssistant: 'KI-Assistent', + aiAssistantSub: 'Fragen zu Aufträgen und Finanzen stellen', + signOut: 'Abmelden', + signOutTitle: 'Abmelden', + signOutConfirm: 'Sind Sie sicher, dass Sie sich abmelden möchten?', + editLabInfo: 'Laborinformationen bearbeiten', + editClinicInfo: 'Klinikinformationen bearbeiten', + labNameHint: 'Laborname eingeben', + clinicNameHint: 'Klinikname eingeben', + appLanguage: 'App-Sprache', + languageSelection: 'Sprachauswahl', + currencySelection: 'Währungsauswahl', + languageTurkish: 'Türkçe', + languageEnglish: 'English', + languageRussian: 'Русский', + languageArabic: 'العربية', + languageGerman: 'Deutsch', + type: 'Typ', + roleOwner: 'Inhaber', + roleAdmin: 'Administrator', + roleTechnician: 'Techniker', + roleDelivery: 'Liefermitarbeiter', + roleFinance: 'Finanzmitarbeiter', + roleDoctor: 'Arzt', + roleMember: 'Mitglied', + tenantKindClinic: 'Klinik', + tenantKindLab: 'Labor', + signInWelcome: 'Willkommen zurück', + signInSubtitle: 'Melden Sie sich in Ihrem Konto an', + emailAddress: 'E-Mail-Adresse', + password: 'Passwort', + emailRequired: 'E-Mail ist erforderlich', + passwordRequired: 'Passwort ist erforderlich', + signIn: 'Anmelden', + noAccount: 'Kein Konto?', + signUp: 'Registrieren', + signInHeadline: 'Dental-Labor-Verwaltung\nvereinfachen.', + signInTagline: 'Auftragsverfolgung, Klinikverbindungen\nund Echtzeitüberwachung.', + footerCopyright: '© 2025 Dental Lab System · KovakSoft', + signUpTitle: 'Konto erstellen', + signUpSubtitle: 'Bei DLS registrieren', + firstName: 'Vorname', + lastName: 'Nachname', + firstNameHint: 'Vornamen eingeben', + lastNameHint: 'Nachnamen eingeben', + emailHint: 'E-Mail-Adresse eingeben', + passwordHint: 'Passwort eingeben', + confirmPassword: 'Passwort bestätigen', + confirmPasswordHint: 'Passwort erneut eingeben', + passwordMismatch: 'Passwörter stimmen nicht überein', + alreadyHaveAccount: 'Haben Sie bereits ein Konto?', + finance: 'Finanzen', + pendingReceivable: 'Ausstehende Forderungen', + collected: 'Eingezogen', + pending: 'Ausstehend', + sortNewest: 'Neueste zuerst', + sortAmountDesc: 'Nach Betrag (absteigend)', + sortAmountAsc: 'Nach Betrag (aufsteigend)', + noPendingEntries: 'Keine ausstehenden Forderungen', + noPaidEntries: 'Keine eingezogenen Einträge', + laboratoryCategory: 'LABOR', + clinicCategory: 'KLINIK', + jobsTitle: 'Aufträge', + dashboardTitle: 'Übersicht', + productsTitle: 'Produkte', + patientsTitle: 'Patienten', + currencyTRY: 'Türkische Lira (₺)', + currencyUSD: 'US-Dollar (\$)', + currencyEUR: 'Euro (€)', + currencyGBP: 'Britisches Pfund (£)', + currencyAED: 'VAE-Dirham (د.إ)', + ); +} diff --git a/lib/core/providers/auth_provider.dart b/lib/core/providers/auth_provider.dart new file mode 100644 index 0000000..3fe3ee9 --- /dev/null +++ b/lib/core/providers/auth_provider.dart @@ -0,0 +1,195 @@ +import 'package:flutter/widgets.dart' show Locale; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:pocketbase/pocketbase.dart'; +import '../auth/auth_repository.dart'; +import '../services/notification_service.dart'; +import '../../models/tenant.dart'; +import '../../models/user_profile.dart'; +import 'locale_provider.dart'; + +class AuthState { + const AuthState({ + this.profile, + this.activeTenant, + this.memberships = const [], + this.isLoading = true, + this.error, + }); + + final UserProfile? profile; + final TenantMembership? activeTenant; + final List memberships; + final bool isLoading; + final String? error; + + bool get isAuthenticated => profile != null; + + AuthState copyWith({ + UserProfile? profile, + TenantMembership? activeTenant, + List? memberships, + bool? isLoading, + String? error, + bool clearError = false, + }) => + AuthState( + profile: profile ?? this.profile, + activeTenant: activeTenant ?? this.activeTenant, + memberships: memberships ?? this.memberships, + isLoading: isLoading ?? this.isLoading, + error: clearError ? null : (error ?? this.error), + ); +} + +class AuthNotifier extends StateNotifier { + AuthNotifier({this.onLocaleLoaded}) : super(const AuthState()) { + _init(); + } + + final void Function(String languageCode)? onLocaleLoaded; + final _repo = AuthRepository.instance; + + Future _init() async { + final loggedIn = await _repo.isLoggedIn(); + if (!loggedIn) { + state = const AuthState(isLoading: false); + return; + } + try { + final result = await _repo.refreshSession(); + state = AuthState( + profile: result.user, + memberships: result.tenants, + activeTenant: + result.tenants.isEmpty ? null : result.tenants.first, + isLoading: false, + ); + final isLab = result.tenants.isNotEmpty && result.tenants.first.tenant.isLab; + NotificationService.loginUser(result.user.id, isLab: isLab); + _applyLocale(result.user.preferredLanguage); + } catch (_) { + state = const AuthState(isLoading: false); + } + } + + void _applyLocale(String? code) { + if (code != null && code.isNotEmpty) { + onLocaleLoaded?.call(code); + } + } + + Future signIn(String email, String password) async { + state = state.copyWith(isLoading: true, clearError: true); + try { + final result = await _repo.login(email, password); + state = AuthState( + profile: result.user, + memberships: result.tenants, + activeTenant: + result.tenants.isEmpty ? null : result.tenants.first, + isLoading: false, + ); + final isLab = result.tenants.isNotEmpty && result.tenants.first.tenant.isLab; + NotificationService.loginUser(result.user.id, isLab: isLab); + _applyLocale(result.user.preferredLanguage); + } catch (e) { + state = state.copyWith(isLoading: false, error: _parseError(e)); + } + } + + Future register({ + required String email, + required String password, + String? firstName, + String? lastName, + }) async { + state = state.copyWith(isLoading: true, clearError: true); + try { + final result = await _repo.register( + email: email, + password: password, + firstName: firstName, + lastName: lastName, + ); + state = AuthState( + profile: result.user, + memberships: result.tenants, + activeTenant: + result.tenants.isEmpty ? null : result.tenants.first, + isLoading: false, + ); + } catch (e) { + state = state.copyWith(isLoading: false, error: _parseError(e)); + rethrow; + } + } + + Future signOut() async { + await _repo.logout(); + await NotificationService.logoutUser(); + state = const AuthState(isLoading: false); + } + + void setActiveTenant(TenantMembership membership) { + state = state.copyWith(activeTenant: membership); + } + + Future refresh() async { + try { + final result = await _repo.refreshSession(); + final currentId = state.activeTenant?.tenant.id; + final newActive = currentId != null + ? result.tenants.firstWhere( + (m) => m.tenant.id == currentId, + orElse: () => result.tenants.isNotEmpty + ? result.tenants.first + : state.activeTenant!, + ) + : (result.tenants.isNotEmpty ? result.tenants.first : null); + state = state.copyWith( + profile: result.user, + memberships: result.tenants, + activeTenant: newActive, + ); + } catch (_) {} + } + + Future updateLanguage(String languageCode) async { + final userId = state.profile?.id; + if (userId == null) return; + await _repo.updateUserLanguage(userId, languageCode); + } + + Future updateTenantInfo({ + required String tenantId, + required String companyName, + String? defaultCurrency, + }) async { + await _repo.updateTenant( + tenantId, + companyName: companyName, + defaultCurrency: defaultCurrency, + ); + await refresh(); + } + + String _parseError(Object e) { + if (e is ClientException) { + final code = e.statusCode; + if (code == 400 || code == 401 || code == 403) { + return 'E-posta veya şifre hatalı.'; + } + final msg = e.response['message'] as String? ?? ''; + if (msg.isNotEmpty) return msg; + } + return 'Bağlantı hatası. Lütfen tekrar deneyin.'; + } +} + +final authProvider = + StateNotifierProvider((ref) { + return AuthNotifier( + onLocaleLoaded: (code) => + ref.read(localeProvider.notifier).setLocale(Locale(code)), + ); +}); diff --git a/lib/core/providers/locale_provider.dart b/lib/core/providers/locale_provider.dart new file mode 100644 index 0000000..b21a1a6 --- /dev/null +++ b/lib/core/providers/locale_provider.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../l10n/app_strings.dart'; + +const _kLocaleKey = 'app_locale'; + +class LocaleNotifier extends StateNotifier { + LocaleNotifier(Locale initial) : super(initial); + + Future setLocale(Locale locale) async { + state = locale; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kLocaleKey, locale.languageCode); + } + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final code = prefs.getString(_kLocaleKey) ?? 'tr'; + return Locale(code); + } +} + +final localeProvider = StateNotifierProvider( + (ref) => LocaleNotifier(const Locale('tr')), +); + +final stringsProvider = Provider((ref) { + final locale = ref.watch(localeProvider); + return AppStrings.of(locale.languageCode); +}); + +const supportedLocales = [ + Locale('tr'), + Locale('en'), + Locale('ru'), + Locale('ar'), + Locale('de'), +]; diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart new file mode 100644 index 0000000..e942c67 --- /dev/null +++ b/lib/core/router/app_router.dart @@ -0,0 +1,496 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../theme/app_theme.dart'; +import '../widgets/tooth_logo.dart'; +import '../providers/auth_provider.dart'; +import '../../models/tenant.dart'; +import '../../features/auth/sign_in_screen.dart'; +import '../../features/auth/sign_up_screen.dart'; +import '../../features/auth/onboarding_screen.dart'; +import '../../features/clinic/dashboard/clinic_dashboard_screen.dart'; +import '../../features/clinic/jobs/clinic_jobs_screen.dart'; +import '../../features/clinic/jobs/clinic_job_detail_screen.dart'; +import '../../features/clinic/jobs/new_job_screen.dart'; +import '../../features/clinic/patients/clinic_patients_screen.dart'; +import '../../features/clinic/patients/clinic_patient_detail_screen.dart'; +import '../../features/clinic/connections/clinic_connections_screen.dart'; +import '../../features/clinic/finance/clinic_finance_screen.dart'; +import '../../features/clinic/settings/clinic_settings_screen.dart'; +import '../../features/lab/dashboard/lab_dashboard_screen.dart'; +import '../../features/lab/jobs/lab_jobs_inbound_screen.dart'; +import '../../features/lab/jobs/lab_all_jobs_screen.dart'; +import '../../features/lab/jobs/lab_job_detail_screen.dart'; +import '../../features/lab/products/lab_products_screen.dart'; +import '../../features/lab/connections/lab_connections_screen.dart'; +import '../../features/lab/finance/lab_finance_screen.dart'; +import '../../features/lab/settings/lab_settings_screen.dart'; +import '../../features/shared/reports_screen.dart'; +import '../../features/shared/ai_chat_screen.dart'; +import '../../features/lab/discounts/discounts_screen.dart'; +import '../../features/lab/connections/connection_detail_screen.dart'; +import '../../models/connection.dart'; + +// Auth routes +const routeSignIn = '/sign-in'; +const routeSignUp = '/sign-up'; +const routeOnboarding = '/onboarding'; + +// Clinic routes +const routeClinicDashboard = '/clinic/dashboard'; +const routeClinicJobs = '/clinic/jobs'; +const routeClinicJobDetail = '/clinic/jobs/:jobId'; +const routeClinicJobNew = '/clinic/jobs/new'; +const routeClinicPatients = '/clinic/patients'; +const routeClinicPatientDetail = '/clinic/patients/:patientId'; +const routeClinicConnections = '/clinic/connections'; +const routeClinicFinance = '/clinic/finance'; +const routeClinicSettings = '/clinic/settings'; +const routeClinicReports = '/clinic/reports'; +const routeClinicAi = '/clinic/ai'; + +// Lab routes +const routeLabDashboard = '/lab/dashboard'; +const routeLabJobsInbound = '/lab/jobs/inbound'; +const routeLabJobsAll = '/lab/jobs'; +const routeLabJobDetail = '/lab/jobs/:jobId'; +const routeLabProducts = '/lab/products'; +const routeLabConnections = '/lab/connections'; +const routeLabFinance = '/lab/finance'; +const routeLabSettings = '/lab/settings'; +const routeLabReports = '/lab/reports'; +const routeLabAi = '/lab/ai'; +const routeLabDiscounts = '/lab/discounts'; + +List buildRoutes() => [ + GoRoute(path: routeSignIn, builder: (_, __) => const SignInScreen()), + GoRoute(path: routeSignUp, builder: (_, __) => const SignUpScreen()), + GoRoute(path: routeOnboarding, builder: (_, __) => const OnboardingScreen()), + + // ── Clinic shell ────────────────────────────────────────────────────── + ShellRoute( + builder: (context, state, child) => _ClinicShell(child: child), + routes: [ + GoRoute(path: routeClinicDashboard, builder: (_, __) => const ClinicDashboardScreen()), + GoRoute( + path: routeClinicJobs, + builder: (_, __) => const ClinicJobsScreen(), + routes: [ + GoRoute(path: 'new', builder: (_, __) => const NewJobScreen()), + GoRoute( + path: ':jobId', + builder: (_, s) => ClinicJobDetailScreen(jobId: s.pathParameters['jobId']!), + ), + ], + ), + GoRoute( + path: routeClinicPatients, + builder: (_, __) => const ClinicPatientsScreen(), + routes: [ + GoRoute( + path: ':patientId', + builder: (_, s) => ClinicPatientDetailScreen(patientId: s.pathParameters['patientId']!), + ), + ], + ), + GoRoute(path: routeClinicConnections, builder: (_, __) => const ClinicConnectionsScreen()), + GoRoute(path: routeClinicFinance, builder: (_, __) => const ClinicFinanceScreen()), + GoRoute(path: routeClinicSettings, builder: (_, __) => const ClinicSettingsScreen()), + GoRoute(path: routeClinicReports, builder: (_, __) => const ReportsScreen()), + GoRoute(path: routeClinicAi, builder: (_, __) => const AiChatScreen()), + ], + ), + + // ── Lab shell ───────────────────────────────────────────────────────── + ShellRoute( + builder: (context, state, child) => _LabShell(child: child), + routes: [ + GoRoute(path: routeLabDashboard, builder: (_, __) => const LabDashboardScreen()), + GoRoute(path: routeLabJobsInbound, builder: (_, __) => const LabJobsInboundScreen()), + GoRoute( + path: routeLabJobsAll, + builder: (_, __) => const LabAllJobsScreen(), + routes: [ + GoRoute( + path: ':jobId', + builder: (_, s) => LabJobDetailScreen(jobId: s.pathParameters['jobId']!), + ), + ], + ), + GoRoute(path: routeLabProducts, builder: (_, __) => const LabProductsScreen()), + GoRoute( + path: routeLabConnections, + builder: (_, __) => const LabConnectionsScreen(), + routes: [ + GoRoute( + path: ':connectionId/detail', + builder: (_, s) { + final extra = s.extra as Map?; + final connection = extra?['connection'] as Connection?; + final labTenantId = extra?['labTenantId'] as String? ?? ''; + if (connection == null) { + return const Scaffold( + body: Center(child: Text('Bağlantı bulunamadı')), + ); + } + return ConnectionDetailScreen( + connection: connection, labTenantId: labTenantId); + }, + ), + ], + ), + GoRoute(path: routeLabDiscounts, builder: (_, __) => const DiscountsScreen()), + GoRoute(path: routeLabFinance, builder: (_, __) => const LabFinanceScreen()), + GoRoute(path: routeLabSettings, builder: (_, __) => const LabSettingsScreen()), + GoRoute(path: routeLabReports, builder: (_, __) => const ReportsScreen()), + GoRoute(path: routeLabAi, builder: (_, __) => const AiChatScreen()), + ], + ), + ]; + +// ── Nav item descriptor ─────────────────────────────────────────────────────── + +class _NavItem { + const _NavItem({ + required this.route, + required this.icon, + required this.selectedIcon, + required this.label, + required this.visible, + }); + final String route; + final Icon icon; + final Icon selectedIcon; + final String label; + final bool Function(TenantMembership?) visible; +} + +// ── Clinic shell ────────────────────────────────────────────────────────────── + +class _ClinicShell extends ConsumerStatefulWidget { + const _ClinicShell({required this.child}); + final Widget child; + + @override + ConsumerState<_ClinicShell> createState() => _ClinicShellState(); +} + +class _ClinicShellState extends ConsumerState<_ClinicShell> { + int _index = 0; + + static final _allItems = [ + _NavItem(route: routeClinicDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: 'Ana Sayfa', visible: (_) => true), + _NavItem(route: routeClinicJobs, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: 'İşler', visible: (m) => m?.showJobs ?? true), + _NavItem(route: routeClinicPatients, icon: const Icon(Icons.people_outline_rounded), selectedIcon: const Icon(Icons.people_rounded), label: 'Hastalar', visible: (m) => m?.showPatients ?? true), + _NavItem(route: routeClinicFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: 'Finans', visible: (m) => m?.showFinance ?? true), + _NavItem(route: routeClinicSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true), + ]; + + @override + Widget build(BuildContext context) { + final membership = ref.watch(authProvider).activeTenant; + final items = _allItems.where((it) => it.visible(membership)).toList(); + final clampedIndex = _index.clamp(0, items.length - 1); + final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + + void onTap(int i) { + setState(() => _index = i); + context.go(items[i].route); + } + + if (isDesktop) { + return Scaffold( + backgroundColor: AppColors.background, + body: Row( + children: [ + _DesktopSidebar(destinations: items, selectedIndex: clampedIndex, onTap: onTap), + Expanded(child: widget.child), + ], + ), + ); + } + + return Scaffold( + body: widget.child, + bottomNavigationBar: NavigationBar( + selectedIndex: clampedIndex, + onDestinationSelected: onTap, + destinations: [ + for (final it in items) + Semantics( + label: it.label, + button: true, + child: NavigationDestination(icon: it.icon, selectedIcon: it.selectedIcon, label: it.label), + ), + ], + ), + ); + } +} + +// ── Lab shell ───────────────────────────────────────────────────────────────── + +class _LabShell extends ConsumerStatefulWidget { + const _LabShell({required this.child}); + final Widget child; + + @override + ConsumerState<_LabShell> createState() => _LabShellState(); +} + +class _LabShellState extends ConsumerState<_LabShell> { + int _index = 0; + + static final _allItems = [ + _NavItem(route: routeLabDashboard, icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home_rounded), label: 'Ana Sayfa', visible: (_) => true), + _NavItem(route: routeLabJobsAll, icon: const Icon(Icons.work_outline_rounded), selectedIcon: const Icon(Icons.work_rounded), label: 'İşler', visible: (m) => m?.showJobs ?? true), + _NavItem(route: routeLabProducts, icon: const Icon(Icons.inventory_2_outlined), selectedIcon: const Icon(Icons.inventory_2_rounded), label: 'Ürünler', visible: (m) => m?.showProducts ?? true), + _NavItem(route: routeLabFinance, icon: const Icon(Icons.account_balance_outlined), selectedIcon: const Icon(Icons.account_balance_rounded), label: 'Finans', visible: (m) => m?.showFinance ?? true), + _NavItem(route: routeLabSettings, icon: const Icon(Icons.settings_outlined), selectedIcon: const Icon(Icons.settings_rounded), label: 'Ayarlar', visible: (_) => true), + ]; + + @override + Widget build(BuildContext context) { + final membership = ref.watch(authProvider).activeTenant; + final items = _allItems.where((it) => it.visible(membership)).toList(); + final clampedIndex = _index.clamp(0, items.length - 1); + final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + + void onTap(int i) { + setState(() => _index = i); + context.go(items[i].route); + } + + if (isDesktop) { + return Scaffold( + backgroundColor: AppColors.background, + body: Row( + children: [ + _DesktopSidebar(destinations: items, selectedIndex: clampedIndex, onTap: onTap), + Expanded(child: widget.child), + ], + ), + ); + } + + return Scaffold( + body: widget.child, + bottomNavigationBar: NavigationBar( + selectedIndex: clampedIndex, + onDestinationSelected: onTap, + destinations: [ + for (final it in items) + Semantics( + label: it.label, + button: true, + child: NavigationDestination(icon: it.icon, selectedIcon: it.selectedIcon, label: it.label), + ), + ], + ), + ); + } +} + +// ── Desktop sidebar ─────────────────────────────────────────────────────────── + +class _DesktopSidebar extends StatefulWidget { + const _DesktopSidebar({ + required this.destinations, + required this.selectedIndex, + required this.onTap, + }); + + final List<_NavItem> destinations; + final int selectedIndex; + final ValueChanged onTap; + + // Must match the toolbarHeight used in desktop SliverAppBar headers + static const double headerHeight = 64; + static const double _openWidth = 220; + static const double _closedWidth = 64; + + @override + State<_DesktopSidebar> createState() => _DesktopSidebarState(); +} + +class _DesktopSidebarState extends State<_DesktopSidebar> { + bool _open = true; + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOut, + width: _open ? _DesktopSidebar._openWidth : _DesktopSidebar._closedWidth, + decoration: const BoxDecoration( + color: AppColors.surface, + border: Border(right: BorderSide(color: AppColors.border)), + boxShadow: [BoxShadow(color: Color(0x08000000), blurRadius: 8, offset: Offset(2, 0))], + ), + child: ClipRect( + child: Column( + children: [ + // Header + Container( + height: _DesktopSidebar.headerHeight, + decoration: const BoxDecoration( + gradient: LinearGradient(colors: [AppColors.primary, AppColors.accent]), + border: Border(bottom: BorderSide(color: AppColors.border)), + ), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(9), + border: Border.all(color: Colors.white.withValues(alpha: 0.25)), + ), + child: const Center(child: ToothLogo(size: 18, color: Colors.white)), + ), + if (_open) ...[ + const SizedBox(width: 10), + const Text( + 'DLS', + style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.w800, letterSpacing: 1), + ), + ], + ], + ), + ), + + // Nav items + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + const SizedBox(height: 8), + for (int i = 0; i < widget.destinations.length; i++) + _SidebarItem( + icon: widget.destinations[i].icon, + selectedIcon: widget.destinations[i].selectedIcon, + label: widget.destinations[i].label, + selected: widget.selectedIndex == i, + open: _open, + onTap: () => widget.onTap(i), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + + // Toggle button + Container( + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: AppColors.border)), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => setState(() => _open = !_open), + child: SizedBox( + height: 48, + child: Row( + mainAxisAlignment: _open ? MainAxisAlignment.start : MainAxisAlignment.center, + children: [ + if (_open) const SizedBox(width: 20), + AnimatedRotation( + duration: const Duration(milliseconds: 220), + turns: _open ? 0.5 : 0, + child: const Icon(Icons.chevron_right_rounded, color: AppColors.textMuted, size: 20), + ), + if (_open) ...[ + const SizedBox(width: 8), + const Text('Daralt', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.textMuted)), + ], + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +// ── Sidebar nav item ────────────────────────────────────────────────────────── + +class _SidebarItem extends StatelessWidget { + const _SidebarItem({ + required this.icon, + required this.selectedIcon, + required this.label, + required this.selected, + required this.open, + required this.onTap, + }); + + final Widget icon; + final Widget selectedIcon; + final String label; + final bool selected; + final bool open; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final item = Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: Material( + color: selected ? const Color(0xFFDBEAFE) : Colors.transparent, + borderRadius: BorderRadius.circular(10), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: SizedBox( + height: 40, + child: open + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + IconTheme( + data: IconThemeData( + color: selected ? AppColors.primary : AppColors.textSecondary, + size: 20, + ), + child: selected ? selectedIcon : icon, + ), + const SizedBox(width: 12), + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + color: selected ? AppColors.primary : AppColors.textSecondary, + ), + ), + ], + ), + ) + : Center( + child: IconTheme( + data: IconThemeData( + color: selected ? AppColors.primary : AppColors.textSecondary, + size: 20, + ), + child: selected ? selectedIcon : icon, + ), + ), + ), + ), + ), + ); + + if (!open) { + return Tooltip(message: label, preferBelow: false, child: item); + } + return item; + } +} diff --git a/lib/core/router/router_provider.dart b/lib/core/router/router_provider.dart new file mode 100644 index 0000000..379b867 --- /dev/null +++ b/lib/core/router/router_provider.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../providers/auth_provider.dart'; +import 'app_router.dart'; + +// Bridges Riverpod auth state changes to GoRouter's Listenable interface +class _AuthRouterNotifier extends ChangeNotifier { + _AuthRouterNotifier(this._ref) { + _ref.listen(authProvider, (_, __) => notifyListeners()); + } + final Ref _ref; +} + +final routerProvider = Provider((ref) { + final notifier = _AuthRouterNotifier(ref); + + return GoRouter( + refreshListenable: notifier, + initialLocation: routeSignIn, + redirect: (context, state) { + final auth = ref.read(authProvider); + + if (auth.isLoading) return null; + + final loc = state.matchedLocation; + final onLoginOrRegister = loc == routeSignIn || loc == routeSignUp; + final onAuthPage = onLoginOrRegister || loc == routeOnboarding; + + if (!auth.isAuthenticated) { + return onAuthPage ? null : routeSignIn; + } + + // Authenticated but no tenant → onboarding + if (auth.activeTenant == null) { + return loc == routeOnboarding ? null : routeOnboarding; + } + + final isLab = auth.activeTenant!.tenant.isLab; + + if (onAuthPage) { + return isLab ? routeLabDashboard : routeClinicDashboard; + } + + if (isLab && loc.startsWith('/clinic')) return routeLabDashboard; + if (!isLab && loc.startsWith('/lab')) return routeClinicDashboard; + + return null; + }, + routes: buildRoutes(), + ); +}); diff --git a/lib/core/services/ai_actions.dart b/lib/core/services/ai_actions.dart new file mode 100644 index 0000000..de40cd9 --- /dev/null +++ b/lib/core/services/ai_actions.dart @@ -0,0 +1,171 @@ +import '../../features/shared/job_files_repository.dart'; +import '../../features/shared/tenant_team_repository.dart'; +import '../../models/job_file.dart'; +import '../../models/tenant.dart'; +import '../api/pocketbase_client.dart'; + +// ── Message segments ────────────────────────────────────────────────────────── + +sealed class MessageSegment {} + +class TextSegment extends MessageSegment { + TextSegment(this.text); + final String text; +} + +class ActionSegment extends MessageSegment { + ActionSegment(this.action); + final AiAction action; +} + +// ── Action model ────────────────────────────────────────────────────────────── + +class AiAction { + const AiAction({ + required this.type, + required this.params, + required this.label, + }); + final String type; + final Map params; + final String label; + + bool get isDangerous => type == 'cancel_job'; + bool get isFileAction => type == 'job_files'; +} + +// ── Action outcome ──────────────────────────────────────────────────────────── + +sealed class ActionOutcome {} + +class ActionSuccess extends ActionOutcome { + ActionSuccess(this.message); + final String message; +} + +class ActionError extends ActionOutcome { + ActionError(this.error); + final String error; +} + +class ActionFiles extends ActionOutcome { + ActionFiles(this.files); + final List files; +} + +// ── Parser ──────────────────────────────────────────────────────────────────── + +List parseSegments(String text) { + // Strip code fences wrapping tags that the AI sometimes emits. + // Handles: ```xml\n\n``` and ```\n\n``` + text = text.replaceAllMapped( + RegExp(r'```(?:xml)?\s*\n(\s*]*/>)\s*\n\s*```'), + (m) => m.group(1)!, + ); + // Also handle inline variant: ```xml ``` + text = text.replaceAllMapped( + RegExp(r'```(?:xml)?\s*(]*/>)\s*```'), + (m) => m.group(1)!, + ); + + final pattern = RegExp(r'', dotAll: true); + final segments = []; + int last = 0; + + for (final m in pattern.allMatches(text)) { + final before = text.substring(last, m.start).trim(); + if (before.isNotEmpty) segments.add(TextSegment(before)); + + final attrs = _parseAttrs(m.group(1) ?? ''); + segments.add(ActionSegment(AiAction( + type: attrs['type'] ?? '', + params: attrs, + label: attrs['label'] ?? attrs['type'] ?? 'İşlem', + ))); + last = m.end; + } + + final rest = text.substring(last).trim(); + if (rest.isNotEmpty) segments.add(TextSegment(rest)); + return segments; +} + +Map _parseAttrs(String s) { + final result = {}; + for (final m in RegExp(r'(\w+)="([^"]*)"').allMatches(s)) { + result[m.group(1)!] = m.group(2)!; + } + return result; +} + +// ── Executor ────────────────────────────────────────────────────────────────── + +class AiActionExecutor { + static final _pb = PocketBaseClient.instance.pb; + + static Future execute( + AiAction action, + TenantMembership membership, + ) async { + try { + return switch (action.type) { + 'cancel_job' => await _cancelJob(action.params), + 'mark_delivered' => await _markDelivered(action.params), + 'job_files' => await _jobFiles(action.params), + 'add_member' => await _addMember(action.params, membership), + _ => ActionError('Bilinmeyen işlem türü: ${action.type}'), + }; + } catch (e) { + final msg = e.toString(); + if (msg.length > 120) return ActionError('Sunucu hatası'); + return ActionError(msg); + } + } + + static Future _cancelJob(Map p) async { + final id = p['job_id']; + if (id == null || id.isEmpty) return ActionError('İş ID bulunamadı.'); + await _pb.collection('jobs').update(id, body: {'status': 'cancelled'}); + return ActionSuccess('İş başarıyla iptal edildi.'); + } + + static Future _markDelivered(Map p) async { + final id = p['job_id']; + if (id == null || id.isEmpty) return ActionError('İş ID bulunamadı.'); + await _pb.collection('jobs').update(id, body: {'status': 'delivered'}); + return ActionSuccess('İş teslim edildi olarak işaretlendi.'); + } + + static Future _jobFiles(Map p) async { + final id = p['job_id']; + if (id == null || id.isEmpty) return ActionError('İş ID bulunamadı.'); + final files = await JobFilesRepository.instance.listForJob(id); + if (files.isEmpty) return ActionSuccess('Bu iş için henüz dosya yüklenmemiş.'); + return ActionFiles(files); + } + + static Future _addMember( + Map p, + TenantMembership membership, + ) async { + final email = p['email']; + final firstName = p['first_name']; + final lastName = p['last_name'] ?? ''; + final role = p['role']; + final password = p['password']; + + if (email == null || firstName == null || role == null || password == null) { + return ActionError('Eksik bilgi: e-posta, ad, rol ve şifre gerekli.'); + } + + await TenantTeamRepository.instance.addMember( + tenantId: membership.tenant.id, + email: email, + password: password, + firstName: firstName, + lastName: lastName, + role: TenantMembership.parseRole(role), + ); + return ActionSuccess('$firstName $lastName ekibe eklendi.'); + } +} diff --git a/lib/core/services/ai_context_builder.dart b/lib/core/services/ai_context_builder.dart new file mode 100644 index 0000000..e99a798 --- /dev/null +++ b/lib/core/services/ai_context_builder.dart @@ -0,0 +1,226 @@ +import 'package:pocketbase/pocketbase.dart'; +import '../api/pocketbase_client.dart'; +import '../../models/tenant.dart'; + +class AiContextBuilder { + AiContextBuilder._(); + static final instance = AiContextBuilder._(); + + PocketBase get _pb => PocketBaseClient.instance.pb; + + Future build(TenantMembership membership) async { + final tenant = membership.tenant; + final tenantId = tenant.id; + final isLab = tenant.kind == TenantKind.lab; + + final now = DateTime.now(); + final dateStr = '${now.day}.${now.month}.${now.year}'; + + final results = await Future.wait([ + _fetchActiveJobs(tenantId, isLab), + _fetchRecentDelivered(tenantId, isLab), + _fetchFinance(tenantId, isLab), + _fetchTeam(tenantId), + ]); + + final actions = _actionsPrompt(isLab); + + return 'Sen DLS (Dental Lab System) uygulamasinin akilli asistanisin.\n' + '${tenant.companyName} adli ${isLab ? 'dental laboratuvarinin' : 'dis kliniginin'} verilerine erisebilirsin.\n' + 'Kullanici rolu: ${isLab ? 'LABORATUVAR' : 'KLINIK'}\n' + '\n' + 'Tarih: $dateStr\n' + '\n' + '${results[0]}\n' + '\n' + '${results[1]}\n' + '\n' + '${results[2]}\n' + '\n' + '${results[3]}\n' + '\n' + '$actions\n' + '\n' + 'Yanit kurallari:\n' + '- Turkce, kisa ve net yaz\n' + '- Sadece yukaridaki verilerden hareketle yorum yap\n' + '- Listelerde madde isareti (- ) kullan\n' + '- Onemli bilgileri **kalin** yaz\n' + '- Aksiyon etiketlerini HERZAMAN metnin sonuna koy\n' + '- ${isLab ? 'Is kodlari icin [ID:...] formatini kullan' : 'Hasta kodlari ve is durumlarini net belirt'}\n'; + } + + static String _actionsPrompt(bool isLab) { + final buf = StringBuffer(); + buf.writeln('## EYLEM YETKILERIN'); + buf.writeln('Kullanici bir islem yapmak istediginde asagidaki XML etiketlerini yanita ekle:'); + buf.writeln(''); + buf.writeln('Is dosyalarini gostermek:'); + buf.writeln(''); + buf.writeln(''); + buf.writeln('Is iptal etmek:'); + buf.writeln(''); + if (!isLab) { + buf.writeln(''); + buf.writeln('Teslim edildi isaretlemek (sadece klinik):'); + buf.writeln(''); + } + buf.writeln(''); + buf.writeln('Ekip uyesi eklemek (TUM bilgiler alindiktan sonra):'); + buf.writeln(''); + buf.writeln(''); + buf.writeln('KURALLAR:'); + buf.writeln('- Etiketi SADECE kullanici acikca islem istediginde ekle'); + buf.writeln('- Sifre sorulursa kullanicidan al, ASLA uydurma'); + buf.writeln('- iptal gibi geri alinmaz islemleri acikca belirt'); + buf.writeln('- Etiket icindeki job_id degerini yukaridaki is listesinden al'); + buf.writeln('- etiketlerini KESINLİKLE kod blogu (```xml veya ```) icine ALMA, duz metin olarak yaz'); + return buf.toString(); + } + + Future _fetchActiveJobs(String tenantId, bool isLab) async { + try { + final tenantField = isLab ? 'lab_tenant_id' : 'clinic_tenant_id'; + final counterpartField = isLab ? 'clinic_tenant_id' : 'lab_tenant_id'; + final result = await _pb.collection('jobs').getList( + filter: '$tenantField = "$tenantId" && status != "delivered" && status != "cancelled"', + perPage: 60, + sort: '-created', + expand: counterpartField, + ); + + if (result.items.isEmpty) return '## Aktif Isler\nSu an aktif is yok.'; + + final counterpartLabel = isLab ? 'Klinik' : 'Lab'; + final lines = result.items.map((r) { + final j = r.toJson(); + final jobId = j['id'] as String? ?? ''; + final expand = j['expand'] as Map?; + final counterpart = + (expand?[counterpartField] as Map?)?['company_name'] as String? ?? '-'; + final status = _statusTr(j['status'] as String? ?? ''); + final prosthetic = j['prosthetic_type'] as String? ?? '-'; + final patient = j['patient_code'] as String? ?? '-'; + final step = j['current_step'] as String?; + final stepPart = (step != null && step.isNotEmpty) ? ' | Adim: $step' : ''; + final due = j['due_date'] as String? ?? ''; + final duePart = due.isNotEmpty ? ' | Termin: ${due.substring(0, 10)}' : ''; + return '- [ID:$jobId] Hasta: $patient | $prosthetic | $status$stepPart | $counterpartLabel: $counterpart$duePart'; + }).join('\n'); + + return '## Aktif Isler (${result.items.length})\n$lines'; + } catch (e) { + return '## Aktif Isler\n(Veri alinamadi: $e)'; + } + } + + Future _fetchRecentDelivered(String tenantId, bool isLab) async { + try { + final tenantField = isLab ? 'lab_tenant_id' : 'clinic_tenant_id'; + final counterpartField = isLab ? 'clinic_tenant_id' : 'lab_tenant_id'; + final result = await _pb.collection('jobs').getList( + filter: '$tenantField = "$tenantId" && status = "delivered"', + perPage: 10, + sort: '-updated', + expand: counterpartField, + ); + + if (result.items.isEmpty) return '## Son Teslim Edilenler\nHenuz teslim edilen is yok.'; + + final counterpartLabel = isLab ? 'Klinik' : 'Lab'; + final lines = result.items.map((r) { + final j = r.toJson(); + final jobId = j['id'] as String? ?? ''; + final expand = j['expand'] as Map?; + final counterpart = + (expand?[counterpartField] as Map?)?['company_name'] as String? ?? '-'; + final prosthetic = j['prosthetic_type'] as String? ?? '-'; + final patient = j['patient_code'] as String? ?? '-'; + final updated = (j['updated'] as String? ?? ''); + final datePart = updated.length >= 10 ? updated.substring(0, 10) : ''; + return '- [ID:$jobId] Hasta: $patient | $prosthetic | $counterpartLabel: $counterpart${datePart.isNotEmpty ? ' | Tarih: $datePart' : ''}'; + }).join('\n'); + + return '## Son Teslim Edilenler (son 10)\n$lines'; + } catch (_) { + return '## Son Teslim Edilenler\n(Veri alinamadi)'; + } + } + + Future _fetchFinance(String tenantId, bool isLab) async { + try { + final type = isLab ? 'receivable' : 'payable'; + final result = await _pb.collection('finance_entries').getList( + filter: 'tenant_id = "$tenantId" && type = "$type"', + perPage: 200, + ); + + double pending = 0, paid = 0; + for (final r in result.items) { + final j = r.toJson(); + final amount = (j['amount'] as num?)?.toDouble() ?? 0; + if (j['status'] == 'pending') { + pending += amount; + } else { + paid += amount; + } + } + + final label = isLab ? 'alacak' : 'borc'; + return '## Finans\n' + '- Bekleyen $label: ${pending.toStringAsFixed(0)} TL\n' + '- Tahsil edilen: ${paid.toStringAsFixed(0)} TL'; + } catch (_) { + return '## Finans\n(Veri alinamadi)'; + } + } + + Future _fetchTeam(String tenantId) async { + try { + final result = await _pb.collection('tenant_members').getList( + filter: 'tenant_id = "$tenantId"', + expand: 'user_id', + perPage: 50, + ); + + if (result.items.isEmpty) return '## Ekip\nUye yok.'; + + final lines = result.items.map((r) { + final j = r.toJson(); + final expand = j['expand'] as Map?; + final user = expand?['user_id'] as Map?; + final first = (user?['first_name'] as String?) ?? ''; + final last = (user?['last_name'] as String?) ?? ''; + final email = (user?['email'] as String?) ?? ''; + final name = + '$first $last'.trim().isNotEmpty ? '$first $last'.trim() : email; + final role = _roleTr(j['role'] as String? ?? ''); + return '- $name ($role)'; + }).join('\n'); + + return '## Ekip (${result.items.length} uye)\n$lines'; + } catch (_) { + return '## Ekip\n(Veri alinamadi)'; + } + } + + static String _statusTr(String s) => switch (s) { + 'pending' => 'Bekliyor', + 'in_progress' => 'Devam ediyor', + 'sent' => 'Gonderildi', + 'revision' => 'Revizyon', + 'delivered' => 'Teslim edildi', + 'cancelled' => 'Iptal', + _ => s, + }; + + static String _roleTr(String s) => switch (s) { + 'owner' => 'Sahibi', + 'admin' => 'Yonetici', + 'technician' => 'Teknisyen', + 'delivery' => 'Teslimat', + 'finance' => 'Finans', + 'doctor' => 'Hekim', + _ => 'Uye', + }; +} diff --git a/lib/core/services/ai_service.dart b/lib/core/services/ai_service.dart new file mode 100644 index 0000000..7ceeb48 --- /dev/null +++ b/lib/core/services/ai_service.dart @@ -0,0 +1,71 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:http/http.dart' as http; + +class AiService { + static const _baseUrl = 'https://api.featherless.ai/v1'; + static const _apiKey = + 'rc_e10f49aaa4f7af03dcd9da115cfc12cc1988665e895955c11f77788ee5ad93c6'; + static const _model = 'Qwen/Qwen2.5-7B-Instruct'; + + AiService._(); + static final instance = AiService._(); + + Stream streamChat({ + required String systemPrompt, + required List> messages, + }) async* { + final client = http.Client(); + try { + final request = http.Request( + 'POST', + Uri.parse('$_baseUrl/chat/completions'), + ); + request.headers.addAll({ + 'Authorization': 'Bearer $_apiKey', + 'Content-Type': 'application/json', + }); + request.body = jsonEncode({ + 'model': _model, + 'messages': [ + {'role': 'system', 'content': systemPrompt}, + ...messages, + ], + 'stream': true, + 'max_tokens': 2048, + 'temperature': 0.7, + }); + + final response = await client.send(request); + if (response.statusCode != 200) { + final body = await response.stream.bytesToString(); + String msg = 'API hatası ${response.statusCode}'; + try { + final j = jsonDecode(body) as Map; + msg = (j['error'] as Map?)?['message'] as String? ?? msg; + } catch (_) {} + throw Exception(msg); + } + + final lines = response.stream + .transform(utf8.decoder) + .transform(const LineSplitter()); + + await for (final line in lines) { + if (!line.startsWith('data: ')) continue; + final payload = line.substring(6).trim(); + if (payload == '[DONE]') break; + try { + final j = jsonDecode(payload) as Map; + final choices = j['choices'] as List?; + if (choices == null || choices.isEmpty) continue; + final delta = choices.first['delta'] as Map?; + final content = delta?['content'] as String?; + if (content != null && content.isNotEmpty) yield content; + } catch (_) {} + } + } finally { + client.close(); + } + } +} diff --git a/lib/core/services/job_history_service.dart b/lib/core/services/job_history_service.dart new file mode 100644 index 0000000..196ed3a --- /dev/null +++ b/lib/core/services/job_history_service.dart @@ -0,0 +1,117 @@ +import 'package:pocketbase/pocketbase.dart'; +import '../api/pocketbase_client.dart'; +import '../../models/job.dart'; + +class JobHistoryEntry { + const JobHistoryEntry({ + required this.id, + required this.action, + required this.createdAt, + this.step, + this.note, + }); + final String id; + final JobHistoryAction action; + final JobStep? step; + final String? note; + final DateTime createdAt; +} + +enum JobHistoryAction { + accepted, + handedToClinic, + approved, + revisionRequested, + delivered, + cancelled, +} + +extension JobHistoryActionExt on JobHistoryAction { + String get value => switch (this) { + JobHistoryAction.accepted => 'accepted', + JobHistoryAction.handedToClinic => 'handed_to_clinic', + JobHistoryAction.approved => 'approved', + JobHistoryAction.revisionRequested => 'revision_requested', + JobHistoryAction.delivered => 'delivered', + JobHistoryAction.cancelled => 'cancelled', + }; +} + +class JobHistoryService { + JobHistoryService._(); + static final instance = JobHistoryService._(); + + PocketBase get _pb => PocketBaseClient.instance.pb; + + String get _currentUserId => + (_pb.authStore.record?.id) ?? (_pb.authStore.model as dynamic)?.id as String? ?? ''; + + Future> listForJob(String jobId) async { + try { + final result = await _pb.collection('job_status_history').getList( + filter: 'job_id = "$jobId"', + perPage: 200, + ); + return (result.items.map((r) { + final j = r.toJson(); + String? str(dynamic v) { + final s = v as String?; + return (s == null || s.isEmpty) ? null : s; + } + return JobHistoryEntry( + id: j['id'] as String, + action: _parseAction(j['action_type'] as String? ?? ''), + step: str(j['step']) != null ? _parseStep(j['step'] as String) : null, + note: str(j['note']), + createdAt: DateTime.parse(j['created'] as String), + ); + }).toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt))); + } catch (_) { + return []; + } + } + + static JobHistoryAction _parseAction(String s) => switch (s) { + 'accepted' => JobHistoryAction.accepted, + 'handed_to_clinic' => JobHistoryAction.handedToClinic, + 'approved' => JobHistoryAction.approved, + 'revision_requested' => JobHistoryAction.revisionRequested, + 'delivered' => JobHistoryAction.delivered, + _ => JobHistoryAction.cancelled, + }; + + static JobStep _parseStep(String s) => switch (s) { + 'alt_yapi_prova' => JobStep.altYapiProva, + 'ust_yapi_prova' => JobStep.ustYapiProva, + 'mum_prova' => JobStep.mumProva, + 'disler_prova' => JobStep.dislerProva, + 'dayanak_prova' => JobStep.dayanakProva, + 'kron_prova' => JobStep.kronProva, + 'cila_bitim' => JobStep.cilaBitim, + _ => JobStep.olcu, + }; + + Future append({ + required String jobId, + required String clinicTenantId, + required String labTenantId, + required JobHistoryAction action, + JobStep? step, + String? note, + String? userId, + }) async { + try { + await _pb.collection('job_status_history').create(body: { + 'job_id': jobId, + 'clinic_tenant_id': clinicTenantId, + 'lab_tenant_id': labTenantId, + 'completed_by': userId ?? _currentUserId, + 'action_type': action.value, + if (step != null) 'step': step.value, + if (note != null && note.isNotEmpty) 'note': note, + }); + } catch (_) { + // history failures must never block the main mutation + } + } +} diff --git a/lib/core/services/notification_service.dart b/lib/core/services/notification_service.dart new file mode 100644 index 0000000..0886db6 --- /dev/null +++ b/lib/core/services/notification_service.dart @@ -0,0 +1,64 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:go_router/go_router.dart'; +import 'package:onesignal_flutter/onesignal_flutter.dart'; + +// ─── Replace with your OneSignal App ID from onesignal.com ────────────────── +const _kOneSignalAppId = '524cb6d8-2640-4f85-bb24-c9c762233de7'; +// ──────────────────────────────────────────────────────────────────────────── + +class NotificationService { + NotificationService._(); + + static GoRouter? _router; + static bool _initialized = false; + + static void setRouter(GoRouter router) => _router = router; + + static bool get _supported => + !kIsWeb && (Platform.isIOS || Platform.isAndroid || Platform.isMacOS); + + static Future init() async { + if (!_supported || _initialized) return; + _initialized = true; + + OneSignal.initialize(_kOneSignalAppId); + await OneSignal.Notifications.requestPermission(true); + + // Show notification even when app is in foreground + OneSignal.Notifications.addForegroundWillDisplayListener((event) { + event.notification.display(); + }); + + // Tap → navigate to job detail + OneSignal.Notifications.addClickListener((event) { + final data = event.notification.additionalData; + if (data == null) return; + final jobId = data['job_id'] as String?; + final tenantType = data['tenant_type'] as String?; + if (jobId == null || _router == null) return; + if (tenantType == 'lab') { + _router!.push('/lab/jobs/$jobId'); + } else { + _router!.push('/clinic/jobs/$jobId'); + } + }); + } + + /// Call after successful login. Links the OneSignal player to this user. + static Future loginUser(String userId, {bool isLab = false}) async { + if (!_supported) return; + try { + await OneSignal.login(userId); + OneSignal.User.addTagWithKey('tenant_type', isLab ? 'lab' : 'clinic'); + } catch (_) {} + } + + /// Call on logout. + static Future logoutUser() async { + if (!_supported) return; + try { + await OneSignal.logout(); + } catch (_) {} + } +} diff --git a/lib/core/services/realtime_service.dart b/lib/core/services/realtime_service.dart new file mode 100644 index 0000000..f5c0628 --- /dev/null +++ b/lib/core/services/realtime_service.dart @@ -0,0 +1,37 @@ +import 'package:pocketbase/pocketbase.dart'; +import '../api/pocketbase_client.dart'; + +typedef UnsubFn = Future Function(); + +class RealtimeService { + RealtimeService._(); + static final instance = RealtimeService._(); + + final _pb = PocketBaseClient.instance.pb; + + UnsubFn watch( + String collection, { + String topic = '*', + String filter = '', + required void Function(RecordSubscriptionEvent) onEvent, + }) { + UnsubFn? cancel; + + _pb.collection(collection).subscribe(topic, onEvent, filter: filter).then((fn) { + cancel = fn; + }); + + return () async { + try { + final fn = cancel; + if (fn != null) { + await fn(); + } else { + await _pb.collection(collection).unsubscribe(topic); + } + } catch (_) { + await _pb.collection(collection).unsubscribe(topic); + } + }; + } +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..9172e29 --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,299 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; + +abstract final class AppColors { + // Primary — professional navy + static const primary = Color(0xFF1E3A5F); + static const onPrimary = Color(0xFFFFFFFF); + + // Accent — sky blue CTA + static const accent = Color(0xFF0369A1); + static const onAccent = Color(0xFFFFFFFF); + + // Status + static const pending = Color(0xFFF59E0B); + static const pendingBg = Color(0xFFFFFBEB); + static const inProgress = Color(0xFF0369A1); + static const inProgressBg = Color(0xFFEFF6FF); + static const success = Color(0xFF059669); + static const successBg = Color(0xFFECFDF5); + static const cancelled = Color(0xFFDC2626); + static const cancelledBg = Color(0xFFFEF2F2); + + // Surfaces + static const background = Color(0xFFF1F5F9); + static const surface = Color(0xFFFFFFFF); + static const surfaceVariant = Color(0xFFF8FAFC); + static const muted = Color(0xFFE2E8F0); + static const border = Color(0xFFE2E8F0); + + // Text + static const textPrimary = Color(0xFF0F172A); + static const textSecondary = Color(0xFF64748B); + static const textMuted = Color(0xFF94A3B8); + + // Dark variants + static const darkBackground = Color(0xFF0F172A); + static const darkSurface = Color(0xFF1E293B); + static const darkSurfaceVariant = Color(0xFF273344); + static const darkBorder = Color(0xFF334155); + static const darkTextPrimary = Color(0xFFF1F5F9); + static const darkTextSecondary = Color(0xFF94A3B8); +} + +abstract final class AppLayout { + /// Window width above which the sidebar navigation is shown instead of bottom nav. + static const double sidebarBreakpoint = 720.0; + + /// Window width above which wide-desktop content layouts activate + /// (e.g., 3-column stat card row, 2-column forms). + static const double wideBreakpoint = 1100.0; + + /// Maximum content width used for dashboard horizontal padding. + static const double contentMaxWidth = 1040.0; +} + +abstract final class AppTheme { + static TextTheme _buildTextTheme(Color bodyColor, Color displayColor) { + final base = GoogleFonts.plusJakartaSansTextTheme(); + return base.copyWith( + displayLarge: base.displayLarge?.copyWith(color: displayColor, fontWeight: FontWeight.w800), + displayMedium: base.displayMedium?.copyWith(color: displayColor, fontWeight: FontWeight.w700), + headlineLarge: base.headlineLarge?.copyWith(color: displayColor, fontWeight: FontWeight.w700), + headlineMedium: base.headlineMedium?.copyWith(color: displayColor, fontWeight: FontWeight.w700), + headlineSmall: base.headlineSmall?.copyWith(color: displayColor, fontWeight: FontWeight.w600), + titleLarge: base.titleLarge?.copyWith(color: displayColor, fontWeight: FontWeight.w600), + titleMedium: base.titleMedium?.copyWith(color: displayColor, fontWeight: FontWeight.w600), + titleSmall: base.titleSmall?.copyWith(color: displayColor, fontWeight: FontWeight.w500), + bodyLarge: base.bodyLarge?.copyWith(color: bodyColor), + bodyMedium: base.bodyMedium?.copyWith(color: bodyColor), + bodySmall: base.bodySmall?.copyWith(color: AppColors.textSecondary), + labelLarge: base.labelLarge?.copyWith(fontWeight: FontWeight.w600), + labelMedium: base.labelMedium?.copyWith(fontWeight: FontWeight.w500), + ); + } + + static final light = ThemeData( + useMaterial3: true, + colorScheme: ColorScheme( + brightness: Brightness.light, + primary: AppColors.primary, + onPrimary: AppColors.onPrimary, + primaryContainer: const Color(0xFFDBEAFE), + onPrimaryContainer: AppColors.primary, + secondary: AppColors.accent, + onSecondary: AppColors.onAccent, + secondaryContainer: const Color(0xFFE0F2FE), + onSecondaryContainer: AppColors.accent, + tertiary: AppColors.success, + onTertiary: Colors.white, + tertiaryContainer: AppColors.successBg, + onTertiaryContainer: AppColors.success, + error: AppColors.cancelled, + onError: Colors.white, + errorContainer: AppColors.cancelledBg, + onErrorContainer: AppColors.cancelled, + surface: AppColors.surface, + onSurface: AppColors.textPrimary, + surfaceContainerHighest: AppColors.surfaceVariant, + onSurfaceVariant: AppColors.textSecondary, + outline: AppColors.border, + outlineVariant: AppColors.muted, + scrim: Colors.black54, + inverseSurface: AppColors.darkSurface, + onInverseSurface: AppColors.darkTextPrimary, + inversePrimary: const Color(0xFF93C5FD), + ), + scaffoldBackgroundColor: AppColors.background, + textTheme: _buildTextTheme(AppColors.textPrimary, AppColors.textPrimary), + appBarTheme: AppBarTheme( + backgroundColor: AppColors.surface, + foregroundColor: AppColors.textPrimary, + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + shadowColor: Colors.transparent, + centerTitle: false, + systemOverlayStyle: SystemUiOverlayStyle.dark, + titleTextStyle: GoogleFonts.plusJakartaSans( + fontSize: 17, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + iconTheme: const IconThemeData(color: AppColors.textPrimary, size: 22), + ), + cardTheme: CardThemeData( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: AppColors.border, width: 1), + ), + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: AppColors.surface, + elevation: 0, + shadowColor: Colors.transparent, + indicatorColor: const Color(0xFFDBEAFE), + iconTheme: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return const IconThemeData(color: AppColors.primary, size: 22); + } + return IconThemeData(color: AppColors.textSecondary, size: 22); + }), + labelTextStyle: WidgetStateProperty.resolveWith((states) { + final style = GoogleFonts.plusJakartaSans(fontSize: 11); + if (states.contains(WidgetState.selected)) { + return style.copyWith(fontWeight: FontWeight.w600, color: AppColors.primary); + } + return style.copyWith(fontWeight: FontWeight.w500, color: AppColors.textSecondary); + }), + surfaceTintColor: Colors.transparent, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.onPrimary, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + textStyle: GoogleFonts.plusJakartaSans(fontSize: 15, fontWeight: FontWeight.w600), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + minimumSize: const Size(0, 48), + side: const BorderSide(color: AppColors.border, width: 1.5), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + textStyle: GoogleFonts.plusJakartaSans(fontSize: 15, fontWeight: FontWeight.w600), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceVariant, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.accent, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.cancelled, width: 1.5), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + labelStyle: GoogleFonts.plusJakartaSans(color: AppColors.textSecondary), + hintStyle: GoogleFonts.plusJakartaSans(color: AppColors.textMuted), + ), + chipTheme: ChipThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + side: BorderSide.none, + ), + dividerTheme: const DividerThemeData( + color: AppColors.border, + thickness: 1, + space: 1, + ), + listTileTheme: const ListTileThemeData( + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + ), + ); + + static final dark = ThemeData( + useMaterial3: true, + colorScheme: ColorScheme( + brightness: Brightness.dark, + primary: const Color(0xFF93C5FD), + onPrimary: const Color(0xFF1E3A5F), + primaryContainer: const Color(0xFF1E3A5F), + onPrimaryContainer: const Color(0xFFDBEAFE), + secondary: const Color(0xFF7DD3FC), + onSecondary: const Color(0xFF0C4A6E), + secondaryContainer: const Color(0xFF0C4A6E), + onSecondaryContainer: const Color(0xFFE0F2FE), + tertiary: const Color(0xFF6EE7B7), + onTertiary: const Color(0xFF064E3B), + tertiaryContainer: const Color(0xFF064E3B), + onTertiaryContainer: const Color(0xFFD1FAE5), + error: const Color(0xFFFCA5A5), + onError: const Color(0xFF7F1D1D), + errorContainer: const Color(0xFF7F1D1D), + onErrorContainer: const Color(0xFFFEE2E2), + surface: AppColors.darkSurface, + onSurface: AppColors.darkTextPrimary, + surfaceContainerHighest: AppColors.darkSurfaceVariant, + onSurfaceVariant: AppColors.darkTextSecondary, + outline: AppColors.darkBorder, + outlineVariant: const Color(0xFF1E293B), + scrim: Colors.black87, + inverseSurface: const Color(0xFFF1F5F9), + onInverseSurface: AppColors.textPrimary, + inversePrimary: AppColors.primary, + ), + scaffoldBackgroundColor: AppColors.darkBackground, + textTheme: _buildTextTheme(AppColors.darkTextPrimary, AppColors.darkTextPrimary), + appBarTheme: AppBarTheme( + backgroundColor: AppColors.darkSurface, + foregroundColor: AppColors.darkTextPrimary, + elevation: 0, + scrolledUnderElevation: 1, + systemOverlayStyle: SystemUiOverlayStyle.light, + titleTextStyle: GoogleFonts.plusJakartaSans( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.darkTextPrimary, + ), + ), + cardTheme: CardThemeData( + elevation: 0, + color: AppColors.darkSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: AppColors.darkBorder, width: 1), + ), + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: AppColors.darkSurface, + elevation: 0, + indicatorColor: const Color(0xFF1E3A5F), + iconTheme: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return const IconThemeData(color: Color(0xFF93C5FD), size: 22); + } + return IconThemeData(color: AppColors.darkTextSecondary, size: 22); + }), + labelTextStyle: WidgetStateProperty.resolveWith((states) { + final style = GoogleFonts.plusJakartaSans(fontSize: 11); + if (states.contains(WidgetState.selected)) { + return style.copyWith(fontWeight: FontWeight.w600, color: const Color(0xFF93C5FD)); + } + return style.copyWith(fontWeight: FontWeight.w500, color: AppColors.darkTextSecondary); + }), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF93C5FD), + foregroundColor: const Color(0xFF1E3A5F), + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + textStyle: GoogleFonts.plusJakartaSans(fontSize: 15, fontWeight: FontWeight.w600), + ), + ), + dividerTheme: const DividerThemeData( + color: AppColors.darkBorder, + thickness: 1, + space: 1, + ), + ); +} diff --git a/lib/core/utils/currency_formatter.dart b/lib/core/utils/currency_formatter.dart new file mode 100644 index 0000000..6384c72 --- /dev/null +++ b/lib/core/utils/currency_formatter.dart @@ -0,0 +1,35 @@ +class CurrencyFormatter { + static const _symbols = { + 'TRY': '₺', + 'USD': '\$', + 'EUR': '€', + 'GBP': '£', + 'AED': 'د.إ', + }; + + static const _rtlSymbols = {'AED'}; + + static String symbol(String code) => _symbols[code] ?? code; + + static String format(double amount, String currencyCode) { + final sym = symbol(currencyCode); + final isRtl = _rtlSymbols.contains(currencyCode); + final value = _formatNumber(amount); + return isRtl ? '$value $sym' : '$sym$value'; + } + + static String _formatNumber(double amount) { + final formatted = amount.toStringAsFixed(2); + final parts = formatted.split('.'); + final intPart = parts[0]; + final decPart = parts[1]; + final buf = StringBuffer(); + final digits = intPart.replaceAll('-', ''); + final isNeg = intPart.startsWith('-'); + for (int i = 0; i < digits.length; i++) { + if (i > 0 && (digits.length - i) % 3 == 0) buf.write(','); + buf.write(digits[i]); + } + return '${isNeg ? '-' : ''}$buf.$decPart'; + } +} diff --git a/lib/core/utils/file_download_helper.dart b/lib/core/utils/file_download_helper.dart new file mode 100644 index 0000000..b5940a8 --- /dev/null +++ b/lib/core/utils/file_download_helper.dart @@ -0,0 +1,40 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../api/pocketbase_client.dart'; +import '../../models/job_file.dart'; +import '../theme/app_theme.dart'; + +class FileDownloadHelper { + static Future download(BuildContext context, JobFile file, {Rect? shareOrigin}) async { + if (file.downloadUrl.isEmpty) return; + final messenger = ScaffoldMessenger.of(context); + try { + final pb = PocketBaseClient.instance.pb; + final fileToken = await pb.files.getToken(); + final uri = Uri.parse('${file.downloadUrl}?token=$fileToken'); + final response = await http.get(uri); + if (response.statusCode != 200) throw Exception('HTTP ${response.statusCode}'); + final dir = await getTemporaryDirectory(); + final path = '${dir.path}/${file.name}'; + await File(path).writeAsBytes(response.bodyBytes); + await Share.shareXFiles( + [XFile(path, mimeType: file.mimeType ?? 'application/octet-stream')], + subject: file.name, + sharePositionOrigin: shareOrigin ?? const Rect.fromLTWH(0, 0, 1, 1), + ); + } catch (e) { + if (context.mounted) { + messenger.showSnackBar( + SnackBar( + content: Text('İndirilemedi: $e'), + backgroundColor: AppColors.cancelled, + ), + ); + } + } + } +} diff --git a/lib/core/widgets/app_search_field.dart b/lib/core/widgets/app_search_field.dart new file mode 100644 index 0000000..3d1418d --- /dev/null +++ b/lib/core/widgets/app_search_field.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +class AppSearchField extends StatelessWidget { + const AppSearchField({ + super.key, + required this.controller, + required this.onChanged, + this.hint, + }); + + final TextEditingController controller; + final ValueChanged onChanged; + final String? hint; + + @override + Widget build(BuildContext context) { + return Container( + color: AppColors.surface, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: ListenableBuilder( + listenable: controller, + builder: (context, _) => Container( + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: TextField( + controller: controller, + onChanged: onChanged, + style: const TextStyle( + fontSize: 14, + color: AppColors.textPrimary, + ), + decoration: InputDecoration( + hintText: hint ?? 'Ara...', + hintStyle: const TextStyle( + color: AppColors.textMuted, + fontSize: 14, + ), + prefixIcon: const Icon( + Icons.search_rounded, + color: AppColors.textMuted, + size: 20, + ), + suffixIcon: controller.text.isNotEmpty + ? GestureDetector( + onTap: () { + controller.clear(); + onChanged(''); + }, + child: const Padding( + padding: EdgeInsets.all(12), + child: Icon( + Icons.close_rounded, + color: AppColors.textMuted, + size: 16, + ), + ), + ) + : null, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ), + ); + } +} diff --git a/lib/core/widgets/gradient_app_bar.dart b/lib/core/widgets/gradient_app_bar.dart new file mode 100644 index 0000000..bac5030 --- /dev/null +++ b/lib/core/widgets/gradient_app_bar.dart @@ -0,0 +1,328 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../theme/app_theme.dart'; +import 'tooth_logo.dart'; + +class GradientAppBar extends StatelessWidget implements PreferredSizeWidget { + const GradientAppBar({ + super.key, + required this.title, + required this.category, + this.actions = const [], + this.searchController, + this.onSearchChanged, + this.searchHint, + }); + + final String title; + final String category; + final List actions; + final TextEditingController? searchController; + final ValueChanged? onSearchChanged; + final String? searchHint; + + bool get _hasSearch => + searchController != null && onSearchChanged != null; + + @override + Size get preferredSize => + Size.fromHeight(kToolbarHeight + (_hasSearch ? 52.0 : 0.0)); + + @override + Widget build(BuildContext context) { + final isDesktop = + MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + final searchBottom = _hasSearch + ? _SearchBarBottom( + controller: searchController!, + onChanged: onSearchChanged!, + hint: searchHint ?? 'Ara...', + ) + : null; + + if (isDesktop) { + return AppBar( + backgroundColor: AppColors.surface, + foregroundColor: AppColors.textPrimary, + elevation: 0, + scrolledUnderElevation: 0, + automaticallyImplyLeading: false, + titleSpacing: 24, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'DLS', + style: TextStyle( + fontSize: 11, + color: AppColors.textSecondary.withValues(alpha: 0.8), + letterSpacing: 0.3, + ), + ), + Text( + title, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + actions: [ + ...actions, + if (actions.isNotEmpty) const SizedBox(width: 8), + ], + iconTheme: + const IconThemeData(color: AppColors.textSecondary, size: 22), + actionsIconTheme: + const IconThemeData(color: AppColors.textSecondary, size: 22), + bottom: searchBottom, + ); + } + + return AppBar( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + systemOverlayStyle: SystemUiOverlayStyle.light, + automaticallyImplyLeading: false, + leadingWidth: 60, + leading: Padding( + padding: const EdgeInsets.only(left: 16, top: 8, bottom: 8), + child: Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: + const Center(child: ToothLogo(size: 20, color: Colors.white)), + ), + ), + titleSpacing: 8, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + category, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.65), + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.5, + ), + ), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + actions: actions.isNotEmpty + ? [...actions, const SizedBox(width: 4)] + : null, + iconTheme: const IconThemeData(color: Colors.white, size: 22), + actionsIconTheme: + const IconThemeData(color: Colors.white, size: 22), + flexibleSpace: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF0F172A), AppColors.primary], + ), + ), + ), + bottom: searchBottom, + ); + } +} + +// ── iOS-26-style search bar shown below the AppBar title ───────────────────── + +class _SearchBarBottom extends StatelessWidget implements PreferredSizeWidget { + const _SearchBarBottom({ + required this.controller, + required this.onChanged, + required this.hint, + }); + + final TextEditingController controller; + final ValueChanged onChanged; + final String hint; + + @override + Size get preferredSize => const Size.fromHeight(52); + + @override + Widget build(BuildContext context) { + final isDesktop = + MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + final bg = isDesktop + ? AppColors.surfaceVariant + : Colors.white.withValues(alpha: 0.15); + final textColor = isDesktop ? AppColors.textPrimary : Colors.white; + final iconColor = isDesktop + ? AppColors.textMuted + : Colors.white.withValues(alpha: 0.65); + final hintColor = isDesktop + ? AppColors.textMuted + : Colors.white.withValues(alpha: 0.5); + + return SizedBox( + height: 52, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 10), + child: ListenableBuilder( + listenable: controller, + builder: (context, _) => Container( + height: 38, + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(12), + border: isDesktop + ? Border.all(color: AppColors.border) + : null, + ), + child: TextField( + controller: controller, + onChanged: onChanged, + style: TextStyle(color: textColor, fontSize: 15), + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle(color: hintColor, fontSize: 15), + prefixIcon: Padding( + padding: const EdgeInsets.only(left: 10, right: 6), + child: Icon(Icons.search_rounded, + size: 18, color: iconColor), + ), + prefixIconConstraints: + const BoxConstraints(minWidth: 36, minHeight: 36), + suffixIcon: controller.text.isNotEmpty + ? GestureDetector( + onTap: () { + controller.clear(); + onChanged(''); + }, + child: Padding( + padding: const EdgeInsets.only(right: 10), + child: Icon(Icons.close_rounded, + size: 16, color: iconColor), + ), + ) + : null, + suffixIconConstraints: + const BoxConstraints(minWidth: 32, minHeight: 36), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 10), + isDense: true, + ), + ), + ), + ), + ), + ); + } +} + +// ── Sort / filter bottom sheet ──────────────────────────────────────────────── + +Future showSortSheet( + BuildContext context, { + required String title, + required List options, + required int current, +}) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (ctx) => _SortSheet( + title: title, + options: options, + current: current, + ), + ); +} + +class _SortSheet extends StatelessWidget { + const _SortSheet({ + required this.title, + required this.options, + required this.current, + }); + + final String title; + final List options; + final int current; + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + title, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + ), + const SizedBox(height: 8), + for (int i = 0; i < options.length; i++) + ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 20), + title: Text( + options[i], + style: TextStyle( + color: i == current + ? AppColors.primary + : AppColors.textPrimary, + fontWeight: i == current + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + trailing: i == current + ? const Icon(Icons.check_rounded, + color: AppColors.primary, size: 20) + : null, + onTap: () => Navigator.pop(context, i), + ), + SizedBox(height: MediaQuery.paddingOf(context).bottom + 8), + ], + ), + ); + } +} diff --git a/lib/core/widgets/pill_tabs.dart b/lib/core/widgets/pill_tabs.dart new file mode 100644 index 0000000..ac91e66 --- /dev/null +++ b/lib/core/widgets/pill_tabs.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +class PillTabs extends StatelessWidget { + const PillTabs({ + super.key, + required this.tabs, + required this.selected, + required this.onSelect, + this.counts, + }); + + final List tabs; + final int selected; + final ValueChanged onSelect; + final List? counts; + + @override + Widget build(BuildContext context) { + return Container( + color: AppColors.surface, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + child: Row( + children: [ + for (int i = 0; i < tabs.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + _PillTab( + label: tabs[i], + count: counts != null && i < counts!.length ? counts![i] : null, + selected: selected == i, + onTap: () => onSelect(i), + ), + ], + ], + ), + ), + const Divider(height: 1, thickness: 1, color: AppColors.border), + ], + ), + ); + } +} + +class _PillTab extends StatelessWidget { + const _PillTab({ + required this.label, + required this.selected, + required this.onTap, + this.count, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + final int? count; + + @override + Widget build(BuildContext context) { + return Semantics( + label: label, + button: true, + excludeSemantics: true, + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: selected ? AppColors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: selected ? AppColors.primary : AppColors.border, + width: 1.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + color: selected ? Colors.white : AppColors.textSecondary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + if (count != null) ...[ + const SizedBox(width: 6), + AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: selected + ? Colors.white.withValues(alpha: 0.25) + : AppColors.inProgressBg, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '$count', + style: TextStyle( + color: selected ? Colors.white : AppColors.inProgress, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/core/widgets/tooth_logo.dart b/lib/core/widgets/tooth_logo.dart new file mode 100644 index 0000000..c53ff3a --- /dev/null +++ b/lib/core/widgets/tooth_logo.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +/// Renders the DLS brand logo — navy tooth + cyan chevrons. +/// +/// [color] null → brand colors (#00397C tooth + #57B8CE chevrons). +/// Pass a color (e.g. Colors.white) for monochrome override on dark backgrounds. +class ToothLogo extends StatelessWidget { + const ToothLogo({ + super.key, + required this.size, + this.color, + }); + + final double size; + final Color? color; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: size * 1.9, + height: size, + child: CustomPaint( + painter: _DlsLogoPainter(color: color), + ), + ); + } +} + +class _DlsLogoPainter extends CustomPainter { + const _DlsLogoPainter({this.color}); + final Color? color; + + static const _navy = Color(0xFF00397C); + static const _cyan = Color(0xFF57B8CE); + + @override + void paint(Canvas canvas, Size size) { + final toothColor = color ?? _navy; + final chevronColor = color ?? _cyan; + + // Content bounding box in SVG 200×200 space: x=[42.5..157.5], y=[72..133] + // Width=115, Height=61 → aspect ~1.885 ≈ widget aspect 1.9 + const svgLeft = 42.5, svgTop = 72.0, svgWidth = 115.0, svgHeight = 61.0; + final s = size.height / svgHeight; + final dx = (size.width - svgWidth * s) / 2.0 - svgLeft * s; + final dy = (size.height - svgHeight * s) / 2.0 - svgTop * s; + + canvas.translate(dx, dy); + canvas.scale(s); + + _drawTooth(canvas, toothColor); + _drawChevrons(canvas, chevronColor); + } + + static void _drawTooth(Canvas canvas, Color color) { + // SVG path with scale(0.58) + translate(100,100) applied inline. + const cx = 100.0, cy = 100.0, sc = 0.58; + double px(double v) => cx + v * sc; + double py(double v) => cy + v * sc; + + final path = Path() + ..moveTo(px(0), py(-46)) + ..cubicTo(px(-22), py(-50), px(-44), py(-38), px(-44), py(-12)) + ..cubicTo(px(-44), py(8), px(-34), py(32), px(-26), py(46)) + ..cubicTo(px(-20), py(57), px(-11), py(53), px(-8), py(33)) + ..cubicTo(px(-6), py(19), px(-2), py(17), px(0), py(17)) + ..cubicTo(px(2), py(17), px(6), py(19), px(8), py(33)) + ..cubicTo(px(11), py(53), px(20), py(57), px(26), py(46)) + ..cubicTo(px(34), py(32), px(44), py(8), px(44), py(-12)) + ..cubicTo(px(44), py(-38), px(22), py(-50), px(0), py(-46)) + ..close(); + + canvas.drawPath(path, Paint()..color = color..style = PaintingStyle.fill); + } + + static void _drawChevrons(Canvas canvas, Color color) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 11.0 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + + // Polyline points + translate(100,100). Left: (-52,-22)→(-34,0)→(-52,22) + canvas.drawPath( + Path() + ..moveTo(48, 78) + ..lineTo(66, 100) + ..lineTo(48, 122), + paint, + ); + // Right: (52,-22)→(34,0)→(52,22) + canvas.drawPath( + Path() + ..moveTo(152, 78) + ..lineTo(134, 100) + ..lineTo(152, 122), + paint, + ); + } + + @override + bool shouldRepaint(_DlsLogoPainter old) => old.color != color; +} diff --git a/lib/features/auth/auth_widgets.dart b/lib/features/auth/auth_widgets.dart new file mode 100644 index 0000000..e6e8aa0 --- /dev/null +++ b/lib/features/auth/auth_widgets.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import '../../core/theme/app_theme.dart'; + +/// Animated floating blob background used on auth screens. +/// [bright] = true → white blobs (for dark/gradient backgrounds). +/// [bright] = false → primary/accent blobs (for light backgrounds). +class AnimatedAuthBg extends StatefulWidget { + const AnimatedAuthBg({super.key, this.bright = false}); + final bool bright; + + @override + State createState() => _AnimatedAuthBgState(); +} + +class _AnimatedAuthBgState extends State + with SingleTickerProviderStateMixin { + late AnimationController _ctrl; + late Animation _anim; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(seconds: 8), + )..repeat(reverse: true); + _anim = CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + Color _blob(double alpha) => widget.bright + ? Colors.white.withValues(alpha: alpha * 1.5) + : AppColors.primary.withValues(alpha: alpha); + + Color _blobAccent(double alpha) => widget.bright + ? Colors.white.withValues(alpha: alpha * 1.2) + : AppColors.accent.withValues(alpha: alpha); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _anim, + builder: (_, __) { + final t = _anim.value; + return Stack( + children: [ + Positioned( + top: -80 + t * 30, + left: -60 + t * 20, + child: AuthBlob(size: 300, color: _blob(0.08)), + ), + Positioned( + top: 200 - t * 40, + right: -100 + t * 25, + child: AuthBlob(size: 250, color: _blobAccent(0.06)), + ), + Positioned( + bottom: 100 + t * 30, + left: 50 - t * 15, + child: AuthBlob(size: 200, color: _blob(0.05)), + ), + Positioned( + bottom: -50 + t * 20, + right: -50 + t * 10, + child: AuthBlob(size: 280, color: _blobAccent(0.07)), + ), + Positioned( + top: 350 + t * 25, + left: 80 + t * 20, + child: AuthBlob(size: 160, color: _blob(0.04)), + ), + Positioned( + top: -40 - t * 10, + left: 120 + t * 30, + child: AuthBlob(size: 180, color: _blobAccent(0.05)), + ), + ], + ); + }, + ); + } +} + +/// A simple solid circle used as a background blob. +class AuthBlob extends StatelessWidget { + const AuthBlob({super.key, required this.size, required this.color}); + + final double size; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration(shape: BoxShape.circle, color: color), + ); + } +} diff --git a/lib/features/auth/onboarding_repository.dart b/lib/features/auth/onboarding_repository.dart new file mode 100644 index 0000000..a263715 --- /dev/null +++ b/lib/features/auth/onboarding_repository.dart @@ -0,0 +1,32 @@ +import 'package:pocketbase/pocketbase.dart'; +import '../../core/api/pocketbase_client.dart'; +import '../../core/auth/auth_repository.dart'; + +class OnboardingRepository { + OnboardingRepository._(); + static final instance = OnboardingRepository._(); + + PocketBase get _pb => PocketBaseClient.instance.pb; + + Future createTenantAndJoin({ + required String kind, + required String companyName, + }) async { + final userId = _pb.authStore.record!.id; + + final tenant = await _pb.collection('tenants').create(body: { + 'kind': kind, + 'company_name': companyName, + 'status': 'active', + 'default_currency': 'TRY', + }); + + await _pb.collection('tenant_members').create(body: { + 'tenant_id': tenant.id, + 'user_id': userId, + 'role': 'owner', + }); + + return AuthRepository.instance.refreshSession(); + } +} diff --git a/lib/features/auth/onboarding_screen.dart b/lib/features/auth/onboarding_screen.dart new file mode 100644 index 0000000..0e0cecc --- /dev/null +++ b/lib/features/auth/onboarding_screen.dart @@ -0,0 +1,461 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/providers/auth_provider.dart'; +import 'onboarding_repository.dart'; + +class OnboardingScreen extends ConsumerStatefulWidget { + const OnboardingScreen({super.key}); + + @override + ConsumerState createState() => _OnboardingScreenState(); +} + +class _OnboardingScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + final _formKey = GlobalKey(); + final _nameCtrl = TextEditingController(); + String _selectedKind = 'clinic'; + bool _loading = false; + String? _error; + late AnimationController _animCtrl; + late Animation _fadeAnim; + late Animation _slideAnim; + + @override + void initState() { + super.initState(); + _animCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _fadeAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeOut); + _slideAnim = Tween( + begin: const Offset(0, 0.08), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _animCtrl, curve: Curves.easeOutCubic)); + _animCtrl.forward(); + } + + @override + void dispose() { + _animCtrl.dispose(); + _nameCtrl.dispose(); + super.dispose(); + } + + Future _create() async { + if (!_formKey.currentState!.validate()) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final result = await OnboardingRepository.instance.createTenantAndJoin( + kind: _selectedKind, + companyName: _nameCtrl.text.trim(), + ); + if (!mounted) return; + ref.read(authProvider.notifier).setActiveTenant(result.tenants.first); + } catch (e) { + setState(() { + _error = 'Hesap oluşturulamadı. Lütfen tekrar deneyin.'; + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final size = MediaQuery.sizeOf(context); + + return Scaffold( + backgroundColor: const Color(0xFF4F46E5), + body: Stack( + children: [ + // ── Gradient background ────────────────────────────────────────── + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomCenter, + colors: [Color(0xFF3730A3), Color(0xFF6366F1)], + ), + ), + ), + + // ── Decorative circles ─────────────────────────────────────────── + Positioned( + top: -40, + right: -60, + child: Container( + width: 220, + height: 220, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.06), + ), + ), + ), + Positioned( + top: 80, + left: -70, + child: Container( + width: 200, + height: 200, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.04), + ), + ), + ), + + // ── Content ────────────────────────────────────────────────────── + Column( + children: [ + // Header + SafeArea( + bottom: false, + child: SizedBox( + height: size.height * 0.26, + child: Stack( + children: [ + // Sign out + Positioned( + right: 8, + top: 4, + child: TextButton.icon( + onPressed: () => + ref.read(authProvider.notifier).signOut(), + icon: const Icon(Icons.logout_rounded, + color: Colors.white70, size: 18), + label: const Text( + 'Çıkış', + style: TextStyle(color: Colors.white70), + ), + ), + ), + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 68, + height: 68, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1.5, + ), + ), + child: const Icon( + Icons.domain_add_rounded, + size: 32, + color: Colors.white, + ), + ), + const SizedBox(height: 14), + const Text( + 'Kurumunuzu Oluşturun', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w700, + letterSpacing: 0.3, + ), + ), + const SizedBox(height: 4), + Text( + 'Klinik veya laboratuvar olarak kayıt olun', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.70), + fontSize: 13, + ), + ), + ], + ), + ), + ], + ), + ), + ), + + // Form card + Expanded( + child: FadeTransition( + opacity: _fadeAnim, + child: SlideTransition( + position: _slideAnim, + child: Container( + decoration: BoxDecoration( + color: cs.surface, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(32), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 24, + offset: const Offset(0, -4), + ), + ], + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(28, 32, 28, 24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Kurum Türünü Seçin', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 14), + + // Kind cards + Row( + children: [ + Expanded( + child: _KindCard( + icon: Icons.local_hospital_outlined, + label: 'Klinik', + description: 'Diş kliniği', + value: 'clinic', + selected: _selectedKind == 'clinic', + onTap: () => setState( + () => _selectedKind = 'clinic'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _KindCard( + icon: Icons.science_outlined, + label: 'Laboratuvar', + description: 'Diş laboratuvarı', + value: 'lab', + selected: _selectedKind == 'lab', + onTap: () => + setState(() => _selectedKind = 'lab'), + ), + ), + ], + ), + + const SizedBox(height: 24), + + // Company name + Text( + 'Kurum Adı', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 10), + TextFormField( + controller: _nameCtrl, + textInputAction: TextInputAction.done, + textCapitalization: TextCapitalization.words, + onFieldSubmitted: (_) => _create(), + decoration: InputDecoration( + labelText: _selectedKind == 'clinic' + ? 'Klinik Adı' + : 'Laboratuvar Adı', + prefixIcon: const Icon( + Icons.business_outlined, + size: 20), + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide( + color: Color(0xFF4F46E5), width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide( + color: cs.error, width: 1.5), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: + BorderSide(color: cs.error, width: 2), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 16), + ), + validator: (v) { + if (v == null || v.trim().isEmpty) { + return 'Kurum adı gereklidir'; + } + if (v.trim().length < 3) { + return 'En az 3 karakter olmalıdır'; + } + return null; + }, + ), + + // Error banner + if (_error != null) ...[ + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.error_outline_rounded, + color: cs.onErrorContainer, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + _error!, + style: TextStyle( + color: cs.onErrorContainer, + fontSize: 13), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 28), + + FilledButton( + onPressed: _loading ? null : _create, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + backgroundColor: const Color(0xFF4F46E5), + ), + child: _loading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white, + ), + ) + : const Text( + 'Devam Et', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _KindCard extends StatelessWidget { + const _KindCard({ + required this.icon, + required this.label, + required this.description, + required this.value, + required this.selected, + required this.onTap, + }); + + final IconData icon; + final String label; + final String description; + final String value; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: selected ? const Color(0xFF4F46E5) : cs.outlineVariant, + width: selected ? 2 : 1, + ), + color: selected + ? const Color(0xFF4F46E5).withValues(alpha: 0.08) + : cs.surfaceContainerLow, + ), + child: Column( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: selected + ? const Color(0xFF4F46E5).withValues(alpha: 0.12) + : cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + size: 26, + color: selected + ? const Color(0xFF4F46E5) + : cs.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + Text( + label, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: selected ? const Color(0xFF4F46E5) : cs.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + description, + style: TextStyle( + fontSize: 11, + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/auth/sign_in_screen.dart b/lib/features/auth/sign_in_screen.dart new file mode 100644 index 0000000..ef9d038 --- /dev/null +++ b/lib/features/auth/sign_in_screen.dart @@ -0,0 +1,888 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/l10n/app_strings.dart'; +import '../../core/providers/auth_provider.dart'; +import '../../core/providers/locale_provider.dart'; +import '../../core/router/app_router.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/tooth_logo.dart'; + +class SignInScreen extends ConsumerStatefulWidget { + const SignInScreen({super.key}); + + @override + ConsumerState createState() => _SignInScreenState(); +} + +class _SignInScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _emailCtrl = TextEditingController(); + final _passCtrl = TextEditingController(); + bool _obscure = true; + + @override + void dispose() { + _emailCtrl.dispose(); + _passCtrl.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + await ref + .read(authProvider.notifier) + .signIn(_emailCtrl.text.trim(), _passCtrl.text); + } + + @override + Widget build(BuildContext context) { + final auth = ref.watch(authProvider); + final s = ref.watch(stringsProvider); + final locale = ref.watch(localeProvider); + final isDesktop = MediaQuery.sizeOf(context).width > 800; + + return Scaffold( + backgroundColor: AppColors.background, + body: isDesktop + ? _buildDesktop(context, auth, s, locale) + : _buildMobile(context, auth, s, locale), + ); + } + + // ── Mobile ───────────────────────────────────────────────────────────────── + + Widget _buildMobile( + BuildContext context, dynamic auth, AppStrings s, Locale locale) { + return Stack( + children: [ + SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 56), + + // Logo mark + Center( + child: Container( + width: 68, + height: 68, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF0B1D35), Color(0xFF1A5C8A)], + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: const Color(0xFF0B1D35).withValues(alpha: 0.3), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: + const Center(child: ToothLogo(size: 34, color: Colors.white)), + ), + ).animate().fadeIn(duration: 400.ms).scale(begin: const Offset(0.8, 0.8)), + const SizedBox(height: 24), + + Center( + child: Text( + s.signInWelcome, + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + letterSpacing: -0.5, + ), + ), + ).animate(delay: 60.ms).fadeIn(duration: 400.ms).slideY(begin: 0.1), + const SizedBox(height: 6), + Center( + child: Text( + s.signInSubtitle, + style: const TextStyle( + fontSize: 14, color: AppColors.textSecondary), + ), + ).animate(delay: 100.ms).fadeIn(duration: 400.ms), + const SizedBox(height: 36), + + _buildFormFields(auth, s), + + const SizedBox(height: 24), + _buildSignUpLink(context, s), + const SizedBox(height: 32), + ], + ), + ), + ), + Positioned( + top: MediaQuery.paddingOf(context).top + 12, + right: 12, + child: _LanguageButton(locale: locale, s: s, ref: ref), + ), + ], + ); + } + + // ── Desktop ──────────────────────────────────────────────────────────────── + + Widget _buildDesktop( + BuildContext context, dynamic auth, AppStrings s, Locale locale) { + return Row( + children: [ + // LEFT PANEL + Expanded( + flex: 55, + child: Stack( + fit: StackFit.expand, + children: [ + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + stops: [0.0, 0.55, 1.0], + colors: [ + Color(0xFF080F1E), + Color(0xFF0D2D58), + Color(0xFF0E4A82), + ], + ), + ), + ), + const Positioned(top: -140, left: -140, child: _Ring(size: 520, opacity: 0.06)), + const Positioned(bottom: -100, right: -100, child: _Ring(size: 400, opacity: 0.05)), + const Positioned(top: 160, right: 60, child: _Ring(size: 100, opacity: 0.09)), + const Positioned(bottom: 220, left: 60, child: _Ring(size: 70, opacity: 0.07)), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 64, vertical: 52), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Colors.white.withValues(alpha: 0.2), + ), + ), + child: const Center( + child: ToothLogo(size: 20, color: Colors.white)), + ), + const SizedBox(width: 12), + const Text( + 'DLS', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w800, + letterSpacing: 1.5, + ), + ), + ], + ).animate().fadeIn(duration: 500.ms), + const Spacer(), + Text( + s.signInHeadline, + style: const TextStyle( + color: Colors.white, + fontSize: 46, + fontWeight: FontWeight.w800, + height: 1.1, + letterSpacing: -1.0, + ), + ) + .animate(delay: 100.ms) + .fadeIn(duration: 500.ms) + .slideY(begin: 0.1, end: 0), + const SizedBox(height: 18), + Text( + s.signInTagline, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.6), + fontSize: 16, + height: 1.6, + ), + ).animate(delay: 160.ms).fadeIn(duration: 500.ms), + const SizedBox(height: 44), + const _DashboardPreviewCard() + .animate(delay: 220.ms) + .fadeIn(duration: 600.ms) + .slideY(begin: 0.12, end: 0), + const Spacer(), + Text( + s.footerCopyright, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.3), + fontSize: 12, + ), + ).animate(delay: 300.ms).fadeIn(duration: 500.ms), + ], + ), + ), + ], + ), + ), + + // RIGHT PANEL + Stack( + children: [ + Container( + width: 460, + color: Colors.white, + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: + BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 52, vertical: 40), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFF0B1D35), + Color(0xFF1A5C8A) + ], + ), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: ToothLogo( + size: 24, color: Colors.white)), + ).animate().fadeIn(duration: 400.ms), + const SizedBox(height: 32), + Text( + s.signInWelcome, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + letterSpacing: -0.5, + ), + ) + .animate(delay: 60.ms) + .fadeIn(duration: 400.ms) + .slideY(begin: 0.08, end: 0), + const SizedBox(height: 6), + Text( + s.signInSubtitle, + style: const TextStyle( + fontSize: 15, + color: AppColors.textSecondary, + ), + ).animate(delay: 100.ms).fadeIn(duration: 400.ms), + const SizedBox(height: 40), + _buildFormFields(auth, s) + .animate(delay: 140.ms) + .fadeIn(duration: 400.ms) + .slideY(begin: 0.08, end: 0), + const SizedBox(height: 28), + _buildSignUpLink(context, s) + .animate(delay: 200.ms) + .fadeIn(duration: 400.ms), + ], + ), + ), + ), + ), + ), + ), + ), + ), + Positioned( + top: MediaQuery.paddingOf(context).top + 16, + right: 16, + child: _LanguageButton(locale: locale, s: s, ref: ref), + ), + ], + ), + ], + ); + } + + // ── Form fields (shared) ──────────────────────────────────────────────────── + + Widget _buildFormFields(dynamic auth, AppStrings s) { + return Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Field( + controller: _emailCtrl, + label: s.emailAddress, + icon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + validator: (v) => + (v == null || v.trim().isEmpty) ? s.emailRequired : null, + ), + const SizedBox(height: 14), + + _Field( + controller: _passCtrl, + label: s.password, + icon: Icons.lock_outline_rounded, + obscureText: _obscure, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + suffixIcon: IconButton( + icon: Icon( + _obscure + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 20, + color: AppColors.textSecondary, + ), + onPressed: () => setState(() => _obscure = !_obscure), + ), + validator: (v) => + (v == null || v.isEmpty) ? s.passwordRequired : null, + ), + + if (auth.error != null) ...[ + const SizedBox(height: 14), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppColors.cancelled.withValues(alpha: 0.25)), + ), + child: Row( + children: [ + const Icon(Icons.error_outline_rounded, + color: AppColors.cancelled, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + auth.error!, + style: const TextStyle( + color: AppColors.cancelled, fontSize: 13), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 24), + + DecoratedBox( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0B1D35), Color(0xFF1A5C8A)], + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: const Color(0xFF0B1D35).withValues(alpha: 0.35), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: FilledButton( + onPressed: auth.isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + disabledForegroundColor: Colors.white.withValues(alpha: 0.5), + disabledBackgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + child: auth.isLoading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, color: Colors.white), + ) + : Text( + s.signIn, + style: const TextStyle( + fontSize: 15, fontWeight: FontWeight.w600), + ), + ), + ), + ], + ), + ); + } + + // ── Sign-up link ─────────────────────────────────────────────────────────── + + Widget _buildSignUpLink(BuildContext context, AppStrings s) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + s.noAccount, + style: + const TextStyle(color: AppColors.textSecondary, fontSize: 14), + ), + TextButton( + onPressed: () => context.go(routeSignUp), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF0D4C85), + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + child: Text( + s.signUp, + style: + const TextStyle(fontWeight: FontWeight.w700, fontSize: 14), + ), + ), + ], + ); + } +} + +// ── Language button ─────────────────────────────────────────────────────────── + +class _LanguageButton extends StatelessWidget { + const _LanguageButton( + {required this.locale, required this.s, required this.ref}); + final Locale locale; + final AppStrings s; + final WidgetRef ref; + + static const _flags = { + 'tr': '🇹🇷', + 'en': '🇬🇧', + 'ru': '🇷🇺', + 'ar': '🇸🇦', + 'de': '🇩🇪', + }; + + @override + Widget build(BuildContext context) { + final flag = _flags[locale.languageCode] ?? '🌐'; + return GestureDetector( + onTap: () => _showPicker(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(flag, style: const TextStyle(fontSize: 15)), + const SizedBox(width: 4), + const Icon(Icons.expand_more_rounded, + size: 14, color: AppColors.textSecondary), + ], + ), + ), + ); + } + + void _showPicker(BuildContext context) { + final options = [ + ('tr', '🇹🇷', s.languageTurkish), + ('en', '🇬🇧', s.languageEnglish), + ('ru', '🇷🇺', s.languageRussian), + ('ar', '🇸🇦', s.languageArabic), + ('de', '🇩🇪', s.languageGerman), + ]; + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => Container( + decoration: const BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Text( + s.languageSelection, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 12), + for (final (code, flag, label) in options) + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + leading: Text(flag, style: const TextStyle(fontSize: 24)), + title: Text( + label, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + trailing: locale.languageCode == code + ? const Icon(Icons.check_circle_rounded, + color: AppColors.accent) + : null, + onTap: () { + ref.read(localeProvider.notifier).setLocale(Locale(code)); + Navigator.pop(context); + }, + ), + SizedBox(height: MediaQuery.paddingOf(context).bottom + 4), + ], + ), + ), + ); + } +} + +// ── Decorative ring ─────────────────────────────────────────────────────────── + +class _Ring extends StatelessWidget { + const _Ring({required this.size, required this.opacity}); + final double size; + final double opacity; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withValues(alpha: opacity), + width: 1.5, + ), + ), + ); + } +} + +// ── Dashboard preview card (glassmorphism) ──────────────────────────────────── + +class _DashboardPreviewCard extends StatelessWidget { + const _DashboardPreviewCard(); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + width: 340, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Colors.white.withValues(alpha: 0.12), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.bar_chart_rounded, + color: Colors.white, + size: 15, + ), + ), + const SizedBox(width: 10), + Text( + 'Bugünkü Durum', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + const Spacer(), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + 'Canlı', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: 18), + const Row( + children: [ + _StatChip(value: '24', label: 'Aktif', color: Color(0xFF60A5FA)), + SizedBox(width: 8), + _StatChip( + value: '8', label: 'Bekliyor', color: Color(0xFFFBBF24)), + SizedBox(width: 8), + _StatChip( + value: '142', label: 'Bu ay', color: Color(0xFF34D399)), + ], + ), + const SizedBox(height: 18), + const _PreviewBar( + label: 'Zirkon', value: 0.76, color: Color(0xFF60A5FA)), + const SizedBox(height: 10), + const _PreviewBar( + label: 'Metal alt.', value: 0.48, color: Color(0xFFFBBF24)), + const SizedBox(height: 10), + const _PreviewBar( + label: 'Porselen', value: 0.62, color: Color(0xFF34D399)), + ], + ), + ), + ), + ); + } +} + +class _StatChip extends StatelessWidget { + const _StatChip({ + required this.value, + required this.label, + required this.color, + }); + final String value; + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.2)), + ), + child: Column( + children: [ + Text( + value, + style: TextStyle( + color: color, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.55), + fontSize: 11, + ), + ), + ], + ), + ), + ); + } +} + +class _PreviewBar extends StatelessWidget { + const _PreviewBar({ + required this.label, + required this.value, + required this.color, + }); + final String label; + final double value; + final Color color; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.65), + fontSize: 12, + ), + ), + Text( + '${(value * 100).toInt()}%', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.65), + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 5), + LayoutBuilder( + builder: (_, constraints) => Stack( + children: [ + Container( + height: 5, + width: constraints.maxWidth, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + ), + Container( + height: 5, + width: constraints.maxWidth * value, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(10), + ), + ), + ], + ), + ), + ], + ); + } +} + +// ── Form field ──────────────────────────────────────────────────────────────── + +class _Field extends StatelessWidget { + const _Field({ + required this.controller, + required this.label, + required this.icon, + this.keyboardType, + this.textInputAction, + this.obscureText = false, + this.suffixIcon, + this.onFieldSubmitted, + this.validator, + }); + + final TextEditingController controller; + final String label; + final IconData icon; + final TextInputType? keyboardType; + final TextInputAction? textInputAction; + final bool obscureText; + final Widget? suffixIcon; + final ValueChanged? onFieldSubmitted; + final FormFieldValidator? validator; + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + keyboardType: keyboardType, + textInputAction: textInputAction, + obscureText: obscureText, + onFieldSubmitted: onFieldSubmitted, + validator: validator, + style: const TextStyle(fontSize: 15, color: AppColors.textPrimary), + decoration: InputDecoration( + labelText: label, + prefixIcon: Icon(icon, size: 20, color: AppColors.textSecondary), + suffixIcon: suffixIcon, + filled: true, + fillColor: const Color(0xFFF8FAFC), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF0D4C85), width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: + const BorderSide(color: AppColors.cancelled, width: 1.5), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.cancelled, width: 2), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + labelStyle: const TextStyle( + color: AppColors.textSecondary, fontSize: 14), + ), + ); + } +} diff --git a/lib/features/auth/sign_up_screen.dart b/lib/features/auth/sign_up_screen.dart new file mode 100644 index 0000000..fb1914e --- /dev/null +++ b/lib/features/auth/sign_up_screen.dart @@ -0,0 +1,619 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/providers/auth_provider.dart'; +import '../../core/router/app_router.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/tooth_logo.dart'; +import 'auth_widgets.dart'; + +class SignUpScreen extends ConsumerStatefulWidget { + const SignUpScreen({super.key}); + + @override + ConsumerState createState() => _SignUpScreenState(); +} + +class _SignUpScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _firstNameCtrl = TextEditingController(); + final _lastNameCtrl = TextEditingController(); + final _emailCtrl = TextEditingController(); + final _passCtrl = TextEditingController(); + final _confirmPassCtrl = TextEditingController(); + bool _obscure = true; + bool _obscureConfirm = true; + bool _loading = false; + String? _error; + + @override + void dispose() { + _firstNameCtrl.dispose(); + _lastNameCtrl.dispose(); + _emailCtrl.dispose(); + _passCtrl.dispose(); + _confirmPassCtrl.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + setState(() { + _loading = true; + _error = null; + }); + try { + await ref.read(authProvider.notifier).register( + email: _emailCtrl.text.trim(), + password: _passCtrl.text, + firstName: _firstNameCtrl.text.trim(), + lastName: _lastNameCtrl.text.trim(), + ); + } catch (e) { + setState(() { + _error = _parseError(e.toString()); + _loading = false; + }); + } + } + + String _parseError(String msg) { + if (msg.contains('already') || msg.contains('unique') || msg.contains('UNIQUE')) { + return 'Bu e-posta adresi zaten kayıtlı.'; + } + if (msg.contains('403') || msg.contains('Forbidden')) { + return 'Kayıt şu anda kapalı. Lütfen yönetici ile iletişime geçin.'; + } + return 'Kayıt olunamadı. Lütfen tekrar deneyin.'; + } + + @override + Widget build(BuildContext context) { + final isDesktop = MediaQuery.sizeOf(context).width > 800; + + return Scaffold( + backgroundColor: AppColors.background, + body: isDesktop ? _buildDesktop(context) : _buildMobile(context), + ); + } + + // ── Mobile layout ────────────────────────────────────────────────────────── + + Widget _buildMobile(BuildContext context) { + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: _buildForm(context, isMobile: true), + ), + ); + } + + // ── Desktop layout ───────────────────────────────────────────────────────── + + Widget _buildDesktop(BuildContext context) { + return Row( + children: [ + // LEFT PANEL — solid gradient + white animated blobs on top + Expanded( + flex: 5, + child: Stack( + fit: StackFit.expand, + children: [ + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.primary, Color(0xFF1A5C8A)], + ), + ), + ), + const AnimatedAuthBg(bright: true), + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 56), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1.5, + ), + ), + child: const Center(child: ToothLogo(size: 38, color: Colors.white)), + ), + const SizedBox(height: 24), + const Text( + 'DLS', + style: TextStyle( + fontSize: 48, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: 2, + ), + ), + const SizedBox(height: 4), + Text( + 'Dental Lab Sistemi', + style: TextStyle( + fontSize: 17, + color: Colors.white.withValues(alpha: 0.7), + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 48), + const _FeatureBullet( + icon: Icons.dashboard_rounded, + text: 'İş takibi tek ekranda', + ), + const SizedBox(height: 16), + const _FeatureBullet( + icon: Icons.link_rounded, + text: 'Klinik-lab bağlantısı', + ), + const SizedBox(height: 16), + const _FeatureBullet( + icon: Icons.bolt_rounded, + text: 'Gerçek zamanlı durum', + ), + ], + ), + ), + ), + ], + ), + ), + + // RIGHT PANEL — light gray so white card stands out + Container( + width: 480, + color: AppColors.background, + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [_buildForm(context, isMobile: false)], + ), + ), + ), + ), + ), + ), + ), + ), + ], + ); + } + + // ── Shared form content ──────────────────────────────────────────────────── + + Widget _buildForm(BuildContext context, {required bool isMobile}) { + return Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (isMobile) const SizedBox(height: 48), + + // ── Back button + branding (mobile only) ─────────────────────── + if (isMobile) ...[ + Row( + children: [ + IconButton( + onPressed: () => context.go(routeSignIn), + icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 20), + style: IconButton.styleFrom( + foregroundColor: AppColors.textPrimary, + backgroundColor: AppColors.surface, + padding: const EdgeInsets.all(10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: AppColors.border), + ), + ), + ), + ], + ).animate().fadeIn(duration: 300.ms), + + const SizedBox(height: 28), + + Center( + child: Column( + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.primary, AppColors.accent], + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: AppColors.accent.withValues(alpha: 0.3), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], + ), + child: const Icon( + Icons.person_add_alt_1_rounded, + size: 32, + color: Colors.white, + ), + ), + const SizedBox(height: 16), + const Text( + 'Hesap Oluştur', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + const Text( + 'DLS ağına katılın', + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ).animate().fadeIn(duration: 400.ms).slideY(begin: -0.08, end: 0), + + const SizedBox(height: 32), + ], + + // Desktop back button (outside card) + if (!isMobile) ...[ + Row( + children: [ + IconButton( + onPressed: () => context.go(routeSignIn), + icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 18), + style: IconButton.styleFrom( + foregroundColor: AppColors.textPrimary, + backgroundColor: AppColors.surface, + padding: const EdgeInsets.all(8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: const BorderSide(color: AppColors.border), + ), + ), + ), + ], + ).animate().fadeIn(duration: 300.ms), + const SizedBox(height: 20), + ], + + // ── Form card ────────────────────────────────────────────────── + Container( + padding: EdgeInsets.all(isMobile ? 24 : 32), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isMobile ? 0.05 : 0.09), + blurRadius: isMobile ? 16 : 28, + spreadRadius: 0, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Heading inside card on desktop + if (!isMobile) ...[ + const Text( + 'Hesap Oluştur', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ).animate().fadeIn(duration: 400.ms).slideY(begin: -0.08, end: 0), + const SizedBox(height: 4), + const Text( + 'DLS ağına katılın', + style: TextStyle(fontSize: 14, color: AppColors.textSecondary), + ).animate(delay: 40.ms).fadeIn(duration: 400.ms), + const SizedBox(height: 24), + ], + // Ad / Soyad satırı + Row( + children: [ + Expanded( + child: _Field( + controller: _firstNameCtrl, + label: 'Ad', + icon: Icons.badge_outlined, + textCapitalization: TextCapitalization.words, + textInputAction: TextInputAction.next, + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Gerekli' : null, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _Field( + controller: _lastNameCtrl, + label: 'Soyad', + icon: Icons.badge_outlined, + textCapitalization: TextCapitalization.words, + textInputAction: TextInputAction.next, + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Gerekli' : null, + ), + ), + ], + ), + const SizedBox(height: 12), + + _Field( + controller: _emailCtrl, + label: 'E-posta', + icon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + validator: (v) { + if (v == null || v.trim().isEmpty) return 'E-posta gereklidir'; + if (!v.contains('@')) return 'Geçerli bir e-posta girin'; + return null; + }, + ), + const SizedBox(height: 12), + + _Field( + controller: _passCtrl, + label: 'Şifre', + icon: Icons.lock_outline_rounded, + obscureText: _obscure, + textInputAction: TextInputAction.next, + suffixIcon: IconButton( + icon: Icon( + _obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined, + size: 20, + color: AppColors.textSecondary, + ), + onPressed: () => setState(() => _obscure = !_obscure), + ), + validator: (v) { + if (v == null || v.isEmpty) return 'Şifre gereklidir'; + if (v.length < 8) return 'En az 8 karakter olmalıdır'; + return null; + }, + ), + const SizedBox(height: 12), + + _Field( + controller: _confirmPassCtrl, + label: 'Şifre Tekrar', + icon: Icons.lock_outline_rounded, + obscureText: _obscureConfirm, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + suffixIcon: IconButton( + icon: Icon( + _obscureConfirm + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 20, + color: AppColors.textSecondary, + ), + onPressed: () => setState(() => _obscureConfirm = !_obscureConfirm), + ), + validator: (v) => + (v != _passCtrl.text) ? 'Şifreler eşleşmiyor' : null, + ), + + if (_error != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppColors.cancelled.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.error_outline_rounded, + color: AppColors.cancelled, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + _error!, + style: const TextStyle( + color: AppColors.cancelled, fontSize: 13), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 20), + + FilledButton( + onPressed: _loading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + child: _loading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, color: Colors.white), + ) + : const Text( + 'Kayıt Ol', + style: + TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ).animate(delay: 100.ms).fadeIn(duration: 400.ms).slideY(begin: 0.1, end: 0), + + const SizedBox(height: 20), + + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Zaten hesabın var mı?', + style: TextStyle(color: AppColors.textSecondary, fontSize: 14), + ), + TextButton( + onPressed: () => context.go(routeSignIn), + style: TextButton.styleFrom( + foregroundColor: AppColors.accent, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + child: const Text( + 'Giriş Yap', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14), + ), + ), + ], + ).animate(delay: 200.ms).fadeIn(duration: 400.ms), + + SizedBox(height: isMobile ? 32 : 16), + ], + ), + ); + } +} + +// ── Feature bullet (desktop left panel) ────────────────────────────────────── + +class _FeatureBullet extends StatelessWidget { + const _FeatureBullet({required this.icon, required this.text}); + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 18, color: Colors.white), + ), + const SizedBox(width: 14), + Text( + text, + style: TextStyle( + fontSize: 15, + color: Colors.white.withValues(alpha: 0.9), + fontWeight: FontWeight.w500, + ), + ), + ], + ); + } +} + +// ── Form field ──────────────────────────────────────────────────────────────── + +class _Field extends StatelessWidget { + const _Field({ + required this.controller, + required this.label, + required this.icon, + this.keyboardType, + this.textCapitalization = TextCapitalization.none, + this.textInputAction, + this.obscureText = false, + this.suffixIcon, + this.onFieldSubmitted, + this.validator, + }); + + final TextEditingController controller; + final String label; + final IconData icon; + final TextInputType? keyboardType; + final TextCapitalization textCapitalization; + final TextInputAction? textInputAction; + final bool obscureText; + final Widget? suffixIcon; + final ValueChanged? onFieldSubmitted; + final FormFieldValidator? validator; + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + keyboardType: keyboardType, + textCapitalization: textCapitalization, + textInputAction: textInputAction, + obscureText: obscureText, + onFieldSubmitted: onFieldSubmitted, + validator: validator, + style: const TextStyle(fontSize: 15, color: AppColors.textPrimary), + decoration: InputDecoration( + labelText: label, + prefixIcon: Icon(icon, size: 20, color: AppColors.textSecondary), + suffixIcon: suffixIcon, + filled: true, + fillColor: AppColors.background, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.accent, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.cancelled, width: 1.5), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.cancelled, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + labelStyle: const TextStyle(color: AppColors.textSecondary, fontSize: 14), + ), + ); + } +} diff --git a/lib/features/clinic/connections/clinic_connections_repository.dart b/lib/features/clinic/connections/clinic_connections_repository.dart new file mode 100644 index 0000000..3dbd7e4 --- /dev/null +++ b/lib/features/clinic/connections/clinic_connections_repository.dart @@ -0,0 +1,40 @@ +import 'package:pocketbase/pocketbase.dart'; +import '../../../core/api/pocketbase_client.dart'; +import '../../../models/connection.dart'; + +class ClinicConnectionsRepository { + ClinicConnectionsRepository._(); + static final instance = ClinicConnectionsRepository._(); + + PocketBase get _pb => PocketBaseClient.instance.pb; + + Future> listConnections(String clinicTenantId) async { + final result = await _pb.collection('connections').getList( + filter: 'clinic_tenant_id = "$clinicTenantId"', + expand: 'lab_tenant_id,clinic_tenant_id', + perPage: 100, + ); + return (result.items.map((r) => Connection.fromJson(r.toJson())).toList() + ..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? ''))); + } + + Future requestConnection({ + required String clinicTenantId, + required String labTenantId, + }) async { + final record = await _pb.collection('connections').create(body: { + 'clinic_tenant_id': clinicTenantId, + 'lab_tenant_id': labTenantId, + 'status': 'pending', + }); + return Connection.fromJson(record.toJson()); + } + + Future>> searchLabs(String query) async { + final result = await _pb.collection('tenants').getList( + filter: 'kind = "lab" && company_name ~ "$query"', + perPage: 20, + ); + return result.items.map((r) => r.toJson()).toList(); + } +} diff --git a/lib/features/clinic/connections/clinic_connections_screen.dart b/lib/features/clinic/connections/clinic_connections_screen.dart new file mode 100644 index 0000000..f89acfa --- /dev/null +++ b/lib/features/clinic/connections/clinic_connections_screen.dart @@ -0,0 +1,441 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/providers/auth_provider.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../models/connection.dart'; +import 'clinic_connections_repository.dart'; + +class ClinicConnectionsScreen extends ConsumerStatefulWidget { + const ClinicConnectionsScreen({super.key}); + + @override + ConsumerState createState() => + _ClinicConnectionsScreenState(); +} + +class _ClinicConnectionsScreenState + extends ConsumerState { + late Future> _future; + + @override + void initState() { + super.initState(); + _load(); + } + + void _load() { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + setState(() { + _future = ClinicConnectionsRepository.instance + .listConnections(tenantId); + }); + } + + void _showSearchDialog() { + showDialog( + context: context, + builder: (ctx) => _LabSearchDialog( + onRequested: (labId, labName) async { + Navigator.of(ctx).pop(); + final tenantId = + ref.read(authProvider).activeTenant!.tenant.id; + try { + await ClinicConnectionsRepository.instance.requestConnection( + clinicTenantId: tenantId, + labTenantId: labId, + ); + _load(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '$labName\'a bağlantı talebi gönderildi.')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Bağlantılar'), + actions: [ + IconButton( + icon: const Icon(Icons.add_link), + tooltip: 'Laboratuvar Bul', + onPressed: _showSearchDialog, + ), + ], + ), + body: RefreshIndicator( + color: AppColors.accent, + onRefresh: () async => _load(), + child: FutureBuilder>( + future: _future, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator(color: AppColors.accent)); + } + if (snap.hasError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(16)), + child: const Icon(Icons.wifi_off_rounded, + color: AppColors.cancelled, size: 30), + ), + const SizedBox(height: 16), + Text('Hata: ${snap.error}', + style: + const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Tekrar Dene'), + ), + ], + ), + ); + } + final connections = snap.data!; + if (connections.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(20)), + child: const Icon(Icons.link_off, + color: AppColors.inProgress, size: 32), + ), + const SizedBox(height: 16), + const Text( + 'Henüz bağlantı yok', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary), + ), + const SizedBox(height: 8), + FilledButton.icon( + onPressed: _showSearchDialog, + icon: const Icon(Icons.search), + label: const Text('Laboratuvar Bul'), + ), + ], + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + itemCount: connections.length, + itemBuilder: (context, index) { + final conn = connections[index]; + final statusColor = _statusColor(conn.status); + final statusBg = _statusBg(conn.status); + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2)) + ]), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: statusBg, + borderRadius: BorderRadius.circular(12)), + child: Icon(Icons.science_outlined, + color: statusColor, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + conn.labName ?? 'Laboratuvar', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary), + ), + if (conn.dateCreated != null) ...[ + const SizedBox(height: 2), + Text( + _formatDate(conn.dateCreated!), + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary), + ), + ], + ], + ), + ), + _StatusChip(status: conn.status), + ], + ), + ), + ); + }, + ); + }, + ), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _showSearchDialog, + backgroundColor: AppColors.accent, + foregroundColor: Colors.white, + icon: const Icon(Icons.search), + label: const Text('Laboratuvar Bul'), + ), + ); + } + + Color _statusColor(ConnectionStatus s) { + switch (s) { + case ConnectionStatus.pending: + return AppColors.pending; + case ConnectionStatus.approved: + return AppColors.success; + case ConnectionStatus.rejected: + return AppColors.cancelled; + } + } + + Color _statusBg(ConnectionStatus s) { + switch (s) { + case ConnectionStatus.pending: + return AppColors.pendingBg; + case ConnectionStatus.approved: + return AppColors.successBg; + case ConnectionStatus.rejected: + return AppColors.cancelledBg; + } + } + + String _formatDate(String dateStr) { + try { + final d = DateTime.parse(dateStr); + return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}'; + } catch (_) { + return dateStr; + } + } +} + +class _StatusChip extends StatelessWidget { + const _StatusChip({required this.status}); + final ConnectionStatus status; + + @override + Widget build(BuildContext context) { + final color = _color(status); + final bg = _bg(status); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + status.label, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Color _color(ConnectionStatus s) { + switch (s) { + case ConnectionStatus.pending: + return AppColors.pending; + case ConnectionStatus.approved: + return AppColors.success; + case ConnectionStatus.rejected: + return AppColors.cancelled; + } + } + + Color _bg(ConnectionStatus s) { + switch (s) { + case ConnectionStatus.pending: + return AppColors.pendingBg; + case ConnectionStatus.approved: + return AppColors.successBg; + case ConnectionStatus.rejected: + return AppColors.cancelledBg; + } + } +} + +class _LabSearchDialog extends StatefulWidget { + const _LabSearchDialog({required this.onRequested}); + final void Function(String labId, String labName) onRequested; + + @override + State<_LabSearchDialog> createState() => _LabSearchDialogState(); +} + +class _LabSearchDialogState extends State<_LabSearchDialog> { + final _searchController = TextEditingController(); + List> _results = []; + bool _isLoading = false; + bool _searched = false; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _search() async { + final query = _searchController.text.trim(); + if (query.isEmpty) return; + + setState(() { + _isLoading = true; + _searched = true; + }); + try { + final results = + await ClinicConnectionsRepository.instance.searchLabs(query); + setState(() { + _results = results; + _isLoading = false; + }); + } catch (e) { + setState(() => _isLoading = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Laboratuvar Bul'), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + decoration: const InputDecoration( + hintText: 'Lab adı ile arayın...', + contentPadding: EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + ), + onSubmitted: (_) => _search(), + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _search, + child: const Text('Ara'), + ), + ], + ), + const SizedBox(height: 12), + if (_isLoading) + const Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator(color: AppColors.accent), + ) + else if (_searched && _results.isEmpty) + const Padding( + padding: EdgeInsets.all(16), + child: Text('Sonuç bulunamadı', + style: TextStyle(color: AppColors.textSecondary)), + ) + else if (_results.isNotEmpty) + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240), + child: ListView.builder( + shrinkWrap: true, + itemCount: _results.length, + itemBuilder: (context, index) { + final lab = _results[index]; + final name = + lab['company_name'] as String? ?? 'Lab'; + return ListTile( + dense: true, + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(8)), + child: const Icon(Icons.science_outlined, + color: AppColors.inProgress, size: 18), + ), + title: Text(name, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary)), + subtitle: lab['member_number'] != null + ? Text('No: ${lab['member_number']}', + style: const TextStyle( + color: AppColors.textSecondary)) + : null, + onTap: () => + widget.onRequested(lab['id'] as String, name), + ); + }, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('İptal'), + ), + ], + ); + } +} diff --git a/lib/features/clinic/dashboard/clinic_dashboard_screen.dart b/lib/features/clinic/dashboard/clinic_dashboard_screen.dart new file mode 100644 index 0000000..0562ab8 --- /dev/null +++ b/lib/features/clinic/dashboard/clinic_dashboard_screen.dart @@ -0,0 +1,1230 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/providers/auth_provider.dart'; +import '../../../core/router/app_router.dart'; +import '../../../core/services/realtime_service.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/tooth_logo.dart'; +import '../../../models/job.dart'; +import '../jobs/clinic_jobs_repository.dart'; +import '../patients/clinic_patients_repository.dart'; + +class ClinicDashboardScreen extends ConsumerStatefulWidget { + const ClinicDashboardScreen({super.key}); + + @override + ConsumerState createState() => + _ClinicDashboardScreenState(); +} + +class _ClinicDashboardScreenState extends ConsumerState { + late Future<_DashboardData> _future; + late UnsubFn _unsub; + final Map _actingJobs = {}; + + @override + void initState() { + super.initState(); + _load(); + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + _unsub = RealtimeService.instance.watch( + 'jobs', + filter: "clinic_tenant_id='$tenantId'", + onEvent: (_) { if (mounted) _load(); }, + ); + } + + @override + void dispose() { + _unsub(); + super.dispose(); + } + + void _load() { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + setState(() { + _future = _loadAll(tenantId); + }); + } + + Future _approveAtClinic(Job job) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(job.patientCode), + content: Text('${job.prostheticType.label} işini onaylıyor musunuz?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('İptal')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: AppColors.success), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Onayla'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _actingJobs[job.id] = true); + try { + await ClinicJobsRepository.instance.approveAtClinic(job.id, job); + _load(); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e'))); + } finally { + if (mounted) setState(() => _actingJobs.remove(job.id)); + } + } + + Future _markDelivered(Job job) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(job.patientCode), + content: Text('${job.prostheticType.label} işi teslim alındı olarak işaretlensin mi?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('İptal')), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Teslim Aldım'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _actingJobs[job.id] = true); + try { + await ClinicJobsRepository.instance.markDelivered(job.id, job); + _load(); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e'))); + } finally { + if (mounted) setState(() => _actingJobs.remove(job.id)); + } + } + + Future<_DashboardData> _loadAll(String tenantId) async { + final now = DateTime.now(); + final thisMonthStart = DateTime(now.year, now.month, 1); + final lastMonthStart = DateTime(now.year, now.month - 1, 1); + + final results = await Future.wait([ + ClinicJobsRepository.instance.listOutbound(tenantId, statuses: ['pending'], limit: 200), + ClinicJobsRepository.instance.listOutbound(tenantId, statuses: ['in_progress'], limit: 200), + ClinicJobsRepository.instance.listOutbound(tenantId, statuses: ['sent'], limit: 200), + ClinicJobsRepository.instance.listOutbound(tenantId, limit: 5), + ClinicPatientsRepository.instance.listPatients(tenantId, limit: 200), + ]); + final thisMonth = await ClinicJobsRepository.instance.countDelivered(tenantId, from: thisMonthStart); + final lastMonth = await ClinicJobsRepository.instance.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart); + + final inProgressJobs = results[1] as List; + final sentJobs = results[2] as List; + final provaAtClinic = inProgressJobs.where((j) => j.location == JobLocation.atClinic).toList(); + final actionJobs = [...provaAtClinic, ...sentJobs]; + + return _DashboardData( + pendingCount: (results[0] as List).length, + inProgressCount: inProgressJobs.length, + sentCount: sentJobs.length, + patientCount: (results[4] as List).length, + recentJobs: results[3] as List, + thisMonthDelivered: thisMonth, + lastMonthDelivered: lastMonth, + actionJobs: actionJobs, + ); + } + + @override + Widget build(BuildContext context) { + final companyName = + ref.watch(authProvider).activeTenant?.tenant.companyName ?? ''; + + return Scaffold( + backgroundColor: AppColors.background, + body: LayoutBuilder( + builder: (context, constraints) { + const maxContent = 1040.0; + final hPad = constraints.maxWidth > maxContent + ? (constraints.maxWidth - maxContent) / 2 + : 16.0; + + return RefreshIndicator( + color: AppColors.accent, + onRefresh: () async => _load(), + child: FutureBuilder<_DashboardData>( + future: _future, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return _DashboardSkeleton(companyName: companyName, hPad: hPad); + } + if (snap.hasError) { + return _ErrorBody(onRetry: _load); + } + final data = snap.data!; + final isDesktop = MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint; + return CustomScrollView( + slivers: [ + _DashboardHeader(companyName: companyName), + if (isDesktop) + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + sliver: SliverToBoxAdapter( + child: _StatsRow( + pending: data.pendingCount, + inProgress: data.inProgressCount, + sent: data.sentCount, + patients: data.patientCount, + ), + ), + ), + if (isDesktop) ...[ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + sliver: SliverToBoxAdapter( + child: _MonthlyReportSection(data: data) + .animate().fadeIn(duration: 300.ms).slideY(begin: 0.08, end: 0), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + sliver: SliverToBoxAdapter( + child: _GamificationRow(data: data) + .animate().fadeIn(duration: 300.ms, delay: 60.ms).slideY(begin: 0.08, end: 0), + ), + ), + ], + SliverPadding( + padding: EdgeInsets.fromLTRB(hPad, 16, hPad, 0), + sliver: SliverToBoxAdapter( + child: FilledButton.icon( + onPressed: () => context.push(routeClinicJobNew), + icon: const Icon(Icons.add_rounded, size: 20), + label: const Text('Yeni İş Oluştur'), + style: FilledButton.styleFrom( + minimumSize: const Size(double.infinity, 52), + backgroundColor: AppColors.accent, + ), + ).animate().fadeIn(duration: 300.ms).slideY(begin: 0.1, end: 0), + ), + ), + if (data.actionJobs.isNotEmpty) + SliverPadding( + padding: EdgeInsets.fromLTRB(hPad, 20, hPad, 0), + sliver: SliverToBoxAdapter( + child: _ActionSection( + jobs: data.actionJobs, + actingJobs: _actingJobs, + onApprove: _approveAtClinic, + onDeliver: _markDelivered, + ).animate().fadeIn(duration: 300.ms).slideY(begin: 0.06, end: 0), + ), + ), + SliverPadding( + padding: EdgeInsets.fromLTRB(hPad, 20, hPad, 4), + sliver: SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Son İşler', + style: Theme.of(context).textTheme.titleMedium), + TextButton( + onPressed: () => context.go(routeClinicJobs), + style: TextButton.styleFrom( + foregroundColor: AppColors.accent, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + child: const Text('Tümünü Gör'), + ), + ], + ), + ), + ), + if (data.recentJobs.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, child: _EmptyJobs()) + else + SliverPadding( + padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 24), + sliver: SliverList.separated( + itemCount: data.recentJobs.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (ctx, i) => + _JobCard(job: data.recentJobs[i]) + .animate(delay: (i * 60).ms) + .fadeIn(duration: 300.ms) + .slideY(begin: 0.12, end: 0), + ), + ), + ], + ); + }, + ), + ); + }, + ), + ); + } +} + +class _DashboardData { + const _DashboardData({ + required this.pendingCount, + required this.inProgressCount, + required this.sentCount, + required this.patientCount, + required this.recentJobs, + required this.thisMonthDelivered, + required this.lastMonthDelivered, + required this.actionJobs, + }); + final int pendingCount; + final int inProgressCount; + final int sentCount; + final int patientCount; + final List recentJobs; + final int thisMonthDelivered; + final int lastMonthDelivered; + final List actionJobs; + + int get points => thisMonthDelivered * 10; + double get changePercent => lastMonthDelivered == 0 + ? (thisMonthDelivered > 0 ? 100 : 0) + : (thisMonthDelivered - lastMonthDelivered) / lastMonthDelivered * 100; +} + +// ── Action Section ─────────────────────────────────────────────────────────── + +class _ActionSection extends StatelessWidget { + const _ActionSection({ + required this.jobs, + required this.actingJobs, + required this.onApprove, + required this.onDeliver, + }); + + final List jobs; + final Map actingJobs; + final Future Function(Job) onApprove; + final Future Function(Job) onDeliver; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 26, height: 26, + decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(7)), + child: const Icon(Icons.priority_high_rounded, size: 15, color: Colors.white), + ), + const SizedBox(width: 8), + Text('Yapılacaklar', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(10)), + child: Text('${jobs.length}', style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: Colors.white)), + ), + ], + ), + const SizedBox(height: 12), + ...jobs.asMap().entries.map((entry) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _ActionJobCard( + job: entry.value, + acting: actingJobs[entry.value.id] == true, + onApprove: () => onApprove(entry.value), + onDeliver: () => onDeliver(entry.value), + ).animate(delay: (entry.key * 50).ms).fadeIn(duration: 250.ms).slideY(begin: 0.08, end: 0), + )), + ], + ); + } +} + +class _ActionJobCard extends StatelessWidget { + const _ActionJobCard({ + required this.job, + required this.acting, + required this.onApprove, + required this.onDeliver, + }); + + final Job job; + final bool acting; + final VoidCallback onApprove; + final VoidCallback onDeliver; + + bool get _isProva => job.status == JobStatus.inProgress && job.location == JobLocation.atClinic; + + @override + Widget build(BuildContext context) { + final isProva = _isProva; + final borderColor = isProva ? AppColors.pending : AppColors.accent; + final bgColor = isProva ? AppColors.pendingBg : AppColors.inProgressBg; + final iconColor = isProva ? AppColors.pending : AppColors.accent; + final icon = isProva ? Icons.rate_review_outlined : Icons.inventory_2_outlined; + final statusLabel = isProva ? 'Onay Bekliyor' : 'Teslimat Bekliyor'; + + return Semantics( + label: job.patientCode, + button: true, + excludeSemantics: true, + child: Material( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: () => context.push('/clinic/jobs/${job.id}'), + borderRadius: BorderRadius.circular(14), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: borderColor.withValues(alpha: 0.45), width: 1.5), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, 3))], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 10), + child: Row( + children: [ + Container( + width: 40, height: 40, + decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(11)), + child: Icon(icon, color: iconColor, size: 19), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(job.patientCode, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + const SizedBox(height: 2), + Text( + '${job.prostheticType.label} · ${job.labName ?? 'Lab'}', + style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), + maxLines: 1, overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(8)), + child: Text(statusLabel, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: iconColor)), + ), + ], + ), + ), + Container( + decoration: BoxDecoration( + color: bgColor.withValues(alpha: 0.45), + borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(13), bottomRight: Radius.circular(13)), + ), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 10), + child: acting + ? const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 4), + child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2.5, color: AppColors.accent)), + ), + ) + : isProva + ? Row(children: [ + Expanded( + child: FilledButton.icon( + onPressed: onApprove, + icon: const Icon(Icons.check_circle_outline, size: 15), + label: const Text('Onayla', style: TextStyle(fontSize: 13)), + style: FilledButton.styleFrom( + backgroundColor: AppColors.success, + minimumSize: const Size(0, 36), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => context.push('/clinic/jobs/${job.id}'), + icon: const Icon(Icons.open_in_new_rounded, size: 14), + label: const Text('Detay', style: TextStyle(fontSize: 13)), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 36), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric(horizontal: 12), + foregroundColor: AppColors.pending, + side: BorderSide(color: AppColors.pending.withValues(alpha: 0.6)), + ), + ), + ]) + : Row(children: [ + Expanded( + child: FilledButton.icon( + onPressed: onDeliver, + icon: const Icon(Icons.inventory_2_outlined, size: 15), + label: const Text('Teslim Aldım', style: TextStyle(fontSize: 13)), + style: FilledButton.styleFrom( + minimumSize: const Size(0, 36), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => context.push('/clinic/jobs/${job.id}'), + icon: const Icon(Icons.open_in_new_rounded, size: 14), + label: const Text('Detay', style: TextStyle(fontSize: 13)), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 36), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric(horizontal: 12), + foregroundColor: AppColors.accent, + side: BorderSide(color: AppColors.accent.withValues(alpha: 0.6)), + ), + ), + ]), + ), + ], + ), + ), + ), + ), + ); + } +} + +// ── Monthly Report ────────────────────────────────────────────────────────── + +class _MonthlyReportSection extends StatelessWidget { + const _MonthlyReportSection({required this.data}); + final _DashboardData data; + + @override + Widget build(BuildContext context) { + final pct = data.changePercent; + final isUp = pct >= 0; + final pctStr = '${isUp ? '+' : ''}${pct.toStringAsFixed(0)}%'; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.bar_chart_rounded, size: 18, color: AppColors.accent), + const SizedBox(width: 6), + Text('Aylık Rapor', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: _MonthStat(label: 'Bu Ay', value: data.thisMonthDelivered, highlighted: true)), + const SizedBox(width: 12), + Expanded(child: _MonthStat(label: 'Geçen Ay', value: data.lastMonthDelivered, highlighted: false)), + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: isUp ? AppColors.successBg : AppColors.cancelledBg, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded, + size: 16, + color: isUp ? AppColors.success : AppColors.cancelled, + ), + const SizedBox(width: 4), + Text( + pctStr, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: isUp ? AppColors.success : AppColors.cancelled, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } +} + +class _MonthStat extends StatelessWidget { + const _MonthStat({required this.label, required this.value, required this.highlighted}); + final String label; + final int value; + final bool highlighted; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: highlighted ? AppColors.accent.withValues(alpha: 0.06) : AppColors.background, + borderRadius: BorderRadius.circular(8), + border: highlighted ? Border.all(color: AppColors.accent.withValues(alpha: 0.2)) : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontSize: 11, color: AppColors.textSecondary, fontWeight: FontWeight.w500)), + const SizedBox(height: 2), + Text( + '$value iş', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: highlighted ? AppColors.accent : AppColors.textPrimary, + ), + ), + ], + ), + ); + } +} + +// ── Gamification Row ───────────────────────────────────────────────────────── + +const _monthlyGoal = 20; + +class _GamificationRow extends StatelessWidget { + const _GamificationRow({required this.data}); + final _DashboardData data; + + @override + Widget build(BuildContext context) { + final progress = (data.thisMonthDelivered / _monthlyGoal).clamp(0.0, 1.0); + final remaining = (_monthlyGoal - data.thisMonthDelivered).clamp(0, _monthlyGoal); + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('🏆', style: TextStyle(fontSize: 16)), + const SizedBox(width: 6), + Text('Aylık Hedef', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${data.points} puan', + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.primary), + ), + ), + ], + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: progress, + minHeight: 8, + backgroundColor: AppColors.background, + valueColor: AlwaysStoppedAnimation( + progress >= 1.0 ? AppColors.success : AppColors.accent, + ), + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${data.thisMonthDelivered} / $_monthlyGoal iş teslim edildi', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + Text( + progress >= 1.0 ? 'Hedef tamamlandı!' : '$remaining iş kaldı', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: progress >= 1.0 ? AppColors.success : AppColors.textSecondary, + ), + ), + ], + ), + ], + ), + ); + } +} + +// ── Header ────────────────────────────────────────────────────────────────── + +class _DashboardHeader extends StatelessWidget { + const _DashboardHeader({required this.companyName}); + final String companyName; + + static const double _desktopToolbarHeight = 64; + + @override + Widget build(BuildContext context) { + final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + + if (isDesktop) { + return SliverAppBar( + pinned: true, + toolbarHeight: _desktopToolbarHeight, + backgroundColor: AppColors.surface, + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: false, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Genel Bakış', style: TextStyle(fontSize: 11, color: AppColors.textSecondary.withValues(alpha: 0.8), letterSpacing: 0.3)), + const Text('Bugünkü Durum', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + ], + ), + ), + actions: [ + IconButton( + onPressed: () => context.go(routeClinicSettings), + icon: const Icon(Icons.settings_outlined, color: AppColors.textSecondary, size: 22), + ), + const SizedBox(width: 8), + ], + ); + } + + return SliverAppBar( + pinned: true, + expandedHeight: 148, + backgroundColor: AppColors.primary, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + systemOverlayStyle: SystemUiOverlayStyle.light, + centerTitle: false, + leadingWidth: 60, + leading: Padding( + padding: const EdgeInsets.only(left: 16, top: 8, bottom: 8), + child: Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: const Center(child: ToothLogo(size: 20, color: Colors.white)), + ), + ), + titleSpacing: 8, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('DLS', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 1.5)), + Text(companyName, style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700), maxLines: 1, overflow: TextOverflow.ellipsis), + ], + ), + actions: [ + IconButton( + onPressed: () => context.go(routeClinicSettings), + icon: const Icon(Icons.settings_outlined, color: Colors.white, size: 22), + ), + ], + flexibleSpace: FlexibleSpaceBar( + collapseMode: CollapseMode.pin, + background: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.primary, AppColors.accent], + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text('Genel Bakış', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 12, fontWeight: FontWeight.w500, letterSpacing: 0.5)), + const SizedBox(height: 4), + const Text('Bugünkü Durum', style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.w800, letterSpacing: -0.5)), + ], + ), + ), + ), + ), + ); + } +} + +// ── Stats ──────────────────────────────────────────────────────────────────── + +class _StatsRow extends StatelessWidget { + const _StatsRow({ + required this.pending, + required this.inProgress, + required this.sent, + required this.patients, + }); + final int pending; + final int inProgress; + final int sent; + final int patients; + + @override + Widget build(BuildContext context) { + final isWideDesktop = MediaQuery.sizeOf(context).width >= AppLayout.wideBreakpoint; + + final c1 = _StatCard(label: 'Bekleyen', value: '$pending', icon: Icons.hourglass_top_rounded, color: AppColors.pending, bgColor: AppColors.pendingBg) + .animate().fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); + final c2 = _StatCard(label: 'Devam Eden', value: '$inProgress', icon: Icons.autorenew_rounded, color: AppColors.inProgress, bgColor: AppColors.inProgressBg) + .animate(delay: 80.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); + final c3 = _StatCard(label: 'Toplam Hasta', value: '$patients', icon: Icons.people_outline_rounded, color: AppColors.success, bgColor: AppColors.successBg) + .animate(delay: 160.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); + + // Wide desktop (≥ 1100px): 4 cards side by side — full lifecycle view. + if (isWideDesktop) { + final c4 = _StatCard(label: 'Klinik\'te', value: '$sent', icon: Icons.local_hospital_outlined, color: AppColors.accent, bgColor: AppColors.inProgressBg) + .animate(delay: 120.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); + return Row( + children: [ + Expanded(child: c1), + const SizedBox(width: 12), + Expanded(child: c2), + const SizedBox(width: 12), + Expanded(child: c4), + const SizedBox(width: 12), + Expanded(child: c3), + ], + ); + } + + // Mobile + narrow sidebar (< 1100px): 2+1 column layout. + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded(child: c1), + const SizedBox(width: 12), + Expanded(child: c2), + ], + ), + const SizedBox(height: 12), + c3, + ], + ); + } +} + +class _StatCard extends StatelessWidget { + const _StatCard({ + required this.label, + required this.value, + required this.icon, + required this.color, + required this.bgColor, + }); + final String label; + final String value; + final IconData icon; + final Color color; + final Color bgColor; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: + BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(12)), + child: Icon(icon, color: color, size: 22), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(value, + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: color, + height: 1)), + const SizedBox(height: 3), + Text(label, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500)), + ], + ), + ], + ), + ); + } +} + +// ── Job Card ───────────────────────────────────────────────────────────────── + +class _JobCard extends StatelessWidget { + const _JobCard({required this.job}); + final Job job; + + @override + Widget build(BuildContext context) { + final due = job.dueDate; + final isOverdue = due != null && due.isBefore(DateTime.now()); + final dueText = due != null + ? '${due.day.toString().padLeft(2, '0')}.${due.month.toString().padLeft(2, '0')}.${due.year}' + : null; + + final statusColor = _statusColor(job.status); + final statusBg = _statusBg(job.status); + + return Semantics( + label: job.patientCode, + button: true, + excludeSemantics: true, + child: Material( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: () => context.push('/clinic/jobs/${job.id}'), + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border)), + child: Row( + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: statusBg, borderRadius: BorderRadius.circular(12)), + child: Icon(Icons.work_outline_rounded, + color: statusColor, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(job.patientCode, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary)), + const SizedBox(height: 2), + Text(job.labName ?? 'Laboratuvar', + style: const TextStyle( + fontSize: 13, color: AppColors.textSecondary)), + const SizedBox(height: 6), + Wrap( + spacing: 6, + children: [ + _Tag( + label: job.prostheticType.label, + color: AppColors.inProgress, + bg: AppColors.inProgressBg), + _Tag( + label: job.status.label, + color: statusColor, + bg: statusBg), + ], + ), + ], + ), + ), + if (dueText != null) ...[ + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Icon(Icons.calendar_today_outlined, + size: 13, + color: isOverdue + ? AppColors.cancelled + : AppColors.textMuted), + const SizedBox(height: 3), + Text( + dueText, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: isOverdue + ? AppColors.cancelled + : AppColors.textSecondary), + ), + ], + ), + ], + ], + ), + ), + ), + ), + ); + } + + Color _statusColor(JobStatus s) { + switch (s) { + case JobStatus.pending: + return AppColors.pending; + case JobStatus.inProgress: + return AppColors.inProgress; + case JobStatus.sent: + return AppColors.accent; + case JobStatus.delivered: + return AppColors.success; + case JobStatus.cancelled: + return AppColors.cancelled; + } + } + + Color _statusBg(JobStatus s) { + switch (s) { + case JobStatus.pending: + return AppColors.pendingBg; + case JobStatus.inProgress: + return AppColors.inProgressBg; + case JobStatus.sent: + return AppColors.inProgressBg; + case JobStatus.delivered: + return AppColors.successBg; + case JobStatus.cancelled: + return AppColors.cancelledBg; + } + } +} + +class _Tag extends StatelessWidget { + const _Tag({required this.label, required this.color, required this.bg}); + final String label; + final Color color; + final Color bg; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: + BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)), + child: Text(label, + style: TextStyle( + fontSize: 11, fontWeight: FontWeight.w600, color: color)), + ); + } +} + +// ── Empty / Error / Skeleton ───────────────────────────────────────────────── + +class _EmptyJobs extends StatelessWidget { + const _EmptyJobs(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(40), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(20)), + child: const Icon(Icons.work_off_outlined, + color: AppColors.inProgress, size: 32), + ), + const SizedBox(height: 16), + const Text('Henüz iş yok', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary)), + const SizedBox(height: 6), + const Text( + 'Yeni iş oluşturduğunuzda\nburada görünecek', + textAlign: TextAlign.center, + style: + TextStyle(fontSize: 13, color: AppColors.textSecondary), + ), + ], + ), + ); + } +} + +class _ErrorBody extends StatelessWidget { + const _ErrorBody({required this.onRetry}); + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(16)), + child: const Icon(Icons.wifi_off_rounded, + color: AppColors.cancelled, size: 30), + ), + const SizedBox(height: 16), + const Text('Bağlantı hatası', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary)), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Tekrar Dene')), + ], + ), + ), + ); + } +} + +class _DashboardSkeleton extends StatelessWidget { + const _DashboardSkeleton({required this.companyName, required this.hPad}); + final String companyName; + final double hPad; + + @override + Widget build(BuildContext context) { + return CustomScrollView( + physics: const NeverScrollableScrollPhysics(), + slivers: [ + _DashboardHeader(companyName: companyName), + SliverPadding( + padding: EdgeInsets.fromLTRB(hPad, 16, hPad, 0), + sliver: const SliverToBoxAdapter( + child: Column( + children: [ + Row(children: [ + Expanded(child: _ShimmerBox(height: 84, radius: 16)), + SizedBox(width: 12), + Expanded(child: _ShimmerBox(height: 84, radius: 16)), + ]), + SizedBox(height: 12), + _ShimmerBox(height: 84, radius: 16), + ], + ), + ), + ), + SliverPadding( + padding: EdgeInsets.fromLTRB(hPad, 8, hPad, 0), + sliver: SliverList.builder( + itemCount: 4, + itemBuilder: (_, i) => const Padding( + padding: EdgeInsets.only(bottom: 10), + child: _ShimmerBox(height: 92, radius: 14)), + ), + ), + ], + ); + } +} + +class _ShimmerBox extends StatefulWidget { + const _ShimmerBox({required this.height, required this.radius}); + final double height; + final double radius; + + @override + State<_ShimmerBox> createState() => _ShimmerBoxState(); +} + +class _ShimmerBoxState extends State<_ShimmerBox> + with SingleTickerProviderStateMixin { + late AnimationController _ctrl; + late Animation _anim; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, duration: const Duration(milliseconds: 1100)) + ..repeat(reverse: true); + _anim = CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _anim, + builder: (_, __) => Container( + height: widget.height, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(widget.radius), + color: Color.lerp(const Color(0xFFE2E8F0), + const Color(0xFFF1F5F9), _anim.value)), + ), + ); + } +} diff --git a/lib/features/clinic/finance/clinic_finance_repository.dart b/lib/features/clinic/finance/clinic_finance_repository.dart new file mode 100644 index 0000000..0843e49 --- /dev/null +++ b/lib/features/clinic/finance/clinic_finance_repository.dart @@ -0,0 +1,49 @@ +import 'package:pocketbase/pocketbase.dart'; +import '../../../core/api/pocketbase_client.dart'; +import '../../../models/finance_entry.dart'; + +class ClinicFinanceRepository { + ClinicFinanceRepository._(); + static final instance = ClinicFinanceRepository._(); + + PocketBase get _pb => PocketBaseClient.instance.pb; + + Future> listEntries( + String tenantId, { + String? status, + int page = 1, + int limit = 30, + }) async { + final filterParts = ['tenant_id = "$tenantId"', 'type = "payable"']; + if (status != null) filterParts.add('status = "$status"'); + + final result = await _pb.collection('finance_entries').getList( + page: page, + perPage: limit, + filter: filterParts.join(' && '), + expand: 'job_id', + ); + return (result.items.map((r) => FinanceEntry.fromJson(r.toJson())).toList() + ..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? ''))); + } + + Future> summary(String tenantId) async { + final all = await listEntries(tenantId, limit: 200); + double pending = 0, paid = 0; + for (final e in all) { + if (e.status == FinanceStatus.pending) { + pending += e.amount; + } else { + paid += e.amount; + } + } + return {'pending': pending, 'paid': paid}; + } + + Future markPaid(String entryId) async { + await _pb.collection('finance_entries').update(entryId, body: { + 'status': 'paid', + 'paid_at': DateTime.now().toIso8601String(), + }); + } +} diff --git a/lib/features/clinic/finance/clinic_finance_screen.dart b/lib/features/clinic/finance/clinic_finance_screen.dart new file mode 100644 index 0000000..fffd4b7 --- /dev/null +++ b/lib/features/clinic/finance/clinic_finance_screen.dart @@ -0,0 +1,534 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/providers/auth_provider.dart'; +import '../../../core/providers/locale_provider.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/utils/currency_formatter.dart'; +import '../../../core/widgets/gradient_app_bar.dart'; +import '../../../core/widgets/pill_tabs.dart'; +import '../../../models/finance_entry.dart'; +import 'clinic_finance_repository.dart'; + +enum _FinanceSort { newestFirst, byAmountDesc, byAmountAsc } + +class ClinicFinanceScreen extends ConsumerStatefulWidget { + const ClinicFinanceScreen({super.key}); + + @override + ConsumerState createState() => + _ClinicFinanceScreenState(); +} + +class _ClinicFinanceScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + late Future> _summaryFuture; + _FinanceSort _sort = _FinanceSort.newestFirst; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _tabController.addListener(() { + if (mounted) setState(() {}); + }); + _loadSummary(); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + void _loadSummary() { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + setState(() { + _summaryFuture = + ClinicFinanceRepository.instance.summary(tenantId); + }); + } + + Future _showSortOptions() async { + final s = ref.read(stringsProvider); + final result = await showSortSheet( + context, + title: s.sort, + options: [s.sortNewest, s.sortAmountDesc, s.sortAmountAsc], + current: _sort.index, + ); + if (result != null) { + setState(() => _sort = _FinanceSort.values[result]); + } + } + + @override + Widget build(BuildContext context) { + final isSortActive = _sort != _FinanceSort.newestFirst; + final s = ref.watch(stringsProvider); + final currencyCode = + ref.watch(authProvider).activeTenant?.tenant.defaultCurrency ?? 'TRY'; + + return Scaffold( + backgroundColor: AppColors.background, + appBar: GradientAppBar( + title: s.finance, + category: s.clinicCategory, + actions: [ + IconButton( + onPressed: _showSortOptions, + tooltip: 'Sırala', + icon: Badge( + isLabelVisible: isSortActive, + smallSize: 8, + backgroundColor: AppColors.accent, + child: const Icon(Icons.sort_rounded), + ), + ), + ], + ), + body: Column( + children: [ + FutureBuilder>( + future: _summaryFuture, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const LinearProgressIndicator( + color: AppColors.accent); + } + final summary = snap.data ?? {'pending': 0.0, 'paid': 0.0}; + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: _SummaryCard( + label: s.pendingReceivable, + amount: summary['pending'] ?? 0.0, + currencyCode: currencyCode, + color: AppColors.pending, + bgColor: AppColors.pendingBg, + icon: Icons.hourglass_empty_rounded, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _SummaryCard( + label: s.collected, + amount: summary['paid'] ?? 0.0, + currencyCode: currencyCode, + color: AppColors.success, + bgColor: AppColors.successBg, + icon: Icons.check_circle_outline, + ), + ), + ], + ), + ); + }, + ), + PillTabs( + tabs: [s.pending, s.collected], + selected: _tabController.index, + onSelect: (i) => _tabController.animateTo(i), + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _FinanceTab( + status: 'pending', + sort: _sort, + onPaymentMade: _loadSummary, + currencyCode: currencyCode, + ), + _FinanceTab( + status: 'paid', + sort: _sort, + onPaymentMade: _loadSummary, + currencyCode: currencyCode, + ), + ], + ), + ), + ], + ), + ); + } +} + +class _SummaryCard extends StatelessWidget { + const _SummaryCard({ + required this.label, + required this.amount, + required this.currencyCode, + required this.color, + required this.bgColor, + required this.icon, + }); + + final String label; + final double amount; + final String currencyCode; + final Color color; + final Color bgColor; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(12)), + child: Icon(icon, color: color, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + CurrencyFormatter.format(amount, currencyCode), + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + color: color, + height: 1), + ), + const SizedBox(height: 3), + Text(label, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500)), + ], + ), + ), + ], + ), + ); + } +} + +class _FinanceTab extends ConsumerStatefulWidget { + const _FinanceTab({ + required this.status, + required this.sort, + required this.onPaymentMade, + required this.currencyCode, + }); + + final String status; + final _FinanceSort sort; + final VoidCallback onPaymentMade; + final String currencyCode; + + @override + ConsumerState<_FinanceTab> createState() => _FinanceTabState(); +} + +class _FinanceTabState extends ConsumerState<_FinanceTab> { + late Future> _future; + + @override + void initState() { + super.initState(); + _load(); + } + + void _load() { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + setState(() { + _future = ClinicFinanceRepository.instance.listEntries( + tenantId, + status: widget.status, + limit: 100, + ); + }); + } + + List _sorted(List entries) { + final list = List.from(entries); + switch (widget.sort) { + case _FinanceSort.newestFirst: + list.sort((a, b) { + final da = a.dateCreated != null + ? DateTime.tryParse(a.dateCreated!) + : null; + final db = b.dateCreated != null + ? DateTime.tryParse(b.dateCreated!) + : null; + if (da == null && db == null) return 0; + if (da == null) return 1; + if (db == null) return -1; + return db.compareTo(da); + }); + case _FinanceSort.byAmountDesc: + list.sort((a, b) => b.amount.compareTo(a.amount)); + case _FinanceSort.byAmountAsc: + list.sort((a, b) => a.amount.compareTo(b.amount)); + } + return list; + } + + Future _markPaid(FinanceEntry entry) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Ödeme Onayı'), + content: Text( + '${entry.counterpartyName ?? "Bu kayıt"} için ' + '${CurrencyFormatter.format(entry.amount, widget.currencyCode)} tutarındaki borcu ' + 'ödendi olarak işaretlemek istiyor musunuz?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('İptal'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Ödendi'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + try { + await ClinicFinanceRepository.instance.markPaid(entry.id); + _load(); + widget.onPaymentMade(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Ödeme kaydedildi.')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + color: AppColors.accent, + onRefresh: () async => _load(), + child: FutureBuilder>( + future: _future, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator(color: AppColors.accent)); + } + if (snap.hasError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(16)), + child: const Icon(Icons.wifi_off_rounded, + color: AppColors.cancelled, size: 30), + ), + const SizedBox(height: 16), + Text('Hata: ${snap.error}', + style: const TextStyle( + color: AppColors.textSecondary)), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Tekrar Dene'), + ), + ], + ), + ); + } + final entries = _sorted(snap.data!); + if (entries.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(20)), + child: const Icon(Icons.receipt_long_outlined, + color: AppColors.inProgress, size: 32), + ), + const SizedBox(height: 16), + const Text( + 'Kayıt bulunamadı', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary), + ), + ], + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + itemCount: entries.length, + itemBuilder: (context, index) { + final entry = entries[index]; + final isPending = entry.status == FinanceStatus.pending; + final statusColor = + isPending ? AppColors.pending : AppColors.success; + final statusBg = + isPending ? AppColors.pendingBg : AppColors.successBg; + + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Material( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: isPending ? () => _markPaid(entry) : null, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2)) + ]), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: statusBg, + borderRadius: BorderRadius.circular(12)), + child: Icon( + isPending + ? Icons.hourglass_empty_rounded + : Icons.check_circle_outline, + color: statusColor, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + entry.counterpartyName ?? + 'Bilinmiyor', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary), + ), + ), + Text( + CurrencyFormatter.format( + entry.amount, widget.currencyCode), + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: statusColor), + ), + ], + ), + if (entry.patientCode != null) ...[ + const SizedBox(height: 2), + Text( + 'Protokol: ${entry.patientCode}', + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary), + ), + ], + if (entry.dateCreated != null) ...[ + const SizedBox(height: 2), + Text( + _formatDate(entry.dateCreated!), + style: const TextStyle( + fontSize: 12, + color: AppColors.textMuted), + ), + ], + ], + ), + ), + if (isPending) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppColors.pending, + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'Öde', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + ), + ), + ), + ); + }, + ); + }, + ), + ); + } + + String _formatDate(String dateStr) { + try { + final d = DateTime.parse(dateStr); + return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}'; + } catch (_) { + return dateStr; + } + } +} diff --git a/lib/features/clinic/jobs/clinic_job_detail_screen.dart b/lib/features/clinic/jobs/clinic_job_detail_screen.dart new file mode 100644 index 0000000..bc0a047 --- /dev/null +++ b/lib/features/clinic/jobs/clinic_job_detail_screen.dart @@ -0,0 +1,749 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/providers/auth_provider.dart'; +import '../../../core/services/realtime_service.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../models/job.dart'; +import '../../../models/job_file.dart'; +import '../../../features/shared/job_files_repository.dart'; +import '../../../features/shared/job_files_panel.dart'; +import '../../../core/services/job_history_service.dart'; +import 'clinic_jobs_repository.dart'; + +class ClinicJobDetailScreen extends ConsumerStatefulWidget { + const ClinicJobDetailScreen({super.key, required this.jobId}); + final String jobId; + + @override + ConsumerState createState() => + _ClinicJobDetailScreenState(); +} + +class _ClinicJobDetailScreenState + extends ConsumerState { + Job? _job; + String? _loadError; + late Future> _filesFuture; + bool _isActing = false; + late UnsubFn _unsub; + + @override + void initState() { + super.initState(); + _load(); + _loadFiles(); + _unsub = RealtimeService.instance.watch( + 'jobs', + topic: widget.jobId, + onEvent: (_) { if (mounted && !_isActing) _load(); }, + ); + } + + @override + void dispose() { + _unsub(); + super.dispose(); + } + + Future _load() async { + if (mounted) setState(() { _loadError = null; }); + try { + final job = await ClinicJobsRepository.instance.getJob(widget.jobId); + if (mounted) setState(() { _job = job; }); + } catch (e) { + if (mounted) setState(() { _loadError = e.toString(); }); + } + } + + void _loadFiles() { + setState(() { + _filesFuture = JobFilesRepository.instance.listForJob(widget.jobId); + }); + } + + Future _approve(Job job) async { + setState(() => _isActing = true); + try { + final updated = await ClinicJobsRepository.instance.approveAtClinic(job.id, job); + if (mounted) { + setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('İş onaylandı.')), + ); + } + } catch (e) { + if (mounted) { + setState(() => _isActing = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } + } + + Future _cancelJob(Job job) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('İşi İptal Et'), + content: const Text('Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Vazgeç')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('İptal Et'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _isActing = true); + try { + final updated = await ClinicJobsRepository.instance.cancelJob(job.id, job); + if (mounted) { + setState(() { _job = _job!.copyWith(status: updated.status); _isActing = false; }); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('İş iptal edildi.'))); + } + } catch (e) { + if (mounted) { + setState(() => _isActing = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e'))); + } + } + } + + Future _requestRevision(Job job) async { + final noteController = TextEditingController(); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Revizyon Talebi'), + content: TextField( + controller: noteController, + decoration: const InputDecoration( + labelText: 'Açıklama', + hintText: 'Revizyon sebebini belirtin...', + ), + minLines: 3, + maxLines: 5, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('İptal'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Gönder'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + if (noteController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Lütfen bir açıklama girin.')), + ); + return; + } + + setState(() => _isActing = true); + try { + final updated = await ClinicJobsRepository.instance.requestRevision( + job.id, + job, + note: noteController.text.trim(), + ); + if (mounted) { + setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Revizyon talebi gönderildi.')), + ); + } + } catch (e) { + if (mounted) { + setState(() => _isActing = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } + } + + Future _markDelivered(Job job) async { + final noteCtrl = TextEditingController(); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Teslim Alındı'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Bu işi teslim aldığınızı onaylıyor musunuz?'), + const SizedBox(height: 12), + TextField( + controller: noteCtrl, + decoration: const InputDecoration( + labelText: 'Teslimat notu (isteğe bağlı)', + hintText: 'Teslim eden kişi, durum vb...', + isDense: true, + ), + maxLines: 2, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('İptal'), + ), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: AppColors.success), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Teslim Alındı'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + setState(() => _isActing = true); + try { + final note = noteCtrl.text.trim().isNotEmpty ? noteCtrl.text.trim() : null; + final updated = await ClinicJobsRepository.instance.markDelivered(job.id, job, note: note); + if (mounted) { + setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('İş teslim alındı olarak işaretlendi.')), + ); + } + } catch (e) { + if (mounted) { + setState(() => _isActing = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar(title: const Text('İş Detayı')), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_job == null && _loadError == null) { + return const Center(child: CircularProgressIndicator(color: AppColors.accent)); + } + if (_loadError != null && _job == null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(16)), + child: const Icon(Icons.wifi_off_rounded, + color: AppColors.cancelled, size: 30), + ), + const SizedBox(height: 16), + Text('Hata: $_loadError', + style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Tekrar Dene'), + ), + ], + ), + ); + } + if (_job == null) return const Center(child: CircularProgressIndicator(color: AppColors.accent)); + final job = _job!; + final membership = ref.read(authProvider).activeTenant; + final canDeliver = membership?.canDeliverJobs ?? true; + final canCancel = membership?.canCancelJobs ?? true; + final canManage = !(membership?.isDeliveryOnly ?? false); + return _JobDetailBody( + job: job, + filesFuture: _filesFuture, + isActing: _isActing, + canDeliver: canDeliver, + canManage: canManage, + onApprove: canManage ? () => _approve(job) : () {}, + onRevision: canManage ? () => _requestRevision(job) : () {}, + onDelivered: () => _markDelivered(job), + onCancel: (canCancel && job.status == JobStatus.pending) ? () => _cancelJob(job) : null, + onFilesRefresh: _loadFiles, + ); + } +} + +class _JobDetailBody extends StatelessWidget { + const _JobDetailBody({ + required this.job, + required this.filesFuture, + required this.isActing, + required this.canDeliver, + required this.canManage, + required this.onApprove, + required this.onRevision, + required this.onDelivered, + required this.onFilesRefresh, + this.onCancel, + }); + + final Job job; + final Future> filesFuture; + final bool isActing; + final bool canDeliver; + final bool canManage; + final VoidCallback onApprove; + final VoidCallback onRevision; + final VoidCallback onDelivered; + final VoidCallback? onCancel; + final VoidCallback onFilesRefresh; + + @override + Widget build(BuildContext context) { + final steps = job.stepTemplate; + final currentStepIndex = + job.currentStep != null ? steps.indexOf(job.currentStep!) : -1; + + final canApproveOrRevise = canManage && + job.location == JobLocation.atClinic && + job.status == JobStatus.inProgress; + final canMarkDelivered = canDeliver && job.status == JobStatus.sent; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // Info card + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Patient code + status + Row( + children: [ + Expanded( + child: Text( + job.patientCode, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.textPrimary), + ), + ), + _StatusBadge(status: job.status), + ], + ), + const SizedBox(height: 16), + const Divider(height: 1, color: AppColors.border), + const SizedBox(height: 12), + + // Patient + Lab + _SectionLabel(title: 'Hasta & Laboratuvar'), + _InfoRow(label: 'Protokol No', value: job.patientCode), + if (job.patientId != null) + _InfoRow(label: 'Hasta ID', value: job.patientId!), + _InfoRow( + label: 'Laboratuvar', value: job.labName ?? 'Bilinmiyor'), + const SizedBox(height: 12), + + // Prosthetic + _SectionLabel(title: 'Protez Bilgisi'), + _InfoRow(label: 'Tür', value: job.prostheticType.label), + _InfoRow(label: 'Üye Sayısı', value: '${job.memberCount}'), + if (job.teeth.isNotEmpty) + _InfoRow(label: 'Dişler', value: job.teeth.join(', ')), + if (job.color != null && job.color!.isNotEmpty) + _InfoRow(label: 'Renk', value: job.color!), + if (job.description != null && job.description!.isNotEmpty) + _InfoRow(label: 'Açıklama', value: job.description!), + if (job.dueDate != null) + _InfoRow(label: 'Son Tarih', value: _formatDate(job.dueDate!, withTime: true)), + if (job.price != null) + _InfoRow( + label: 'Fiyat', + value: + '${job.price!.toStringAsFixed(2)} ${job.currency ?? 'TRY'}'), + ], + ), + ), + + const SizedBox(height: 16), + + // Stepper card + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'İş Adımları', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary), + ), + const SizedBox(height: 16), + _StepperWidget( + steps: steps, + currentStepIndex: currentStepIndex, + historyFuture: JobHistoryService.instance.listForJob(job.id), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Action buttons + if (isActing) + const Center( + child: CircularProgressIndicator(color: AppColors.accent)) + else if (canApproveOrRevise) ...[ + FilledButton.icon( + onPressed: onApprove, + icon: const Icon(Icons.check_circle_outline), + label: const Text('Onayla'), + style: FilledButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + backgroundColor: AppColors.success, + ), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: onRevision, + icon: const Icon(Icons.replay_outlined), + label: const Text('Revizyon İste'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + foregroundColor: AppColors.pending, + side: const BorderSide(color: AppColors.pending), + ), + ), + ] else if (canMarkDelivered) + FilledButton.icon( + onPressed: onDelivered, + icon: const Icon(Icons.inventory_2_outlined), + label: const Text('Teslim Aldım'), + style: FilledButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + ), + ), + + if (onCancel != null) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: onCancel, + icon: const Icon(Icons.close_rounded), + label: const Text('İşi İptal Et'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + foregroundColor: AppColors.cancelled, + side: const BorderSide(color: AppColors.cancelled), + ), + ), + ], + + const SizedBox(height: 20), + + // Files panel + JobFilesPanel( + job: job, + filesFuture: filesFuture, + onRefresh: onFilesRefresh, + ), + + const SizedBox(height: 12), + Text( + 'Oluşturulma: ${_formatDate(job.dateCreated)}', + style: const TextStyle(fontSize: 12, color: AppColors.textMuted), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + ], + ); + } + + String _formatDate(DateTime d, {bool withTime = false}) { + final s = '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}'; + if (!withTime || (d.hour == 0 && d.minute == 0)) return s; + return '$s ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; + } +} + +class _StepperWidget extends StatelessWidget { + const _StepperWidget({ + required this.steps, + required this.currentStepIndex, + required this.historyFuture, + }); + + final List steps; + final int currentStepIndex; + final Future> historyFuture; + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: historyFuture, + builder: (ctx, snap) { + final history = snap.data ?? []; + final Map revisionCounts = {}; + for (final e in history) { + if (e.action == JobHistoryAction.revisionRequested && e.step != null) { + revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1; + } + } + + return Column( + children: steps.asMap().entries.map((entry) { + final index = entry.key; + final step = entry.value; + final isCompleted = index < currentStepIndex; + final isCurrent = index == currentStepIndex; + final revCount = revisionCounts[step] ?? 0; + + Color dotColor; + IconData dotIcon; + if (isCompleted) { + dotColor = AppColors.success; + dotIcon = Icons.check_circle; + } else if (isCurrent) { + dotColor = AppColors.inProgress; + dotIcon = Icons.radio_button_checked; + } else { + dotColor = AppColors.muted; + dotIcon = Icons.radio_button_unchecked; + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + Icon(dotIcon, color: dotColor, size: 24), + if (index < steps.length - 1) + Container( + width: 2, + height: 44, + color: index < currentStepIndex + ? AppColors.success.withValues(alpha: 0.35) + : AppColors.border, + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 2, bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + step.label, + style: TextStyle( + fontWeight: isCurrent + ? FontWeight.bold + : FontWeight.normal, + color: isCompleted + ? AppColors.success + : isCurrent + ? AppColors.inProgress + : AppColors.textMuted, + fontSize: 15, + ), + ), + if (revCount > 0) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '$revCount revizyon', + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.cancelled, + ), + ), + ), + ], + ], + ), + if (isCurrent) + Text( + step.description, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ), + ], + ); + }).toList(), + ); + }, + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.title}); + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text( + title, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.accent, + letterSpacing: 0.5), + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.label, required this.value}); + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 110, + child: Text( + label, + style: const TextStyle( + fontSize: 13, color: AppColors.textSecondary), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary), + ), + ), + ], + ), + ); + } +} + +class _StatusBadge extends StatelessWidget { + const _StatusBadge({required this.status}); + final JobStatus status; + + @override + Widget build(BuildContext context) { + final color = _color(status); + final bg = _bg(status); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + status.label, + style: TextStyle( + color: color, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Color _color(JobStatus s) { + switch (s) { + case JobStatus.pending: + return AppColors.pending; + case JobStatus.inProgress: + return AppColors.inProgress; + case JobStatus.sent: + return AppColors.accent; + case JobStatus.delivered: + return AppColors.success; + case JobStatus.cancelled: + return AppColors.cancelled; + } + } + + Color _bg(JobStatus s) { + switch (s) { + case JobStatus.pending: + return AppColors.pendingBg; + case JobStatus.inProgress: + return AppColors.inProgressBg; + case JobStatus.sent: + return AppColors.inProgressBg; + case JobStatus.delivered: + return AppColors.successBg; + case JobStatus.cancelled: + return AppColors.cancelledBg; + } + } +} + diff --git a/lib/features/clinic/jobs/clinic_jobs_repository.dart b/lib/features/clinic/jobs/clinic_jobs_repository.dart new file mode 100644 index 0000000..76e462f --- /dev/null +++ b/lib/features/clinic/jobs/clinic_jobs_repository.dart @@ -0,0 +1,177 @@ +import 'dart:async'; +import 'package:pocketbase/pocketbase.dart'; +import '../../../core/api/pocketbase_client.dart'; +import '../../../core/services/job_history_service.dart'; +import '../../../models/job.dart'; + +const _listExpand = 'clinic_tenant_id,lab_tenant_id'; +const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id,prosthetic_id'; + +class ClinicJobsRepository { + ClinicJobsRepository._(); + static final instance = ClinicJobsRepository._(); + + PocketBase get _pb => PocketBaseClient.instance.pb; + + Future> listOutbound( + String clinicTenantId, { + List? statuses, + String? location, + String? filterExtra, + int page = 1, + int limit = 30, + }) async { + final filterParts = ['clinic_tenant_id = "$clinicTenantId"']; + if (statuses != null && statuses.isNotEmpty) { + final statusFilter = statuses.map((s) => 'status = "$s"').join(' || '); + filterParts.add('($statusFilter)'); + } + if (location != null) { + filterParts.add('location = "$location"'); + } + if (filterExtra != null) { + filterParts.add('($filterExtra)'); + } + + final result = await _pb.collection('jobs').getList( + page: page, + perPage: limit, + filter: filterParts.join(' && '), + expand: _listExpand, + ); + return (result.items.map((r) => Job.fromJson(r.toJson())).toList() + ..sort((a, b) => b.dateCreated.compareTo(a.dateCreated))); + } + + Future getJob(String jobId) async { + final record = await _pb.collection('jobs').getOne(jobId, expand: _detailExpand); + return Job.fromJson(record.toJson()); + } + + Future createJob({ + required String clinicTenantId, + required String labTenantId, + required String patientCode, + required String prostheticId, + required ProstheticType prostheticType, + required List teeth, + String? patientId, + String? color, + String? description, + String? dueDate, + bool provaRequired = true, + }) async { + final record = await _pb.collection('jobs').create(body: { + 'clinic_tenant_id': clinicTenantId, + 'lab_tenant_id': labTenantId, + 'patient_code': patientCode, + if (patientId != null) 'patient_id': patientId, + 'prosthetic_id': prostheticId, + 'prosthetic_type': prostheticType.value, + 'member_count': teeth.length, + 'teeth': teeth, + if (color != null) 'color': color, + if (description != null) 'description': description, + if (dueDate != null) 'due_date': dueDate, + 'status': 'pending', + 'location': 'at_clinic', + 'prova_required': provaRequired, + }); + return Job.fromJson(record.toJson()); + } + + Future approveAtClinic(String jobId, Job job, {String? note}) async { + final nextStep = job.nextStep; + if (nextStep == null) throw Exception('Bu aşamadan ileri gidilemez.'); + + final record = await _pb.collection('jobs').update(jobId, body: { + 'current_step': nextStep.value, + 'location': 'at_lab', + }); + final updated = Job.fromJson(record.toJson()); + unawaited(JobHistoryService.instance.append( + jobId: jobId, + clinicTenantId: job.clinicTenantId, + labTenantId: job.labTenantId, + action: JobHistoryAction.approved, + step: job.currentStep, + note: note, + )); + return updated; + } + + Future requestRevision(String jobId, Job job, {required String note}) async { + final record = await _pb.collection('jobs').update(jobId, body: { + 'location': 'at_lab', + }); + final updated = Job.fromJson(record.toJson()); + unawaited(JobHistoryService.instance.append( + jobId: jobId, + clinicTenantId: job.clinicTenantId, + labTenantId: job.labTenantId, + action: JobHistoryAction.revisionRequested, + step: job.currentStep, + note: note, + )); + return updated; + } + + Future markDelivered(String jobId, Job job, {String? note}) async { + final record = await _pb.collection('jobs').update(jobId, body: { + 'status': 'delivered', + }); + unawaited(JobHistoryService.instance.append( + jobId: jobId, + clinicTenantId: job.clinicTenantId, + labTenantId: job.labTenantId, + action: JobHistoryAction.delivered, + note: note, + )); + return Job.fromJson(record.toJson()); + } + + Future cancelJob(String jobId, Job job) async { + final record = await _pb.collection('jobs').update(jobId, body: { + 'status': 'cancelled', + }); + unawaited(JobHistoryService.instance.append( + jobId: jobId, + clinicTenantId: job.clinicTenantId, + labTenantId: job.labTenantId, + action: JobHistoryAction.cancelled, + )); + return Job.fromJson(record.toJson()); + } + + Future>> listApprovedLabs(String clinicTenantId) async { + final result = await _pb.collection('connections').getList( + filter: 'clinic_tenant_id = "$clinicTenantId" && status = "approved"', + expand: 'lab_tenant_id', + perPage: 100, + ); + return result.items.map((r) { + final expand = r.toJson()['expand'] as Map?; + return expand?['lab_tenant_id'] as Map? ?? {'id': r.data['lab_tenant_id']}; + }).toList(); + } + + Future> listJobsByPatient(String patientId, {int limit = 50}) async { + final result = await _pb.collection('jobs').getList( + filter: 'patient_id = "$patientId"', + perPage: limit, + expand: _listExpand, + ); + return (result.items.map((r) => Job.fromJson(r.toJson())).toList() + ..sort((a, b) => b.dateCreated.compareTo(a.dateCreated))); + } + + Future countDelivered(String clinicTenantId, {DateTime? from, DateTime? to}) async { + final parts = ['clinic_tenant_id = "$clinicTenantId"', 'status = "delivered"']; + if (from != null) parts.add('updated >= "${_date(from)}"'); + if (to != null) parts.add('updated < "${_date(to)}"'); + final r = await _pb.collection('jobs').getList(perPage: 1, filter: parts.join(' && ')); + return r.totalItems; + } + + static String _date(DateTime d) => d.toIso8601String().split('T').first; +} diff --git a/lib/features/clinic/jobs/clinic_jobs_screen.dart b/lib/features/clinic/jobs/clinic_jobs_screen.dart new file mode 100644 index 0000000..884b55e --- /dev/null +++ b/lib/features/clinic/jobs/clinic_jobs_screen.dart @@ -0,0 +1,570 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/providers/auth_provider.dart'; +import '../../../core/router/app_router.dart'; +import '../../../core/services/realtime_service.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/gradient_app_bar.dart'; +import '../../../core/widgets/pill_tabs.dart'; +import '../../../models/job.dart'; +import 'clinic_jobs_repository.dart'; + +enum _JobSort { newestFirst, oldestFirst, byDueDate, byType } + +const _kSortLabels = [ + 'Yeniden Eskiye', + 'Eskiden Yeniye', + 'Vade Tarihine Göre', + 'Türe Göre', +]; + +class ClinicJobsScreen extends ConsumerStatefulWidget { + const ClinicJobsScreen({super.key}); + + @override + ConsumerState createState() => _ClinicJobsScreenState(); +} + +class _ClinicJobsScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + final _searchController = TextEditingController(); + String _searchQuery = ''; + _JobSort _sort = _JobSort.newestFirst; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 5, vsync: this); + _tabController.addListener(() { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _tabController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + void _onSearchChanged(String value) { + setState(() => _searchQuery = value); + } + + Future _showSortOptions() async { + final result = await showSortSheet( + context, + title: 'Sıralama', + options: _kSortLabels, + current: _sort.index, + ); + if (result != null) { + setState(() => _sort = _JobSort.values[result]); + } + } + + @override + Widget build(BuildContext context) { + final isSortActive = _sort != _JobSort.newestFirst; + + return Scaffold( + backgroundColor: AppColors.background, + appBar: GradientAppBar( + title: 'İşlerim', + category: 'KLİNİK', + searchController: _searchController, + onSearchChanged: _onSearchChanged, + searchHint: 'Protokol, laboratuvar veya tür ara...', + actions: [ + IconButton( + onPressed: _showSortOptions, + tooltip: 'Sırala', + icon: Badge( + isLabelVisible: isSortActive, + smallSize: 8, + backgroundColor: AppColors.accent, + child: const Icon(Icons.sort_rounded), + ), + ), + if (ref.watch(authProvider).activeTenant?.canCreateJobs ?? true) + IconButton( + onPressed: () => context.push(routeClinicJobNew), + tooltip: 'Yeni İş', + icon: const Icon(Icons.add_rounded), + ), + ], + ), + body: Column( + children: [ + PillTabs( + tabs: const ['Tümü', 'Onay Bekleyen', 'Lab\'da', 'Teslimat', 'Teslim Alındı'], + selected: _tabController.index, + onSelect: (i) => _tabController.animateTo(i), + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _JobsTab( + statuses: const ['pending', 'in_progress', 'sent', 'delivered'], + searchQuery: _searchQuery, + sort: _sort, + ), + _JobsTab( + statuses: const ['in_progress'], + location: 'at_clinic', + searchQuery: _searchQuery, + sort: _sort, + ), + _JobsTab( + filterExtra: 'status = "pending" || (status = "in_progress" && location = "at_lab")', + searchQuery: _searchQuery, + sort: _sort, + ), + _JobsTab( + statuses: const ['sent'], + searchQuery: _searchQuery, + sort: _sort, + ), + _JobsTab( + statuses: const ['delivered'], + searchQuery: _searchQuery, + sort: _sort, + ), + ], + ), + ), + ], + ), + ); + } +} + +class _JobsTab extends ConsumerStatefulWidget { + const _JobsTab({ + this.statuses, + this.location, + this.filterExtra, + required this.searchQuery, + required this.sort, + }); + + final List? statuses; + final String? location; + final String? filterExtra; + final String searchQuery; + final _JobSort sort; + + @override + ConsumerState<_JobsTab> createState() => _JobsTabState(); +} + +class _JobsTabState extends ConsumerState<_JobsTab> { + final List _jobs = []; + bool _isLoading = false; + bool _hasMore = true; + int _page = 1; + static const _limit = 20; + String? _error; + late UnsubFn _unsub; + + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _load(); + _scrollController.addListener(_onScroll); + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + _unsub = RealtimeService.instance.watch( + 'jobs', + filter: 'clinic_tenant_id="$tenantId"', + onEvent: (_) { if (mounted) _load(); }, + ); + } + + @override + void dispose() { + _unsub(); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200 && + !_isLoading && + _hasMore) { + _loadMore(); + } + } + + Future _load() async { + if (_isLoading) return; + setState(() { + _isLoading = true; + _error = null; + _page = 1; + _jobs.clear(); + _hasMore = true; + }); + await _fetch(); + } + + Future _loadMore() async { + if (_isLoading || !_hasMore) return; + _page++; + await _fetch(); + } + + Future _fetch() async { + setState(() => _isLoading = true); + try { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + final results = await ClinicJobsRepository.instance.listOutbound( + tenantId, + statuses: widget.statuses, + location: widget.location, + filterExtra: widget.filterExtra, + page: _page, + limit: _limit, + ); + setState(() { + _jobs.addAll(results); + _hasMore = results.length == _limit; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + List get _filtered { + var list = _jobs.toList(); + + final q = widget.searchQuery.toLowerCase().trim(); + if (q.isNotEmpty) { + list = list.where((j) { + return j.patientCode.toLowerCase().contains(q) || + (j.labName?.toLowerCase().contains(q) ?? false) || + j.prostheticType.label.toLowerCase().contains(q); + }).toList(); + } + + switch (widget.sort) { + case _JobSort.newestFirst: + list.sort((a, b) => b.dateCreated.compareTo(a.dateCreated)); + case _JobSort.oldestFirst: + list.sort((a, b) => a.dateCreated.compareTo(b.dateCreated)); + case _JobSort.byDueDate: + list.sort((a, b) { + if (a.dueDate == null && b.dueDate == null) return 0; + if (a.dueDate == null) return 1; + if (b.dueDate == null) return -1; + return a.dueDate!.compareTo(b.dueDate!); + }); + case _JobSort.byType: + list.sort( + (a, b) => a.prostheticType.label.compareTo(b.prostheticType.label)); + } + return list; + } + + @override + Widget build(BuildContext context) { + if (_error != null && _jobs.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(16)), + child: const Icon(Icons.wifi_off_rounded, + color: AppColors.cancelled, size: 30), + ), + const SizedBox(height: 16), + Text('Hata: $_error', + style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Tekrar Dene'), + ), + ], + ), + ); + } + + if (_isLoading && _jobs.isEmpty) { + return const Center( + child: CircularProgressIndicator(color: AppColors.accent)); + } + + final filtered = _filtered; + + if (filtered.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(20)), + child: const Icon(Icons.work_off_outlined, + color: AppColors.inProgress, size: 32), + ), + const SizedBox(height: 16), + Text( + widget.searchQuery.isNotEmpty + ? 'Sonuç bulunamadı' + : 'Henüz iş yok', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary), + ), + ], + ), + ); + } + + return RefreshIndicator( + color: AppColors.accent, + onRefresh: _load, + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + itemCount: + filtered.length + (_hasMore && widget.searchQuery.isEmpty ? 1 : 0), + itemBuilder: (context, index) { + if (index == filtered.length) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center( + child: CircularProgressIndicator(color: AppColors.accent)), + ); + } + final job = filtered[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _JobListCard( + job: job, + onTap: () => context.push('/clinic/jobs/${job.id}'), + ), + ); + }, + ), + ); + } +} + +class _JobListCard extends StatelessWidget { + const _JobListCard({required this.job, required this.onTap}); + + final Job job; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final statusColor = _statusColor(job); + final statusBg = _statusBg(job); + final isOverdue = + job.dueDate != null && job.dueDate!.isBefore(DateTime.now()); + + return Semantics( + label: job.patientCode, + button: true, + excludeSemantics: true, + child: Material( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2)) + ]), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: statusBg, + borderRadius: BorderRadius.circular(12)), + child: Icon(Icons.medical_services_outlined, + color: statusColor, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + job.patientCode, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary), + ), + ), + _StatusBadge(status: job.status, location: job.location), + ], + ), + const SizedBox(height: 3), + Text(job.prostheticType.label, + style: const TextStyle( + fontSize: 12, color: AppColors.textSecondary)), + if (job.labName != null) ...[ + const SizedBox(height: 2), + Text( + job.labName!, + style: const TextStyle( + fontSize: 12, color: AppColors.textMuted), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + if (job.dueDate != null) ...[ + const SizedBox(height: 2), + Row( + children: [ + Icon(Icons.calendar_today_outlined, + size: 11, + color: isOverdue + ? AppColors.cancelled + : AppColors.textMuted), + const SizedBox(width: 3), + Text( + _fmt(job.dueDate!), + style: TextStyle( + fontSize: 12, + color: isOverdue + ? AppColors.cancelled + : AppColors.textMuted, + fontWeight: isOverdue + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ], + ), + ], + ], + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right, + color: AppColors.textMuted, size: 20), + ], + ), + ), + ), + ), + ); + } + + String _fmt(DateTime d) => + '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}'; + + Color _statusColor(Job job) { + if (job.status == JobStatus.inProgress && job.location == JobLocation.atClinic) return AppColors.pending; + switch (job.status) { + case JobStatus.pending: return AppColors.pending; + case JobStatus.inProgress: return AppColors.inProgress; + case JobStatus.sent: return AppColors.accent; + case JobStatus.delivered: return AppColors.success; + case JobStatus.cancelled: return AppColors.cancelled; + } + } + + Color _statusBg(Job job) { + if (job.status == JobStatus.inProgress && job.location == JobLocation.atClinic) return AppColors.pendingBg; + switch (job.status) { + case JobStatus.pending: return AppColors.pendingBg; + case JobStatus.inProgress: return AppColors.inProgressBg; + case JobStatus.sent: return AppColors.inProgressBg; + case JobStatus.delivered: return AppColors.successBg; + case JobStatus.cancelled: return AppColors.cancelledBg; + } + } +} + +class _StatusBadge extends StatelessWidget { + const _StatusBadge({required this.status, required this.location}); + + final JobStatus status; + final JobLocation location; + + String get _label { + if (status == JobStatus.inProgress && location == JobLocation.atClinic) return 'Onay Bekliyor'; + if (status == JobStatus.sent) return 'Teslimat Bekliyor'; + return status.label; + } + + Color get _color { + if (status == JobStatus.inProgress && location == JobLocation.atClinic) return AppColors.pending; + switch (status) { + case JobStatus.pending: return AppColors.pending; + case JobStatus.inProgress: return AppColors.inProgress; + case JobStatus.sent: return AppColors.accent; + case JobStatus.delivered: return AppColors.success; + case JobStatus.cancelled: return AppColors.cancelled; + } + } + + Color get _bg { + if (status == JobStatus.inProgress && location == JobLocation.atClinic) return AppColors.pendingBg; + switch (status) { + case JobStatus.pending: return AppColors.pendingBg; + case JobStatus.inProgress: return AppColors.inProgressBg; + case JobStatus.sent: return AppColors.inProgressBg; + case JobStatus.delivered: return AppColors.successBg; + case JobStatus.cancelled: return AppColors.cancelledBg; + } + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: _bg, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _label, + style: TextStyle( + color: _color, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/lib/features/clinic/jobs/new_job_screen.dart b/lib/features/clinic/jobs/new_job_screen.dart new file mode 100644 index 0000000..95bed00 --- /dev/null +++ b/lib/features/clinic/jobs/new_job_screen.dart @@ -0,0 +1,1067 @@ +import 'dart:math'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; + +import '../../../core/api/pocketbase_client.dart'; +import '../../../core/providers/auth_provider.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../models/clinic_discount.dart'; +import '../../../models/job.dart'; +import '../../../models/patient.dart'; +import '../../../models/prosthetic_product.dart'; +import '../../lab/discounts/discount_repository.dart'; +import '../../lab/products/lab_products_repository.dart'; +import 'clinic_jobs_repository.dart'; +import '../patients/clinic_patients_repository.dart'; + +String _mimeFromExt(String ext) => switch (ext) { + 'jpg' || 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'webp' => 'image/webp', + 'pdf' => 'application/pdf', + 'stl' => 'model/stl', + 'obj' => 'model/obj', + 'ply' => 'model/ply', + 'zip' => 'application/zip', + 'mp3' => 'audio/mpeg', + 'mp4' => 'video/mp4', + 'opus' => 'audio/opus', + _ => 'application/octet-stream', + }; + +class NewJobScreen extends ConsumerStatefulWidget { + const NewJobScreen({super.key}); + + @override + ConsumerState createState() => _NewJobScreenState(); +} + +class _NewJobScreenState extends ConsumerState { + final _formKey = GlobalKey(); + + // Form fields + Map? _selectedLab; + Patient? _selectedPatient; + final _patientCodeController = TextEditingController(); + ProstheticType? _selectedProstheticType; + final Set _selectedTeeth = {}; + final _colorController = TextEditingController(); + final _descriptionController = TextEditingController(); + DateTime? _dueDate; + bool _provaRequired = true; + + // State + List> _labs = []; + bool _labsLoading = true; + bool _isSubmitting = false; + String? _labsError; + + // File upload + final List _pendingFiles = []; + + // Patient search + bool _showPatientSearch = false; + final _patientSearchController = TextEditingController(); + List _patientResults = []; + bool _patientSearchLoading = false; + + // Price preview + ProstheticProduct? _labProduct; + double? _effectivePrice; + bool _priceLoading = false; + + @override + void initState() { + super.initState(); + _loadLabs(); + } + + @override + void dispose() { + _patientCodeController.dispose(); + _colorController.dispose(); + _descriptionController.dispose(); + _patientSearchController.dispose(); + super.dispose(); + } + + Future _loadLabs() async { + setState(() { + _labsLoading = true; + _labsError = null; + }); + try { + final tenantId = + ref.read(authProvider).activeTenant!.tenant.id; + final labs = + await ClinicJobsRepository.instance.listApprovedLabs(tenantId); + setState(() { + _labs = labs; + _labsLoading = false; + }); + } catch (e) { + setState(() { + _labsError = e.toString(); + _labsLoading = false; + }); + } + } + + Future _fetchPrice() async { + if (_selectedLab == null || _selectedProstheticType == null) { + setState(() { _labProduct = null; _effectivePrice = null; }); + return; + } + final labId = _selectedLab!['id'] as String; + final ptValue = _selectedProstheticType!.value; + final clinicTenantId = ref.read(authProvider).activeTenant!.tenant.id; + + setState(() => _priceLoading = true); + try { + final products = await LabProductsRepository.instance.listProducts(labId, isActive: true); + ProstheticProduct? product; + try { + product = products.firstWhere((p) => p.prostheticType == ptValue); + } catch (_) { + product = null; + } + if (product == null || product.unitPrice == null) { + setState(() { _labProduct = null; _effectivePrice = null; _priceLoading = false; }); + return; + } + final discounts = await DiscountRepository.instance.listDiscounts(labId); + final applicable = discounts.where((d) => + d.isActive && + (d.appliesToAll || d.clinicTenantId == clinicTenantId) && + (d.appliesToAllTypes || d.prostheticType == ptValue) + ).toList(); + + double price = product.unitPrice!; + for (final d in applicable) { + price = d.discountType == DiscountType.percentage + ? price * (1 - d.discountValue / 100) + : price - d.discountValue; + } + setState(() { + _labProduct = product; + _effectivePrice = price.clamp(0, double.infinity); + _priceLoading = false; + }); + } catch (_) { + setState(() { _labProduct = null; _effectivePrice = null; _priceLoading = false; }); + } + } + + Future _searchPatients(String query) async { + if (query.trim().isEmpty) { + setState(() => _patientResults = []); + return; + } + setState(() => _patientSearchLoading = true); + try { + final tenantId = + ref.read(authProvider).activeTenant!.tenant.id; + final results = await ClinicPatientsRepository.instance + .listPatients(tenantId, search: query, limit: 10); + setState(() { + _patientResults = results; + _patientSearchLoading = false; + }); + } catch (_) { + setState(() => _patientSearchLoading = false); + } + } + + Future _pickDueDate() async { + final pickedDate = await showDatePicker( + context: context, + initialDate: DateTime.now().add(const Duration(days: 7)), + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (pickedDate == null || !mounted) return; + final pickedTime = await showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 17, minute: 0), + ); + if (!mounted) return; + setState(() { + _dueDate = DateTime( + pickedDate.year, pickedDate.month, pickedDate.day, + pickedTime?.hour ?? 17, pickedTime?.minute ?? 0, + ); + }); + } + + String _generateProtocolNo() { + final now = DateTime.now(); + final date = + '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}'; + const chars = '0123456789ABCDEFGHJKLMNPQRSTUVWXYZ'; + final rand = List.generate(4, (_) => chars[Random().nextInt(chars.length)]).join(); + return 'PR-$date-$rand'; + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + if (_selectedLab == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Lütfen bir laboratuvar seçin.')), + ); + return; + } + if (_selectedProstheticType == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Lütfen protez türünü seçin.')), + ); + return; + } + if (_selectedTeeth.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('En az bir diş seçmelisiniz.')), + ); + return; + } + + setState(() => _isSubmitting = true); + try { + final auth = ref.read(authProvider); + final tenantId = auth.activeTenant!.tenant.id; + final rawCode = _patientCodeController.text.trim(); + final protocolNo = rawCode.isNotEmpty ? rawCode : _generateProtocolNo(); + final job = await ClinicJobsRepository.instance.createJob( + clinicTenantId: tenantId, + labTenantId: _selectedLab!['id'] as String, + patientCode: protocolNo, + prostheticId: '', + prostheticType: _selectedProstheticType!, + teeth: _selectedTeeth.map((t) => t.toString()).toList()..sort(), + patientId: _selectedPatient?.id, + color: _colorController.text.trim().isNotEmpty + ? _colorController.text.trim() + : null, + description: _descriptionController.text.trim().isNotEmpty + ? _descriptionController.text.trim() + : null, + dueDate: _dueDate?.toIso8601String(), + provaRequired: _provaRequired, + ); + + // Upload pending files + if (_pendingFiles.isNotEmpty) { + final pb = PocketBaseClient.instance.pb; + final token = pb.authStore.token; + final uploaderId = (pb.authStore.record?.id) ?? (auth.profile?.id ?? ''); + for (final file in _pendingFiles) { + final bytes = file.bytes; + if (bytes == null) continue; + final ext = (file.extension ?? '').toLowerCase(); + final kind = (ext == 'stl' || ext == 'obj' || ext == 'ply') + ? 'scan' + : (ext == 'pdf') ? 'document' : 'image'; + final mimeType = _mimeFromExt(ext); + final req = http.MultipartRequest( + 'POST', + Uri.parse('https://pocket.kovaksoft.com/api/collections/job_files/records'), + ) + ..headers['Authorization'] = 'Bearer $token' + ..fields['job_id'] = job.id + ..fields['clinic_tenant_id'] = job.clinicTenantId + ..fields['lab_tenant_id'] = job.labTenantId + ..fields['uploaded_by'] = uploaderId + ..fields['kind'] = kind + ..fields['name'] = file.name + ..fields['size'] = bytes.length.toString() + ..fields['mime_type'] = mimeType + ..files.add(http.MultipartFile.fromBytes( + 'file', + bytes, + filename: file.name, + )); + final response = await req.send(); + if (response.statusCode < 200 || response.statusCode >= 300) { + final body = await response.stream.bytesToString(); + debugPrint('File upload failed: ${response.statusCode} $body'); + } + } + } + + if (mounted) { + context.go('/clinic/jobs/${job.id}'); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Yeni İş')), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Lab selection + _SectionLabel(label: 'Laboratuvar *'), + if (_labsLoading) + const Center(child: CircularProgressIndicator()) + else if (_labsError != null) + Row( + children: [ + Text('Hata: $_labsError', + style: const TextStyle(color: AppColors.cancelled)), + TextButton( + onPressed: _loadLabs, + child: const Text('Tekrar Dene'), + ), + ], + ) + else + DropdownButtonFormField>( + initialValue: _selectedLab, + decoration: const InputDecoration( + hintText: 'Laboratuvar seçin', + ), + items: _labs + .map( + (lab) => DropdownMenuItem( + value: lab, + child: Text(lab['company_name'] as String? ?? ''), + ), + ) + .toList(), + onChanged: (val) { + setState(() => _selectedLab = val); + _fetchPrice(); + }, + validator: (val) => + val == null ? 'Laboratuvar seçimi zorunludur' : null, + ), + const SizedBox(height: 16), + + // Protocol number + _SectionLabel(label: 'Protokol No (İsteğe Bağlı)'), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _patientCodeController, + decoration: InputDecoration( + hintText: 'Boş bırakılırsa otomatik üretilir', + suffixIcon: _selectedPatient != null + ? const Icon(Icons.person, + color: AppColors.success) + : null, + ), + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () { + setState(() => _showPatientSearch = !_showPatientSearch); + }, + icon: const Icon(Icons.search), + label: const Text('Ara'), + ), + ], + ), + + // Patient search panel + if (_showPatientSearch) ...[ + const SizedBox(height: 8), + TextField( + controller: _patientSearchController, + decoration: const InputDecoration( + hintText: 'Ad, soyad veya kod ile arayın...', + prefixIcon: Icon(Icons.search), + ), + onChanged: _searchPatients, + ), + if (_patientSearchLoading) + const Padding( + padding: EdgeInsets.all(8), + child: Center(child: CircularProgressIndicator()), + ), + ..._patientResults.map( + (p) => ListTile( + dense: true, + leading: const Icon(Icons.person_outline), + title: Text(p.displayName), + subtitle: Text(p.patientCode), + onTap: () { + setState(() { + _selectedPatient = p; + _patientCodeController.text = p.patientCode; + _showPatientSearch = false; + _patientSearchController.clear(); + _patientResults.clear(); + }); + }, + ), + ), + ], + const SizedBox(height: 16), + + // Prosthetic type + _SectionLabel(label: 'Protez Türü *'), + DropdownButtonFormField( + initialValue: _selectedProstheticType, + decoration: const InputDecoration( + hintText: 'Protez türü seçin', + ), + items: ProstheticType.values + .map( + (pt) => DropdownMenuItem( + value: pt, + child: Text(pt.label), + ), + ) + .toList(), + onChanged: (val) { + setState(() => _selectedProstheticType = val); + _fetchPrice(); + }, + validator: (val) => + val == null ? 'Protez türü zorunludur' : null, + ), + // Price preview + if (_priceLoading) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Row(children: [ + SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 1.5)), + SizedBox(width: 8), + Text('Fiyat yükleniyor...', style: TextStyle(fontSize: 12, color: AppColors.textMuted)), + ]), + ) + else if (_labProduct != null && _effectivePrice != null) ...[ + const SizedBox(height: 8), + _PricePreviewChip( + product: _labProduct!, + effectivePrice: _effectivePrice!, + ), + ], + const SizedBox(height: 12), + + // Prova flag + _ProvaToggle( + value: _provaRequired, + prostheticType: _selectedProstheticType, + onChanged: (v) => setState(() => _provaRequired = v), + ), + const SizedBox(height: 16), + + // Teeth selection + _SectionLabel( + label: 'Dişler * (${_selectedTeeth.length} seçili)', + ), + const SizedBox(height: 6), + // Bulk select row + _TeethBulkBar( + selectedTeeth: _selectedTeeth, + onSelectAll: () => setState(() { + _selectedTeeth.addAll([ + for (int i = 11; i <= 18; i++) i, + for (int i = 21; i <= 28; i++) i, + for (int i = 31; i <= 38; i++) i, + for (int i = 41; i <= 48; i++) i, + ]); + }), + onSelectUpper: () => setState(() { + final upper = {...[for (int i = 11; i <= 18; i++) i], ...[for (int i = 21; i <= 28; i++) i]}; + if (upper.every(_selectedTeeth.contains)) { + _selectedTeeth.removeAll(upper); + } else { + _selectedTeeth.addAll(upper); + } + }), + onSelectLower: () => setState(() { + final lower = {...[for (int i = 31; i <= 38; i++) i], ...[for (int i = 41; i <= 48; i++) i]}; + if (lower.every(_selectedTeeth.contains)) { + _selectedTeeth.removeAll(lower); + } else { + _selectedTeeth.addAll(lower); + } + }), + onClear: () => setState(() => _selectedTeeth.clear()), + ), + const SizedBox(height: 8), + _TeethGrid( + selectedTeeth: _selectedTeeth, + onToggle: (t) { + setState(() { + if (_selectedTeeth.contains(t)) { + _selectedTeeth.remove(t); + } else { + _selectedTeeth.add(t); + } + }); + }, + ), + const SizedBox(height: 16), + + // Color (optional) + _SectionLabel(label: 'Renk (İsteğe Bağlı)'), + TextFormField( + controller: _colorController, + decoration: const InputDecoration( + hintText: 'Ör: A2, B3', + ), + ), + const SizedBox(height: 16), + + // Description (optional) + _SectionLabel(label: 'Açıklama (İsteğe Bağlı)'), + TextFormField( + controller: _descriptionController, + decoration: const InputDecoration( + hintText: 'Ek notlar...', + ), + minLines: 2, + maxLines: 4, + ), + const SizedBox(height: 16), + + // Due date (optional) + _SectionLabel(label: 'Son Tarih (İsteğe Bağlı)'), + InkWell( + onTap: _pickDueDate, + child: InputDecorator( + decoration: const InputDecoration( + suffixIcon: Icon(Icons.calendar_today), + ), + child: Text( + _dueDate != null + ? '${_dueDate!.day.toString().padLeft(2, '0')}.${_dueDate!.month.toString().padLeft(2, '0')}.${_dueDate!.year} ${_dueDate!.hour.toString().padLeft(2, '0')}:${_dueDate!.minute.toString().padLeft(2, '0')}' + : 'Tarih ve saat seçin', + style: _dueDate != null + ? null + : TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const SizedBox(height: 16), + + // File attachments (optional) + _SectionLabel(label: 'Dosya Ekle (İsteğe Bağlı)'), + _FilePicker( + files: _pendingFiles, + onAdd: () async { + final result = await FilePicker.platform.pickFiles( + allowMultiple: true, + withData: true, + ); + if (result != null) { + setState(() => _pendingFiles.addAll(result.files)); + } + }, + onRemove: (i) => setState(() => _pendingFiles.removeAt(i)), + ), + const SizedBox(height: 24), + + // Submit button + if (_isSubmitting) + const Center(child: CircularProgressIndicator()) + else + FilledButton.icon( + onPressed: _submit, + icon: const Icon(Icons.check), + label: const Text('İş Oluştur'), + style: FilledButton.styleFrom( + minimumSize: const Size(double.infinity, 52), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ); + } +} + +class _TeethBulkBar extends StatelessWidget { + const _TeethBulkBar({ + required this.selectedTeeth, + required this.onSelectAll, + required this.onSelectUpper, + required this.onSelectLower, + required this.onClear, + }); + + final Set selectedTeeth; + final VoidCallback onSelectAll; + final VoidCallback onSelectUpper; + final VoidCallback onSelectLower; + final VoidCallback onClear; + + bool _allUpperSelected() { + final upper = [for (int i = 11; i <= 18; i++) i, for (int i = 21; i <= 28; i++) i]; + return upper.every(selectedTeeth.contains); + } + + bool _allLowerSelected() { + final lower = [for (int i = 31; i <= 38; i++) i, for (int i = 41; i <= 48; i++) i]; + return lower.every(selectedTeeth.contains); + } + + @override + Widget build(BuildContext context) { + final allSelected = _allUpperSelected() && _allLowerSelected(); + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _BulkChip( + label: 'Tüm Dişler', + active: allSelected, + onTap: allSelected ? onClear : onSelectAll, + icon: Icons.select_all_rounded, + ), + const SizedBox(width: 6), + _BulkChip( + label: 'Üst Çene', + active: _allUpperSelected(), + onTap: onSelectUpper, + icon: Icons.arrow_upward_rounded, + ), + const SizedBox(width: 6), + _BulkChip( + label: 'Alt Çene', + active: _allLowerSelected(), + onTap: onSelectLower, + icon: Icons.arrow_downward_rounded, + ), + const SizedBox(width: 6), + if (selectedTeeth.isNotEmpty) + _BulkChip( + label: 'Temizle', + active: false, + onTap: onClear, + icon: Icons.clear_rounded, + destructive: true, + ), + ], + ), + ); + } +} + +class _BulkChip extends StatelessWidget { + const _BulkChip({ + required this.label, + required this.active, + required this.onTap, + required this.icon, + this.destructive = false, + }); + + final String label; + final bool active; + final VoidCallback onTap; + final IconData icon; + final bool destructive; + + @override + Widget build(BuildContext context) { + final color = destructive + ? AppColors.cancelled + : active + ? AppColors.accent + : AppColors.textSecondary; + final bg = destructive + ? AppColors.cancelledBg + : active + ? AppColors.inProgressBg + : AppColors.surfaceVariant; + + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: active && !destructive ? AppColors.accent : AppColors.border, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color), + ), + ], + ), + ), + ); + } +} + +class _TeethGrid extends StatelessWidget { + const _TeethGrid({ + required this.selectedTeeth, + required this.onToggle, + }); + + final Set selectedTeeth; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + // Upper jaw: 18-11, 21-28 + // Lower jaw: 48-41, 31-38 + final upperRight = List.generate(8, (i) => 18 - i); // 18..11 + final upperLeft = List.generate(8, (i) => 21 + i); // 21..28 + final lowerRight = List.generate(8, (i) => 48 - i); // 48..41 + final lowerLeft = List.generate(8, (i) => 31 + i); // 31..38 + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Upper jaw label + Text('Üst Çene', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + )), + const SizedBox(height: 4), + Row( + children: [ + ...upperRight.map((t) => _ToothButton( + tooth: t, + selected: selectedTeeth.contains(t), + onTap: () => onToggle(t), + )), + const VerticalDivider(width: 8), + ...upperLeft.map((t) => _ToothButton( + tooth: t, + selected: selectedTeeth.contains(t), + onTap: () => onToggle(t), + )), + ], + ), + const SizedBox(height: 8), + // Lower jaw + Text('Alt Çene', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + )), + const SizedBox(height: 4), + Row( + children: [ + ...lowerRight.map((t) => _ToothButton( + tooth: t, + selected: selectedTeeth.contains(t), + onTap: () => onToggle(t), + )), + const VerticalDivider(width: 8), + ...lowerLeft.map((t) => _ToothButton( + tooth: t, + selected: selectedTeeth.contains(t), + onTap: () => onToggle(t), + )), + ], + ), + ], + ); + } +} + +class _ToothButton extends StatelessWidget { + const _ToothButton({ + required this.tooth, + required this.selected, + required this.onTap, + }); + + final int tooth; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.all(1.5), + height: 36, + decoration: BoxDecoration( + color: selected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Center( + child: Text( + '$tooth', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: selected + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ); + } +} + +class _FilePicker extends StatelessWidget { + const _FilePicker({ + required this.files, + required this.onAdd, + required this.onRemove, + }); + + final List files; + final VoidCallback onAdd; + final void Function(int index) onRemove; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (files.isNotEmpty) ...[ + ...files.asMap().entries.map((e) { + final i = e.key; + final f = e.value; + return Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + const Icon(Icons.attach_file, size: 16, color: AppColors.textSecondary), + const SizedBox(width: 8), + Expanded( + child: Text( + f.name, + style: const TextStyle( + fontSize: 13, color: AppColors.textPrimary), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + _formatSize(f.size), + style: const TextStyle( + fontSize: 11, color: AppColors.textMuted), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () => onRemove(i), + child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary), + ), + ], + ), + ); + }), + const SizedBox(height: 4), + ], + OutlinedButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.upload_file_outlined, size: 18), + label: const Text('Dosya Seç'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.accent, + side: const BorderSide(color: AppColors.accent), + ), + ), + ], + ); + } + + String _formatSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } +} + +class _PricePreviewChip extends StatelessWidget { + const _PricePreviewChip({required this.product, required this.effectivePrice}); + + final ProstheticProduct product; + final double effectivePrice; + + @override + Widget build(BuildContext context) { + final currency = product.currency ?? 'TRY'; + final unitPrice = product.unitPrice!; + final hasDiscount = (effectivePrice - unitPrice).abs() > 0.01; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppColors.successBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.success.withValues(alpha: 0.25)), + ), + child: Row( + children: [ + const Icon(Icons.sell_outlined, size: 16, color: AppColors.success), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${product.name} — ${effectivePrice.toStringAsFixed(2)} $currency', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.success), + ), + if (hasDiscount) + Text( + 'Liste: ${unitPrice.toStringAsFixed(2)} $currency · İndirim uygulandı', + style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)), + ) + else + Text( + 'Liste fiyatı · İndirim yok', + style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + final String label; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _ProvaToggle extends StatelessWidget { + const _ProvaToggle({ + required this.value, + required this.onChanged, + this.prostheticType, + }); + + final bool value; + final ValueChanged onChanged; + final ProstheticType? prostheticType; + + @override + Widget build(BuildContext context) { + final steps = prostheticType != null + ? jobStepTemplate(prostheticType!, value) + : []; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: value ? AppColors.inProgressBg : AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: value ? AppColors.inProgress.withValues(alpha: 0.3) : AppColors.border, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + value ? Icons.swap_horiz_rounded : Icons.straighten_rounded, + size: 20, + color: value ? AppColors.inProgress : AppColors.textSecondary, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value ? 'Provalı İş' : 'Provasız İş', + style: TextStyle( + fontWeight: FontWeight.w700, + color: value ? AppColors.inProgress : AppColors.textPrimary, + fontSize: 14, + ), + ), + Text( + value + ? 'Lab her adımda klinik onayı bekler' + : 'Lab doğrudan üretip teslime gönderir', + style: const TextStyle( + fontSize: 12, color: AppColors.textSecondary), + ), + ], + ), + ), + Switch( + value: value, + onChanged: onChanged, + activeColor: AppColors.inProgress, + ), + ], + ), + if (steps.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 6, + children: steps.map((s) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: AppColors.border), + ), + child: Text( + s.label, + style: const TextStyle( + fontSize: 11, color: AppColors.textSecondary), + ), + )).toList(), + ), + ], + ], + ), + ); + } +} diff --git a/lib/features/clinic/patients/clinic_patient_detail_screen.dart b/lib/features/clinic/patients/clinic_patient_detail_screen.dart new file mode 100644 index 0000000..c95160a --- /dev/null +++ b/lib/features/clinic/patients/clinic_patient_detail_screen.dart @@ -0,0 +1,717 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/theme/app_theme.dart'; +import '../../../models/job.dart'; +import '../../../models/patient.dart'; +import '../jobs/clinic_jobs_repository.dart'; +import 'clinic_patients_repository.dart'; + +class ClinicPatientDetailScreen extends ConsumerStatefulWidget { + const ClinicPatientDetailScreen({super.key, required this.patientId}); + final String patientId; + + @override + ConsumerState createState() => + _ClinicPatientDetailScreenState(); +} + +class _ClinicPatientDetailScreenState + extends ConsumerState { + late Future _future; + late Future> _jobsFuture; + bool _editMode = false; + bool _isSaving = false; + + final _patientCodeController = TextEditingController(); + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + final _phoneController = TextEditingController(); + final _notesController = TextEditingController(); + String? _birthDate; + + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _patientCodeController.dispose(); + _firstNameController.dispose(); + _lastNameController.dispose(); + _phoneController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + void _load() { + setState(() { + _future = ClinicPatientsRepository.instance + .getPatient(widget.patientId) + .then((p) { + _populateControllers(p); + return p; + }); + _jobsFuture = ClinicJobsRepository.instance + .listJobsByPatient(widget.patientId); + }); + } + + void _populateControllers(Patient p) { + _patientCodeController.text = p.patientCode; + _firstNameController.text = p.firstName ?? ''; + _lastNameController.text = p.lastName ?? ''; + _phoneController.text = p.phone ?? ''; + _notesController.text = p.notes ?? ''; + _birthDate = p.birthDate; + } + + Future _pickBirthDate() async { + DateTime initial = DateTime(1990); + if (_birthDate != null) { + try { + initial = DateTime.parse(_birthDate!); + } catch (_) {} + } + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + if (picked != null) { + setState(() { + _birthDate = picked.toIso8601String().split('T').first; + }); + } + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _isSaving = true); + try { + final patch = { + 'patient_code': _patientCodeController.text.trim(), + 'first_name': _firstNameController.text.trim().isNotEmpty + ? _firstNameController.text.trim() + : null, + 'last_name': _lastNameController.text.trim().isNotEmpty + ? _lastNameController.text.trim() + : null, + 'phone': _phoneController.text.trim().isNotEmpty + ? _phoneController.text.trim() + : null, + 'birth_date': _birthDate, + 'notes': _notesController.text.trim().isNotEmpty + ? _notesController.text.trim() + : null, + }; + final updated = await ClinicPatientsRepository.instance + .updatePatient(widget.patientId, patch); + _populateControllers(updated); + setState(() { + _editMode = false; + _future = Future.value(updated); + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasta bilgileri güncellendi.')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + Future _delete() async { + // Check for existing jobs first + List? jobs; + try { + jobs = await ClinicJobsRepository.instance + .listJobsByPatient(widget.patientId, limit: 1); + } catch (_) { + jobs = null; + } + + if (!mounted) return; + final hasJobs = (jobs?.isNotEmpty) ?? false; + + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Hastayı Sil'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Bu hastayı silmek istediğinizden emin misiniz?'), + if (hasJobs) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + children: [ + Icon(Icons.warning_amber_rounded, + color: AppColors.cancelled, size: 18), + SizedBox(width: 8), + Expanded( + child: Text( + 'Bu hastaya ait işler bulunmaktadır. Hasta silinirse bu bağlantı kopar.', + style: TextStyle( + fontSize: 13, color: AppColors.cancelled), + ), + ), + ], + ), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Vazgeç')), + FilledButton( + onPressed: () => Navigator.pop(context, true), + style: + FilledButton.styleFrom(backgroundColor: AppColors.cancelled), + child: const Text('Sil'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + try { + await ClinicPatientsRepository.instance.deletePatient(widget.patientId); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasta silindi.')), + ); + Navigator.of(context).pop(true); // signal that a delete happened + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Silme hatası: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Hasta Detayı'), + actions: [ + if (!_editMode) ...[ + IconButton( + icon: const Icon(Icons.edit_outlined), + tooltip: 'Düzenle', + onPressed: () => setState(() => _editMode = true), + ), + IconButton( + icon: const Icon(Icons.delete_outline, color: AppColors.cancelled), + tooltip: 'Sil', + onPressed: _delete, + ), + ] else ...[ + if (_isSaving) + const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else ...[ + TextButton( + onPressed: () { + setState(() => _editMode = false); + _future.then(_populateControllers); + }, + child: const Text('İptal'), + ), + FilledButton( + onPressed: _save, + child: const Text('Kaydet'), + ), + const SizedBox(width: 8), + ], + ], + ], + ), + body: FutureBuilder( + future: _future, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Hata: ${snap.error}'), + const SizedBox(height: 12), + FilledButton( + onPressed: _load, + child: const Text('Tekrar Dene'), + ), + ], + ), + ); + } + + if (_editMode) { + return _EditForm( + formKey: _formKey, + patientCodeController: _patientCodeController, + firstNameController: _firstNameController, + lastNameController: _lastNameController, + phoneController: _phoneController, + notesController: _notesController, + birthDate: _birthDate, + onPickBirthDate: _pickBirthDate, + ); + } + + final patient = snap.data!; + return _PatientView(patient: patient, jobsFuture: _jobsFuture); + }, + ), + ); + } +} + +// ── View ─────────────────────────────────────────────────────────────────── + +class _PatientView extends StatelessWidget { + const _PatientView({required this.patient, required this.jobsFuture}); + final Patient patient; + final Future> jobsFuture; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // Avatar + name header + Center( + child: Column( + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(20)), + child: Center( + child: Text( + patient.displayName.isNotEmpty + ? patient.displayName[0].toUpperCase() + : '?', + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: AppColors.inProgress), + ), + ), + ), + const SizedBox(height: 12), + Text( + patient.displayName, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + Text( + patient.patientCode, + style: const TextStyle( + fontSize: 13, color: AppColors.textSecondary), + ), + ], + ), + ), + const SizedBox(height: 24), + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DetailRow(label: 'Hasta Kodu', value: patient.patientCode), + if (patient.firstName != null) + _DetailRow(label: 'Ad', value: patient.firstName!), + if (patient.lastName != null) + _DetailRow(label: 'Soyad', value: patient.lastName!), + if (patient.phone != null && patient.phone!.isNotEmpty) + _DetailRow(label: 'Telefon', value: patient.phone!), + if (patient.birthDate != null && patient.birthDate!.isNotEmpty) + _DetailRow( + label: 'Doğum Tarihi', value: patient.birthDate!), + if (patient.notes != null && patient.notes!.isNotEmpty) + _DetailRow(label: 'Notlar', value: patient.notes!), + ], + ), + ), + const SizedBox(height: 24), + + // Job history + _JobHistory(jobsFuture: jobsFuture), + const SizedBox(height: 24), + ], + ); + } +} + +// ── Job history ──────────────────────────────────────────────────────────── + +class _JobHistory extends StatelessWidget { + const _JobHistory({required this.jobsFuture}); + final Future> jobsFuture; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'İŞ GEÇMİŞİ', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + FutureBuilder>( + future: jobsFuture, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return Text('Yüklenemedi: ${snap.error}', + style: + const TextStyle(color: AppColors.textSecondary)); + } + final jobs = snap.data ?? []; + if (jobs.isEmpty) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + ), + child: const Center( + child: Text( + 'Henüz iş kaydı yok.', + style: TextStyle(color: AppColors.textSecondary), + ), + ), + ); + } + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + ), + child: Column( + children: jobs.asMap().entries.map((entry) { + final i = entry.key; + final job = entry.value; + final isLast = i == jobs.length - 1; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 12), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: _statusBg(job.status), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + _statusIcon(job.status), + color: _statusColor(job.status), + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + job.patientCode, + style: const TextStyle( + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + fontSize: 14, + ), + ), + Text( + '${job.prostheticType.label} · ${_statusLabel(job.status)}', + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Text( + _formatDate(job.dateCreated), + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + if (!isLast) + const Divider( + height: 1, + indent: 68, + color: AppColors.border), + ], + ); + }).toList(), + ), + ); + }, + ), + ], + ); + } + + static Color _statusBg(JobStatus s) => switch (s) { + JobStatus.delivered => AppColors.successBg, + JobStatus.cancelled => AppColors.cancelledBg, + JobStatus.inProgress => AppColors.inProgressBg, + _ => AppColors.pendingBg, + }; + + static Color _statusColor(JobStatus s) => switch (s) { + JobStatus.delivered => AppColors.success, + JobStatus.cancelled => AppColors.cancelled, + JobStatus.inProgress => AppColors.inProgress, + _ => AppColors.pending, + }; + + static IconData _statusIcon(JobStatus s) => switch (s) { + JobStatus.delivered => Icons.check_circle_outline, + JobStatus.cancelled => Icons.cancel_outlined, + JobStatus.inProgress => Icons.autorenew, + _ => Icons.hourglass_empty_outlined, + }; + + static String _statusLabel(JobStatus s) => switch (s) { + JobStatus.pending => 'Bekliyor', + JobStatus.inProgress => 'Üretimde', + JobStatus.sent => 'Gönderildi', + JobStatus.delivered => 'Teslim Edildi', + JobStatus.cancelled => 'İptal', + }; + + static String _formatDate(DateTime d) { + return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}'; + } +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: const TextStyle( + fontSize: 13, color: AppColors.textSecondary), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary), + ), + ), + ], + ), + ); + } +} + +// ── Edit form ────────────────────────────────────────────────────────────── + +class _EditForm extends StatelessWidget { + const _EditForm({ + required this.formKey, + required this.patientCodeController, + required this.firstNameController, + required this.lastNameController, + required this.phoneController, + required this.notesController, + required this.birthDate, + required this.onPickBirthDate, + }); + + final GlobalKey formKey; + final TextEditingController patientCodeController; + final TextEditingController firstNameController; + final TextEditingController lastNameController; + final TextEditingController phoneController; + final TextEditingController notesController; + final String? birthDate; + final VoidCallback onPickBirthDate; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: patientCodeController, + decoration: const InputDecoration( + labelText: 'Hasta Kodu *', + border: OutlineInputBorder(), + ), + validator: (val) => + (val == null || val.trim().isEmpty) + ? 'Hasta kodu zorunludur' + : null, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextFormField( + controller: firstNameController, + decoration: const InputDecoration( + labelText: 'Ad', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: lastNameController, + decoration: const InputDecoration( + labelText: 'Soyad', + border: OutlineInputBorder(), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + TextFormField( + controller: phoneController, + decoration: const InputDecoration( + labelText: 'Telefon', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 12), + InkWell( + onTap: onPickBirthDate, + child: InputDecorator( + decoration: const InputDecoration( + labelText: 'Doğum Tarihi', + border: OutlineInputBorder(), + suffixIcon: Icon(Icons.calendar_today), + ), + child: Text( + birthDate ?? 'Tarih seçin', + style: birthDate != null + ? null + : TextStyle( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: notesController, + decoration: const InputDecoration( + labelText: 'Notlar', + border: OutlineInputBorder(), + ), + minLines: 3, + maxLines: 5, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/clinic/patients/clinic_patients_repository.dart b/lib/features/clinic/patients/clinic_patients_repository.dart new file mode 100644 index 0000000..92a204e --- /dev/null +++ b/lib/features/clinic/patients/clinic_patients_repository.dart @@ -0,0 +1,67 @@ +import 'package:pocketbase/pocketbase.dart'; +import '../../../core/api/pocketbase_client.dart'; +import '../../../models/patient.dart'; + +class ClinicPatientsRepository { + ClinicPatientsRepository._(); + static final instance = ClinicPatientsRepository._(); + + PocketBase get _pb => PocketBaseClient.instance.pb; + + Future> listPatients( + String tenantId, { + String? search, + int page = 1, + int limit = 30, + }) async { + final filterParts = ['tenant_id = "$tenantId"']; + if (search != null && search.isNotEmpty) { + filterParts.add( + '(patient_code ~ "$search" || first_name ~ "$search" || last_name ~ "$search")', + ); + } + + final result = await _pb.collection('patients').getList( + page: page, + perPage: limit, + filter: filterParts.join(' && '), + ); + return (result.items.map((r) => Patient.fromJson(r.toJson())).toList() + ..sort((a, b) => a.patientCode.compareTo(b.patientCode))); + } + + Future getPatient(String patientId) async { + final record = await _pb.collection('patients').getOne(patientId); + return Patient.fromJson(record.toJson()); + } + + Future createPatient({ + required String tenantId, + required String patientCode, + String? firstName, + String? lastName, + String? birthDate, + String? phone, + String? notes, + }) async { + final record = await _pb.collection('patients').create(body: { + 'tenant_id': tenantId, + 'patient_code': patientCode, + if (firstName != null) 'first_name': firstName, + if (lastName != null) 'last_name': lastName, + if (birthDate != null) 'birth_date': birthDate, + if (phone != null) 'phone': phone, + if (notes != null) 'notes': notes, + }); + return Patient.fromJson(record.toJson()); + } + + Future updatePatient(String patientId, Map patch) async { + final record = await _pb.collection('patients').update(patientId, body: patch); + return Patient.fromJson(record.toJson()); + } + + Future deletePatient(String patientId) async { + await _pb.collection('patients').delete(patientId); + } +} diff --git a/lib/features/clinic/patients/clinic_patients_screen.dart b/lib/features/clinic/patients/clinic_patients_screen.dart new file mode 100644 index 0000000..a92615e --- /dev/null +++ b/lib/features/clinic/patients/clinic_patients_screen.dart @@ -0,0 +1,575 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/providers/auth_provider.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/gradient_app_bar.dart'; +import '../../../models/patient.dart'; +import 'clinic_patients_repository.dart'; + +enum _PatientSort { nameAZ, nameZA, byCode } + +const _kSortLabels = [ + 'Ada Göre (A → Z)', + 'Ada Göre (Z → A)', + 'Hasta Koduna Göre', +]; + +void _showAdaptive(BuildContext context, Widget content) { + final isDesktop = + MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + if (isDesktop) { + showDialog( + context: context, + builder: (_) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: content, + ), + ), + ); + } else { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => content, + ); + } +} + +class ClinicPatientsScreen extends ConsumerStatefulWidget { + const ClinicPatientsScreen({super.key}); + + @override + ConsumerState createState() => + _ClinicPatientsScreenState(); +} + +class _ClinicPatientsScreenState extends ConsumerState { + late Future> _future; + final _searchController = TextEditingController(); + String _searchQuery = ''; + _PatientSort _sort = _PatientSort.nameAZ; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _load([String? search]) { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + setState(() { + _future = ClinicPatientsRepository.instance.listPatients( + tenantId, + search: search?.trim().isNotEmpty == true ? search : null, + limit: 100, + ); + }); + } + + void _onSearchChanged(String value) { + setState(() => _searchQuery = value); + _load(value); + } + + Future _showSortOptions() async { + final result = await showSortSheet( + context, + title: 'Sıralama', + options: _kSortLabels, + current: _sort.index, + ); + if (result != null) { + setState(() => _sort = _PatientSort.values[result]); + } + } + + List _sorted(List patients) { + final list = List.from(patients); + switch (_sort) { + case _PatientSort.nameAZ: + list.sort((a, b) => a.displayName.compareTo(b.displayName)); + case _PatientSort.nameZA: + list.sort((a, b) => b.displayName.compareTo(a.displayName)); + case _PatientSort.byCode: + list.sort((a, b) => a.patientCode.compareTo(b.patientCode)); + } + return list; + } + + void _showNewPatientSheet() { + _showAdaptive( + context, + _NewPatientSheet( + onCreated: () { + Navigator.of(context).pop(); + _load(_searchQuery); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasta oluşturuldu.')), + ); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + final isSortActive = _sort != _PatientSort.nameAZ; + + return Scaffold( + backgroundColor: AppColors.background, + appBar: GradientAppBar( + title: 'Hastalar', + category: 'KLİNİK', + searchController: _searchController, + onSearchChanged: _onSearchChanged, + searchHint: 'Ad, soyad veya kod ile arayın...', + actions: [ + IconButton( + onPressed: _showSortOptions, + tooltip: 'Sırala', + icon: Badge( + isLabelVisible: isSortActive, + smallSize: 8, + backgroundColor: AppColors.accent, + child: const Icon(Icons.sort_rounded), + ), + ), + IconButton( + onPressed: _showNewPatientSheet, + tooltip: 'Yeni Hasta', + icon: const Icon(Icons.person_add_outlined), + ), + ], + ), + body: Column( + children: [ + Expanded( + child: RefreshIndicator( + color: AppColors.accent, + onRefresh: () async => _load(_searchQuery), + child: FutureBuilder>( + future: _future, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator( + color: AppColors.accent)); + } + if (snap.hasError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(16)), + child: const Icon(Icons.wifi_off_rounded, + color: AppColors.cancelled, size: 30), + ), + const SizedBox(height: 16), + Text('Hata: ${snap.error}', + style: const TextStyle( + color: AppColors.textSecondary)), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: () => _load(_searchQuery), + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Tekrar Dene'), + ), + ], + ), + ); + } + final patients = _sorted(snap.data!); + if (patients.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(20)), + child: const Icon(Icons.people_outline, + color: AppColors.inProgress, size: 32), + ), + const SizedBox(height: 16), + Text( + _searchQuery.isNotEmpty + ? 'Sonuç bulunamadı' + : 'Henüz hasta yok', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary), + ), + if (_searchQuery.isEmpty) ...[ + const SizedBox(height: 8), + const Text( + 'Yeni hasta eklemek için + düğmesine dokunun', + style: TextStyle( + fontSize: 13, + color: AppColors.textSecondary), + ), + ], + ], + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + itemCount: patients.length, + itemBuilder: (context, index) { + final patient = patients[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _PatientCard( + patient: patient, + onTap: () => context + .push('/clinic/patients/${patient.id}'), + ), + ); + }, + ); + }, + ), + ), + ), + ], + ), + ); + } +} + +class _PatientCard extends StatelessWidget { + const _PatientCard({required this.patient, required this.onTap}); + + final Patient patient; + final VoidCallback onTap; + + String get _initials { + final name = patient.displayName; + if (name.isEmpty) return '?'; + final parts = name.trim().split(' '); + if (parts.length >= 2) { + return '${parts.first[0]}${parts.last[0]}'.toUpperCase(); + } + return name[0].toUpperCase(); + } + + @override + Widget build(BuildContext context) { + return Material( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2)) + ]), + child: Row( + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF1E3A5F), Color(0xFF0369A1)], + ), + borderRadius: BorderRadius.circular(13), + ), + child: Center( + child: Text( + _initials, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + patient.displayName.isNotEmpty + ? patient.displayName + : patient.patientCode, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary), + ), + const SizedBox(height: 2), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + patient.patientCode, + style: const TextStyle( + fontSize: 11, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ), + if (patient.phone != null && + patient.phone!.isNotEmpty) ...[ + const SizedBox(width: 8), + const Icon(Icons.phone_outlined, + size: 11, color: AppColors.textMuted), + const SizedBox(width: 3), + Text( + patient.phone!, + style: const TextStyle( + fontSize: 11, color: AppColors.textMuted), + ), + ], + ], + ), + ], + ), + ), + const Icon(Icons.chevron_right, + color: AppColors.textMuted, size: 20), + ], + ), + ), + ), + ); + } +} + +// ── New Patient Sheet ───────────────────────────────────────────────────────── + +class _NewPatientSheet extends ConsumerStatefulWidget { + const _NewPatientSheet({required this.onCreated}); + + final VoidCallback onCreated; + + @override + ConsumerState<_NewPatientSheet> createState() => _NewPatientSheetState(); +} + +class _NewPatientSheetState extends ConsumerState<_NewPatientSheet> { + final _formKey = GlobalKey(); + final _patientCodeController = TextEditingController(); + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + final _phoneController = TextEditingController(); + final _notesController = TextEditingController(); + String? _birthDate; + bool _isSubmitting = false; + + @override + void dispose() { + _patientCodeController.dispose(); + _firstNameController.dispose(); + _lastNameController.dispose(); + _phoneController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + Future _pickBirthDate() async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime(1990), + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + if (picked != null) { + setState(() { + _birthDate = picked.toIso8601String().split('T').first; + }); + } + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _isSubmitting = true); + try { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + await ClinicPatientsRepository.instance.createPatient( + tenantId: tenantId, + patientCode: _patientCodeController.text.trim(), + firstName: _firstNameController.text.trim().isNotEmpty + ? _firstNameController.text.trim() + : null, + lastName: _lastNameController.text.trim().isNotEmpty + ? _lastNameController.text.trim() + : null, + phone: _phoneController.text.trim().isNotEmpty + ? _phoneController.text.trim() + : null, + birthDate: _birthDate, + notes: _notesController.text.trim().isNotEmpty + ? _notesController.text.trim() + : null, + ); + widget.onCreated(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + } + + @override + Widget build(BuildContext context) { + final isDesktop = + MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.vertical( + top: isDesktop ? Radius.zero : const Radius.circular(20), + ), + ), + padding: EdgeInsets.only( + bottom: isDesktop ? 0 : MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Yeni Hasta', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _patientCodeController, + decoration: const InputDecoration( + labelText: 'Hasta Kodu *', + hintText: 'Ör: P-001', + ), + validator: (val) => + (val == null || val.trim().isEmpty) + ? 'Hasta kodu zorunludur' + : null, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _firstNameController, + decoration: const InputDecoration(labelText: 'Ad'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _lastNameController, + decoration: const InputDecoration(labelText: 'Soyad'), + ), + ), + ], + ), + const SizedBox(height: 12), + TextFormField( + controller: _phoneController, + decoration: const InputDecoration(labelText: 'Telefon'), + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 12), + InkWell( + onTap: _pickBirthDate, + child: InputDecorator( + decoration: const InputDecoration( + labelText: 'Doğum Tarihi', + suffixIcon: Icon(Icons.calendar_today), + ), + child: Text( + _birthDate ?? 'Tarih seçin', + style: _birthDate != null + ? null + : const TextStyle(color: AppColors.textMuted), + ), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _notesController, + decoration: const InputDecoration(labelText: 'Notlar'), + minLines: 2, + maxLines: 3, + ), + const SizedBox(height: 20), + if (_isSubmitting) + const Center( + child: CircularProgressIndicator(color: AppColors.accent)) + else + FilledButton( + onPressed: _submit, + style: FilledButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + ), + child: const Text('Hasta Oluştur'), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/clinic/settings/clinic_settings_screen.dart b/lib/features/clinic/settings/clinic_settings_screen.dart new file mode 100644 index 0000000..ca532dd --- /dev/null +++ b/lib/features/clinic/settings/clinic_settings_screen.dart @@ -0,0 +1,680 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:go_router/go_router.dart'; + +import '../../../core/l10n/app_strings.dart'; +import '../../../core/providers/auth_provider.dart'; +import '../../../core/providers/locale_provider.dart'; +import '../../../core/router/app_router.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../models/tenant.dart'; +import '../../shared/tenant_team_screen.dart'; +import '../connections/clinic_connections_screen.dart'; + +class ClinicSettingsScreen extends ConsumerWidget { + const ClinicSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final auth = ref.watch(authProvider); + final s = ref.watch(stringsProvider); + final profile = auth.profile; + final membership = auth.activeTenant; + final tenant = membership?.tenant; + final canEdit = membership?.isAdmin ?? false; + + return Scaffold( + appBar: AppBar(title: Text(s.settings)), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // User card + _SectionHeader(title: s.userInfo), + _UserCard(profile: profile), + const SizedBox(height: 20), + + // Clinic info + _SectionHeader( + title: s.clinicInfo, + action: canEdit + ? IconButton( + icon: const Icon(Icons.edit_outlined, + size: 18, color: AppColors.accent), + tooltip: s.edit, + onPressed: () => _showEditSheet(context, ref, tenant, s), + ) + : null, + ), + _InfoCard(children: [ + _InfoTile( + icon: Icons.business, + label: s.clinicName, + value: tenant?.companyName ?? '-', + ), + _InfoTile( + icon: Icons.category_outlined, + label: s.type, + value: _tenantKindLabel(tenant?.kind, s), + ), + _InfoTile( + icon: Icons.star_outline, + label: s.role, + value: _roleLabel(membership?.role, s), + ), + ]), + const SizedBox(height: 20), + + // Connections + if (membership?.showConnections ?? false) ...[ + _SectionHeader(title: s.connections), + _InfoCard(children: [ + _NavTile( + icon: Icons.link_rounded, + iconColor: AppColors.inProgress, + iconBg: AppColors.inProgressBg, + title: s.labConnections, + subtitle: s.labConnectionsSub, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const ClinicConnectionsScreen()), + ), + ), + ]), + const SizedBox(height: 20), + ], + + // Other memberships + if (auth.memberships.length > 1) ...[ + _SectionHeader(title: s.otherMemberships), + _InfoCard(children: [ + for (final m + in auth.memberships.where((m) => m.id != membership?.id)) + _NavTile( + icon: Icons.switch_account_outlined, + iconColor: AppColors.inProgress, + iconBg: AppColors.inProgressBg, + title: m.tenant.companyName, + subtitle: _tenantKindLabel(m.tenant.kind, s), + onTap: () { + ref.read(authProvider.notifier).setActiveTenant(m); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(s.tenantSelected(m.tenant.companyName))), + ); + }, + ), + ]), + const SizedBox(height: 20), + ], + + // Team management + Reports + if (membership?.canManageUsers ?? false) ...[ + _SectionHeader(title: s.management), + _InfoCard(children: [ + _NavTile( + icon: Icons.group_outlined, + iconColor: AppColors.inProgress, + iconBg: AppColors.inProgressBg, + title: s.team, + subtitle: s.teamSub, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const TenantTeamScreen()), + ), + ), + _NavTile( + icon: Icons.bar_chart_rounded, + iconColor: AppColors.accent, + iconBg: AppColors.inProgressBg, + title: s.reports, + subtitle: s.reportsSub, + onTap: () => context.push(routeClinicReports), + ), + _NavTile( + icon: Icons.auto_awesome_outlined, + iconColor: const Color(0xFF7C3AED), + iconBg: const Color(0xFFF3E8FF), + title: s.aiAssistant, + subtitle: s.aiAssistantSub, + onTap: () => context.push(routeClinicAi), + ), + ]), + const SizedBox(height: 20), + ], + + // Preferences (language) + _SectionHeader(title: s.preferences), + _InfoCard(children: [ + _NavTile( + icon: Icons.language_outlined, + iconColor: AppColors.accent, + iconBg: AppColors.inProgressBg, + title: s.appLanguage, + subtitle: _currentLanguageLabel(ref.watch(localeProvider).languageCode, s), + onTap: () => _showLanguagePicker(context, ref, s), + ), + ]), + const SizedBox(height: 20), + + // Sign out + _SignOutCard(ref: ref, s: s), + const SizedBox(height: 32), + const Center( + child: Text('DLS — Dental Lab System', + style: TextStyle(fontSize: 12, color: AppColors.textMuted)), + ), + const SizedBox(height: 8), + ], + ), + ); + } + + void _showEditSheet(BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) { + if (tenant == null) return; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _EditTenantSheet( + tenant: tenant, + s: s, + onSave: (name) async { + await ref + .read(authProvider.notifier) + .updateTenantInfo(tenantId: tenant.id, companyName: name); + }, + ), + ); + } + + void _showLanguagePicker(BuildContext context, WidgetRef ref, AppStrings s) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => _LanguagePickerSheet(s: s, ref: ref), + ); + } + + static String _currentLanguageLabel(String code, AppStrings s) => switch (code) { + 'en' => s.languageEnglish, + 'ru' => s.languageRussian, + 'ar' => s.languageArabic, + 'de' => s.languageGerman, + _ => s.languageTurkish, + }; + + static String _tenantKindLabel(TenantKind? kind, AppStrings s) => + switch (kind) { + TenantKind.clinic => s.tenantKindClinic, + TenantKind.lab => s.tenantKindLab, + null => '-', + }; + + static String _roleLabel(TenantRole? role, AppStrings s) => switch (role) { + TenantRole.owner => s.roleOwner, + TenantRole.admin => s.roleAdmin, + TenantRole.technician => s.roleTechnician, + TenantRole.delivery => s.roleDelivery, + TenantRole.finance => s.roleFinance, + TenantRole.doctor => s.roleDoctor, + TenantRole.member => s.roleMember, + null => '-', + }; +} + +// ── Language picker sheet ───────────────────────────────────────────────────── + +class _LanguagePickerSheet extends ConsumerWidget { + const _LanguagePickerSheet({required this.s, required this.ref}); + final AppStrings s; + final WidgetRef ref; + + @override + Widget build(BuildContext context, WidgetRef _) { + final currentLocale = ref.watch(localeProvider); + final options = [ + ('tr', '🇹🇷', s.languageTurkish), + ('en', '🇬🇧', s.languageEnglish), + ('ru', '🇷🇺', s.languageRussian), + ('ar', '🇸🇦', s.languageArabic), + ('de', '🇩🇪', s.languageGerman), + ]; + + return Container( + decoration: const BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Text( + s.languageSelection, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 12), + for (final (code, flag, label) in options) + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + leading: Text(flag, style: const TextStyle(fontSize: 24)), + title: Text( + label, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + trailing: currentLocale.languageCode == code + ? const Icon(Icons.check_circle_rounded, + color: AppColors.accent) + : null, + onTap: () { + ref.read(localeProvider.notifier).setLocale(Locale(code)); + ref.read(authProvider.notifier).updateLanguage(code); + Navigator.pop(context); + }, + ), + SizedBox(height: MediaQuery.paddingOf(context).bottom + 4), + ], + ), + ); + } +} + +// ── Edit sheet ──────────────────────────────────────────────────────────────── + +class _EditTenantSheet extends StatefulWidget { + const _EditTenantSheet({ + required this.tenant, + required this.s, + required this.onSave, + }); + final Tenant tenant; + final AppStrings s; + final Future Function(String companyName) onSave; + + @override + State<_EditTenantSheet> createState() => _EditTenantSheetState(); +} + +class _EditTenantSheetState extends State<_EditTenantSheet> { + late final TextEditingController _nameController; + bool _saving = false; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.tenant.companyName); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + Future _submit() async { + final name = _nameController.text.trim(); + if (name.isEmpty) return; + setState(() => _saving = true); + final navigator = Navigator.of(context); + final messenger = ScaffoldMessenger.of(context); + try { + await widget.onSave(name); + navigator.pop(); + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('${widget.s.errorPrefix}: $e'))); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final s = widget.s; + return Padding( + padding: + EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Container( + decoration: const BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: BorderRadius.circular(2)), + ), + ), + const SizedBox(height: 16), + Text(s.editClinicInfo, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary)), + const SizedBox(height: 16), + TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: s.clinicName, + hintText: s.clinicNameHint, + ), + textCapitalization: TextCapitalization.words, + ), + const SizedBox(height: 20), + if (_saving) + const Center( + child: CircularProgressIndicator(color: AppColors.accent)) + else + FilledButton( + onPressed: _submit, + style: FilledButton.styleFrom( + minimumSize: const Size(double.infinity, 48)), + child: Text(s.save), + ), + SizedBox(height: MediaQuery.paddingOf(context).bottom + 4), + ], + ), + ), + ); + } +} + +// ── Reusable UI pieces ──────────────────────────────────────────────────────── + +class _UserCard extends StatelessWidget { + const _UserCard({required this.profile}); + final dynamic profile; + + @override + Widget build(BuildContext context) { + final displayName = (profile?.displayName?.isNotEmpty == true) + ? profile!.displayName as String + : 'Kullanıcı'; + final initial = (profile?.displayName?.isNotEmpty == true + ? (profile!.displayName as String)[0] + : (profile?.email as String?)?[0] ?? '?') + .toUpperCase(); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + ), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(14)), + child: Center( + child: Text(initial, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.inProgress)), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(displayName, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary)), + const SizedBox(height: 2), + Text(profile?.email as String? ?? '', + style: const TextStyle( + fontSize: 13, color: AppColors.textSecondary)), + ], + ), + ), + ], + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title, this.action}); + final String title; + final Widget? action; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.accent, + letterSpacing: 0.3), + ), + ), + if (action != null) action!, + ], + ), + ); + } +} + +class _InfoCard extends StatelessWidget { + const _InfoCard({required this.children}); + final List children; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + ), + child: Column( + children: [ + for (int i = 0; i < children.length; i++) ...[ + children[i], + if (i < children.length - 1) + const Divider( + height: 1, indent: 16, endIndent: 16, color: AppColors.border), + ], + ], + ), + ); + } +} + +class _InfoTile extends StatelessWidget { + const _InfoTile( + {required this.icon, required this.label, required this.value}); + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon(icon, size: 18, color: AppColors.textSecondary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle( + fontSize: 11, color: AppColors.textMuted)), + const SizedBox(height: 2), + Text(value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary)), + ], + ), + ), + ], + ), + ); + } +} + +class _NavTile extends StatelessWidget { + const _NavTile({ + required this.icon, + required this.iconColor, + required this.iconBg, + required this.title, + required this.onTap, + this.subtitle, + }); + + final IconData icon; + final Color iconColor; + final Color iconBg; + final String title; + final String? subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: iconBg, borderRadius: BorderRadius.circular(9)), + child: Icon(icon, color: iconColor, size: 18), + ), + title: Text(title, + style: const TextStyle( + fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + subtitle: subtitle != null + ? Text(subtitle!, + style: const TextStyle(color: AppColors.textSecondary)) + : null, + trailing: + const Icon(Icons.chevron_right, color: AppColors.textSecondary), + onTap: onTap, + ); + } +} + +class _SignOutCard extends StatelessWidget { + const _SignOutCard({required this.ref, required this.s}); + final WidgetRef ref; + final AppStrings s; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.cancelledBg), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + ), + child: ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(9)), + child: const Icon(Icons.logout, + color: AppColors.cancelled, size: 18), + ), + title: Text(s.signOut, + style: const TextStyle( + color: AppColors.cancelled, fontWeight: FontWeight.w600)), + onTap: () async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(s.signOutTitle), + content: Text(s.signOutConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(s.cancel)), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: AppColors.cancelled), + child: Text(s.signOut), + ), + ], + ), + ); + if (confirmed == true) { + await ref.read(authProvider.notifier).signOut(); + } + }, + ), + ); + } +} diff --git a/lib/features/lab/connections/connection_detail_screen.dart b/lib/features/lab/connections/connection_detail_screen.dart new file mode 100644 index 0000000..b402c20 --- /dev/null +++ b/lib/features/lab/connections/connection_detail_screen.dart @@ -0,0 +1,581 @@ +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_theme.dart'; +import '../../../models/connection.dart'; +import '../../../models/job.dart'; +import 'connection_stats_repository.dart'; + +class ConnectionDetailScreen extends StatefulWidget { + const ConnectionDetailScreen({ + super.key, + required this.connection, + required this.labTenantId, + }); + + final Connection connection; + final String labTenantId; + + @override + State createState() => _ConnectionDetailScreenState(); +} + +class _ConnectionDetailScreenState extends State { + late Future _future; + + @override + void initState() { + super.initState(); + _load(); + } + + void _load() { + setState(() { + _future = ConnectionStatsRepository.instance.fetchStats( + labTenantId: widget.labTenantId, + clinicTenantId: widget.connection.clinicTenantId, + ); + }); + } + + @override + Widget build(BuildContext context) { + final conn = widget.connection; + final clinicName = conn.clinicName ?? 'Klinik'; + + return Scaffold( + backgroundColor: AppColors.background, + body: CustomScrollView( + slivers: [ + SliverAppBar( + pinned: true, + expandedHeight: 140, + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + flexibleSpace: FlexibleSpaceBar( + background: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF0F172A), AppColors.primary], + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 48, 20, 16), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withValues(alpha: 0.2)), + ), + child: const Icon(Icons.local_hospital_outlined, color: Colors.white, size: 26), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + clinicName, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 3), + _StatusBadge(status: conn.status), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + SliverToBoxAdapter( + child: FutureBuilder( + future: _future, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 80), + child: Center(child: CircularProgressIndicator(color: AppColors.accent)), + ); + } + if (snap.hasError) { + return Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.wifi_off_rounded, color: AppColors.cancelled, size: 40), + const SizedBox(height: 12), + Text('Hata: ${snap.error}', style: const TextStyle(color: AppColors.textSecondary), textAlign: TextAlign.center), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh_rounded, size: 16), + label: const Text('Tekrar Dene'), + ), + ], + ), + ), + ); + } + + final stats = snap.data!; + return _StatsBody(stats: stats); + }, + ), + ), + ], + ), + ); + } +} + +// ── Stats body ──────────────────────────────────────────────────────────────── + +class _StatsBody extends StatelessWidget { + const _StatsBody({required this.stats}); + final ConnectionStats stats; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // KPI row + Row( + children: [ + Expanded(child: _KpiCard(label: 'Toplam İş', value: '${stats.totalJobs}', icon: Icons.work_outline_rounded, color: AppColors.accent)), + const SizedBox(width: 10), + Expanded(child: _KpiCard(label: 'Aktif', value: '${stats.activeJobs}', icon: Icons.pending_outlined, color: AppColors.inProgress)), + const SizedBox(width: 10), + Expanded(child: _KpiCard(label: 'Teslim', value: '${stats.deliveredJobs}', icon: Icons.check_circle_outline, color: AppColors.success)), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded(child: _KpiCard(label: 'Bu Ay', value: '${stats.thisMonthJobs}', icon: Icons.calendar_month_outlined, color: AppColors.accent)), + const SizedBox(width: 10), + Expanded(child: _KpiCard(label: 'Geçen Ay', value: '${stats.lastMonthJobs}', icon: Icons.history_rounded, color: AppColors.textSecondary)), + const SizedBox(width: 10), + Expanded(child: _KpiCard(label: 'İptal', value: '${stats.cancelledJobs}', icon: Icons.cancel_outlined, color: AppColors.cancelled)), + ], + ), + const SizedBox(height: 16), + + // Revenue + _SectionTitle('Finans'), + const SizedBox(height: 8), + _RevenueCard(stats: stats), + const SizedBox(height: 16), + + // Prosthetic type breakdown + if (stats.byType.isNotEmpty) ...[ + _SectionTitle('Ürün Tipi Dağılımı'), + const SizedBox(height: 8), + _TypeBreakdownCard(byType: stats.byType, total: stats.totalJobs), + const SizedBox(height: 16), + ], + + // Monthly trend + _SectionTitle('Aylık Karşılaştırma'), + const SizedBox(height: 8), + _MonthCompareCard(stats: stats), + const SizedBox(height: 16), + + // Recent jobs + if (stats.recentJobs.isNotEmpty) ...[ + _SectionTitle('Son İşler'), + const SizedBox(height: 8), + _RecentJobsCard(jobs: stats.recentJobs), + const SizedBox(height: 16), + ], + + const SizedBox(height: 32), + ], + ), + ); + } +} + +// ── KPI card ────────────────────────────────────────────────────────────────── + +class _KpiCard extends StatelessWidget { + const _KpiCard({required this.label, required this.value, required this.icon, required this.color}); + final String label; + final String value; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 6, offset: const Offset(0, 2))], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 18, color: color), + const SizedBox(height: 8), + Text(value, style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800, color: color)), + const SizedBox(height: 2), + Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted, fontWeight: FontWeight.w500)), + ], + ), + ); + } +} + +// ── Revenue card ────────────────────────────────────────────────────────────── + +class _RevenueCard extends StatelessWidget { + const _RevenueCard({required this.stats}); + final ConnectionStats stats; + + @override + Widget build(BuildContext context) { + final collected = stats.totalRevenue - stats.pendingRevenue; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + ), + child: Column( + children: [ + _RevRow( + label: 'Toplam Gelir', + value: _fmt(stats.totalRevenue), + color: AppColors.textPrimary, + bold: true, + ), + const Divider(height: 20), + _RevRow(label: 'Tahsil Edilen', value: _fmt(collected), color: AppColors.success), + const SizedBox(height: 8), + _RevRow(label: 'Bekleyen Alacak', value: _fmt(stats.pendingRevenue), color: AppColors.pending), + ], + ), + ); + } + + String _fmt(double v) => '${v.toStringAsFixed(0)} TL'; +} + +class _RevRow extends StatelessWidget { + const _RevRow({required this.label, required this.value, required this.color, this.bold = false}); + final String label; + final String value; + final Color color; + final bool bold; + + @override + Widget build(BuildContext context) { + final style = TextStyle( + fontSize: bold ? 15 : 14, + fontWeight: bold ? FontWeight.w700 : FontWeight.w500, + color: color, + ); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: style.copyWith(color: bold ? AppColors.textPrimary : AppColors.textSecondary)), + Text(value, style: style), + ], + ); + } +} + +// ── Type breakdown ──────────────────────────────────────────────────────────── + +class _TypeBreakdownCard extends StatelessWidget { + const _TypeBreakdownCard({required this.byType, required this.total}); + final Map byType; + final int total; + + @override + Widget build(BuildContext context) { + final sorted = byType.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + ), + child: Column( + children: [ + for (int i = 0; i < sorted.length; i++) ...[ + if (i > 0) const SizedBox(height: 10), + _TypeBar( + label: _typeLabel(sorted[i].key), + count: sorted[i].value, + total: total, + ), + ], + ], + ), + ); + } + + String _typeLabel(String key) => switch (key) { + 'metal_porselen' => 'Metal Porselen', + 'zirkonyum' => 'Zirkonyum', + 'implant_ustu_zirkonyum'=> 'İmplant Üstü Zirkonyum', + 'gecici' => 'Geçici', + 'e_max' => 'E-Max', + 'tam_protez' => 'Tam Protez', + 'parsiyel' => 'Parsiyel Protez', + _ => 'Diğer', + }; +} + +class _TypeBar extends StatelessWidget { + const _TypeBar({required this.label, required this.count, required this.total}); + final String label; + final int count; + final int total; + + @override + Widget build(BuildContext context) { + final pct = total > 0 ? count / total : 0.0; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.textPrimary)), + Text('$count adet · ${(pct * 100).toStringAsFixed(0)}%', + style: const TextStyle(fontSize: 12, color: AppColors.textMuted)), + ], + ), + const SizedBox(height: 5), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: pct, + minHeight: 7, + backgroundColor: AppColors.border, + valueColor: const AlwaysStoppedAnimation(AppColors.accent), + ), + ), + ], + ); + } +} + +// ── Monthly compare ─────────────────────────────────────────────────────────── + +class _MonthCompareCard extends StatelessWidget { + const _MonthCompareCard({required this.stats}); + final ConnectionStats stats; + + @override + Widget build(BuildContext context) { + final thisMonth = stats.thisMonthJobs; + final lastMonth = stats.lastMonthJobs; + final maxBar = (thisMonth > lastMonth ? thisMonth : lastMonth).clamp(1, 999); + final trend = lastMonth == 0 + ? null + : ((thisMonth - lastMonth) / lastMonth * 100).toStringAsFixed(0); + final isUp = thisMonth >= lastMonth; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (trend != null) ...[ + Row( + children: [ + Icon( + isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded, + size: 18, + color: isUp ? AppColors.success : AppColors.cancelled, + ), + const SizedBox(width: 6), + Text( + isUp ? '+$trend% geçen aya göre' : '$trend% geçen aya göre', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isUp ? AppColors.success : AppColors.cancelled, + ), + ), + ], + ), + const SizedBox(height: 12), + ], + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _BarColumn(label: 'Geçen Ay', count: lastMonth, maxCount: maxBar, color: AppColors.border), + const SizedBox(width: 16), + _BarColumn(label: 'Bu Ay', count: thisMonth, maxCount: maxBar, color: AppColors.accent), + ], + ), + ], + ), + ); + } +} + +class _BarColumn extends StatelessWidget { + const _BarColumn({required this.label, required this.count, required this.maxCount, required this.color}); + final String label; + final int count; + final int maxCount; + final Color color; + + @override + Widget build(BuildContext context) { + final height = maxCount > 0 ? (count / maxCount * 80).clamp(4.0, 80.0) : 4.0; + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text('$count', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: color == AppColors.border ? AppColors.textSecondary : AppColors.accent)), + const SizedBox(height: 4), + Container( + width: 40, + height: height, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 4), + Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)), + ], + ); + } +} + +// ── Recent jobs ─────────────────────────────────────────────────────────────── + +class _RecentJobsCard extends StatelessWidget { + const _RecentJobsCard({required this.jobs}); + final List jobs; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + ), + child: Column( + children: [ + for (int i = 0; i < jobs.length; i++) ...[ + if (i > 0) const Divider(height: 1), + _RecentJobRow(job: jobs[i]), + ], + ], + ), + ); + } +} + +class _RecentJobRow extends StatelessWidget { + const _RecentJobRow({required this.job}); + final Job job; + + @override + Widget build(BuildContext context) { + final color = job.status == JobStatus.delivered ? AppColors.success : AppColors.inProgress; + final bg = job.status == JobStatus.delivered ? AppColors.successBg : AppColors.inProgressBg; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(10)), + child: Center(child: Icon(Icons.assignment_outlined, size: 18, color: color)), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(job.patientCode, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + Text(job.prostheticType.label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)), + child: Text(job.status.label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)), + ), + ], + ), + ); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +class _SectionTitle extends StatelessWidget { + const _SectionTitle(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Text(text, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.textMuted, letterSpacing: 0.5)); + } +} + +class _StatusBadge extends StatelessWidget { + const _StatusBadge({required this.status}); + final ConnectionStatus status; + + @override + Widget build(BuildContext context) { + final (label, color) = switch (status) { + ConnectionStatus.approved => ('Onaylı', AppColors.success), + ConnectionStatus.pending => ('Bekliyor', AppColors.pending), + ConnectionStatus.rejected => ('Reddedildi', AppColors.cancelled), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)), + ); + } +} diff --git a/lib/features/lab/connections/connection_stats_repository.dart b/lib/features/lab/connections/connection_stats_repository.dart new file mode 100644 index 0000000..9037cb1 --- /dev/null +++ b/lib/features/lab/connections/connection_stats_repository.dart @@ -0,0 +1,124 @@ +import 'package:pocketbase/pocketbase.dart'; +import '../../../core/api/pocketbase_client.dart'; +import '../../../models/job.dart'; + +class ConnectionStats { + const ConnectionStats({ + required this.totalJobs, + required this.byStatus, + required this.byType, + required this.totalRevenue, + required this.pendingRevenue, + required this.thisMonthJobs, + required this.lastMonthJobs, + required this.revisionCount, + required this.recentJobs, + }); + + final int totalJobs; + final Map byStatus; // 'in_progress', 'sent', 'delivered', 'cancelled' + final Map byType; // prosthetic_type -> count + final double totalRevenue; + final double pendingRevenue; + final int thisMonthJobs; + final int lastMonthJobs; + final int revisionCount; + final List recentJobs; + + int get deliveredJobs => byStatus['delivered'] ?? 0; + int get activeJobs => (byStatus['in_progress'] ?? 0) + (byStatus['sent'] ?? 0); + int get cancelledJobs => byStatus['cancelled'] ?? 0; + double get revisionRate => totalJobs > 0 ? revisionCount / totalJobs * 100 : 0; + double get completionRate => totalJobs > 0 ? deliveredJobs / totalJobs * 100 : 0; +} + +class ConnectionStatsRepository { + ConnectionStatsRepository._(); + static final instance = ConnectionStatsRepository._(); + + PocketBase get _pb => PocketBaseClient.instance.pb; + + Future fetchStats({ + required String labTenantId, + required String clinicTenantId, + }) async { + final now = DateTime.now(); + final thisMonthStart = DateTime(now.year, now.month, 1); + final lastMonthStart = DateTime(now.year, now.month - 1, 1); + final lastMonthEnd = thisMonthStart.subtract(const Duration(seconds: 1)); + + final filter = 'lab_tenant_id = "$labTenantId" && clinic_tenant_id = "$clinicTenantId"'; + + final results = await Future.wait([ + _pb.collection('jobs').getList( + filter: filter, + perPage: 500, + expand: 'clinic_tenant_id,lab_tenant_id', + ), + _pb.collection('finance_entries').getList( + filter: 'tenant_id = "$labTenantId" && job_id.clinic_tenant_id = "$clinicTenantId"', + perPage: 500, + ), + ]); + + final jobsResult = results[0]; + final financeResult = results[1]; + + final allJobs = jobsResult.items + .map((r) => Job.fromJson(r.toJson())) + .toList(); + + // Status breakdown + final byStatus = {}; + final byType = {}; + int thisMonthJobs = 0; + int lastMonthJobs = 0; + int revisionCount = 0; + + for (final job in allJobs) { + // Status + final s = job.status.value; + byStatus[s] = (byStatus[s] ?? 0) + 1; + + // Type + final t = job.prostheticType.value; + byType[t] = (byType[t] ?? 0) + 1; + + // Monthly + final created = job.dateCreated; + if (created.isAfter(thisMonthStart)) thisMonthJobs++; + else if (created.isAfter(lastMonthStart) && created.isBefore(lastMonthEnd)) lastMonthJobs++; + + // Revision + if (job.status == JobStatus.inProgress && job.currentStep == null) revisionCount++; + } + + // Finance + double totalRevenue = 0; + double pendingRevenue = 0; + for (final r in financeResult.items) { + final j = r.toJson(); + final amount = (j['amount'] as num?)?.toDouble() ?? 0; + totalRevenue += amount; + if (j['status'] == 'pending') pendingRevenue += amount; + } + + // Recent jobs (last 5 delivered or sent) + final recent = allJobs + .where((j) => j.status == JobStatus.delivered || j.status == JobStatus.sent) + .toList() + ..sort((a, b) => b.dateCreated.compareTo(a.dateCreated)); + + return ConnectionStats( + totalJobs: allJobs.length, + byStatus: byStatus, + byType: byType, + totalRevenue: totalRevenue, + pendingRevenue: pendingRevenue, + thisMonthJobs: thisMonthJobs, + lastMonthJobs: lastMonthJobs, + revisionCount: revisionCount, + recentJobs: recent.take(5).toList(), + ); + } +} diff --git a/lib/features/lab/connections/lab_connections_repository.dart b/lib/features/lab/connections/lab_connections_repository.dart new file mode 100644 index 0000000..496d253 --- /dev/null +++ b/lib/features/lab/connections/lab_connections_repository.dart @@ -0,0 +1,30 @@ +import 'package:pocketbase/pocketbase.dart'; +import '../../../core/api/pocketbase_client.dart'; +import '../../../models/connection.dart'; + +class LabConnectionsRepository { + LabConnectionsRepository._(); + static final instance = LabConnectionsRepository._(); + + PocketBase get _pb => PocketBaseClient.instance.pb; + + Future> listConnections(String labTenantId) async { + final result = await _pb.collection('connections').getList( + filter: 'lab_tenant_id = "$labTenantId"', + expand: 'clinic_tenant_id,lab_tenant_id', + perPage: 100, + ); + return (result.items.map((r) => Connection.fromJson(r.toJson())).toList() + ..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? ''))); + } + + Future respondToRequest({ + required String connectionId, + required bool approve, + }) async { + final record = await _pb.collection('connections').update(connectionId, body: { + 'status': approve ? 'approved' : 'rejected', + }); + return Connection.fromJson(record.toJson()); + } +} diff --git a/lib/features/lab/connections/lab_connections_screen.dart b/lib/features/lab/connections/lab_connections_screen.dart new file mode 100644 index 0000000..4edc804 --- /dev/null +++ b/lib/features/lab/connections/lab_connections_screen.dart @@ -0,0 +1,453 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/providers/auth_provider.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/gradient_app_bar.dart'; +import '../../../models/connection.dart'; +import 'connection_detail_screen.dart'; +import 'lab_connections_repository.dart'; + +class LabConnectionsScreen extends ConsumerStatefulWidget { + const LabConnectionsScreen({super.key}); + + @override + ConsumerState createState() => + _LabConnectionsScreenState(); +} + +class _LabConnectionsScreenState extends ConsumerState { + late Future> _future; + final _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _load() { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + setState(() { + _future = LabConnectionsRepository.instance.listConnections(tenantId); + }); + } + + Future _respond(String connectionId, bool approve) async { + try { + await LabConnectionsRepository.instance.respondToRequest( + connectionId: connectionId, + approve: approve, + ); + _load(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + approve ? 'Bağlantı onaylandı' : 'Bağlantı reddedildi'), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } + } + + Color _statusColor(ConnectionStatus status) { + return switch (status) { + ConnectionStatus.pending => AppColors.pending, + ConnectionStatus.approved => AppColors.success, + ConnectionStatus.rejected => AppColors.cancelled, + }; + } + + Color _statusBg(ConnectionStatus status) { + return switch (status) { + ConnectionStatus.pending => AppColors.pendingBg, + ConnectionStatus.approved => AppColors.successBg, + ConnectionStatus.rejected => AppColors.cancelledBg, + }; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: GradientAppBar( + title: 'Bağlantılar', + category: 'LABORATUVAR', + searchController: _searchController, + onSearchChanged: (v) => setState(() => _searchQuery = v), + searchHint: 'Klinik adı ara...', + ), + body: RefreshIndicator( + color: AppColors.accent, + onRefresh: () async => _load(), + child: FutureBuilder>( + future: _future, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator(color: AppColors.accent)); + } + if (snap.hasError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(16)), + child: const Icon(Icons.wifi_off_rounded, + color: AppColors.cancelled, size: 30), + ), + const SizedBox(height: 16), + Text('Hata: ${snap.error}', + style: + const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Tekrar Dene'), + ), + ], + ), + ); + } + + final allConnections = snap.data!; + final q = _searchQuery.toLowerCase().trim(); + final connections = q.isEmpty + ? allConnections + : allConnections.where((c) => + (c.clinicName ?? '').toLowerCase().contains(q)).toList(); + + if (allConnections.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(20)), + child: const Icon(Icons.link_off, + color: AppColors.inProgress, size: 32), + ), + const SizedBox(height: 16), + const Text( + 'Henüz bağlantı yok', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary), + ), + const SizedBox(height: 8), + const Text( + 'Kliniklerden gelen istekler burada görünür.', + style: TextStyle( + color: AppColors.textSecondary, fontSize: 13), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + final pending = connections + .where((c) => c.status == ConnectionStatus.pending) + .toList(); + final others = connections + .where((c) => c.status != ConnectionStatus.pending) + .toList(); + + return ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + children: [ + if (pending.isNotEmpty) ...[ + _SectionHeader( + label: 'Bekleyen İstekler', + count: pending.length, + countColor: AppColors.pending, + countBg: AppColors.pendingBg, + ), + const SizedBox(height: 8), + ...pending.map((c) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _ConnectionCard( + connection: c, + statusColor: _statusColor(c.status), + statusBg: _statusBg(c.status), + onApprove: () => _respond(c.id, true), + onReject: () => _respond(c.id, false), + ), + )), + const SizedBox(height: 8), + ], + if (others.isNotEmpty) ...[ + _SectionHeader( + label: 'Bağlantılar', + count: others.length, + countColor: AppColors.textSecondary, + countBg: AppColors.surfaceVariant, + ), + const SizedBox(height: 8), + ...others.map((c) { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _ConnectionCard( + connection: c, + statusColor: _statusColor(c.status), + statusBg: _statusBg(c.status), + onTap: c.status == ConnectionStatus.approved + ? () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ConnectionDetailScreen( + connection: c, + labTenantId: tenantId, + ), + ), + ) + : null, + ), + ); + }), + ], + ], + ); + }, + ), + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({ + required this.label, + required this.count, + required this.countColor, + required this.countBg, + }); + final String label; + final int count; + final Color countColor; + final Color countBg; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text( + label, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.accent, + letterSpacing: 0.3, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: countBg, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '$count', + style: TextStyle( + color: countColor, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + } +} + +class _ConnectionCard extends StatefulWidget { + const _ConnectionCard({ + required this.connection, + required this.statusColor, + required this.statusBg, + this.onApprove, + this.onReject, + this.onTap, + }); + final Connection connection; + final Color statusColor; + final Color statusBg; + final VoidCallback? onApprove; + final VoidCallback? onReject; + final VoidCallback? onTap; + + @override + State<_ConnectionCard> createState() => _ConnectionCardState(); +} + +class _ConnectionCardState extends State<_ConnectionCard> { + bool _loading = false; + + Future _act(VoidCallback? cb) async { + if (cb == null) return; + setState(() => _loading = true); + cb(); + await Future.delayed(const Duration(milliseconds: 500)); + if (mounted) setState(() => _loading = false); + } + + String _formatDate(String? raw) { + if (raw == null) return ''; + try { + final dt = DateTime.parse(raw); + return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}'; + } catch (_) { + return ''; + } + } + + @override + Widget build(BuildContext context) { + final c = widget.connection; + final isPending = c.status == ConnectionStatus.pending; + + return Material( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: widget.onTap, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2)) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: widget.statusBg, + borderRadius: BorderRadius.circular(12)), + child: Icon(Icons.business_outlined, + color: widget.statusColor, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + c.clinicName ?? 'Klinik', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary), + ), + if (c.dateCreated != null) ...[ + const SizedBox(height: 2), + Text( + _formatDate(c.dateCreated), + style: const TextStyle( + color: AppColors.textMuted, fontSize: 12), + ), + ], + ], + ), + ), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: widget.statusBg, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + c.status.label, + style: TextStyle( + color: widget.statusColor, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + if (widget.onTap != null) ...[ + const SizedBox(width: 4), + const Icon(Icons.chevron_right_rounded, + size: 18, color: AppColors.textMuted), + ], + ], + ), + if (isPending) ...[ + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: + _loading ? null : () => _act(widget.onReject), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.cancelled, + side: const BorderSide(color: AppColors.cancelled)), + child: const Text('Reddet'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton( + onPressed: + _loading ? null : () => _act(widget.onApprove), + style: FilledButton.styleFrom( + backgroundColor: AppColors.success), + child: _loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Text('Onayla'), + ), + ), + ], + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/lab/dashboard/lab_dashboard_screen.dart b/lib/features/lab/dashboard/lab_dashboard_screen.dart new file mode 100644 index 0000000..16e390d --- /dev/null +++ b/lib/features/lab/dashboard/lab_dashboard_screen.dart @@ -0,0 +1,883 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/providers/auth_provider.dart'; +import '../../../core/router/app_router.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/tooth_logo.dart'; +import '../../../core/services/realtime_service.dart'; +import '../../../models/job.dart'; +import '../jobs/lab_jobs_repository.dart'; + +class LabDashboardScreen extends ConsumerStatefulWidget { + const LabDashboardScreen({super.key}); + @override + ConsumerState createState() => _LabDashboardScreenState(); +} + +class _LabDashboardScreenState extends ConsumerState { + late Future<_DashboardData> _future; + bool _acceptingAll = false; + late UnsubFn _unsub; + + @override + void initState() { + super.initState(); + _load(); + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + _unsub = RealtimeService.instance.watch( + 'jobs', + filter: "lab_tenant_id='$tenantId'", + onEvent: (_) { if (mounted) _load(); }, + ); + } + + @override + void dispose() { + _unsub(); + super.dispose(); + } + + void _load() { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + final now = DateTime.now(); + final thisMonthStart = DateTime(now.year, now.month, 1); + final lastMonthStart = DateTime(now.year, now.month - 1, 1); + setState(() { + _future = Future.wait([ + Future.wait>([ + LabJobsRepository.instance.listInbound(tenantId, status: 'pending'), + LabJobsRepository.instance.listInProgress(tenantId), + LabJobsRepository.instance.listInProgress(tenantId, location: 'at_lab'), + LabJobsRepository.instance.listInProgress(tenantId, location: 'at_clinic'), + LabJobsRepository.instance.listInbound(tenantId, status: 'sent', limit: 200), + LabJobsRepository.instance.listInbound(tenantId, status: 'delivered', limit: 200), + ]), + LabJobsRepository.instance.countDelivered(tenantId, from: thisMonthStart), + LabJobsRepository.instance.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart), + ]).then((r) { + final jobs = r[0] as List>; + return _DashboardData( + pendingJobs: jobs[0], + inProgressJobs: jobs[1], + atLabJobs: jobs[2], + atClinicJobs: jobs[3], + sentCount: jobs[4].length, + deliveredCount: jobs[5].length, + thisMonthDelivered: r[1] as int, + lastMonthDelivered: r[2] as int, + ); + }); + }); + } + + Future _bulkAccept() async { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + setState(() => _acceptingAll = true); + try { + await LabJobsRepository.instance.bulkAcceptPending(tenantId); + _load(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e'), behavior: SnackBarBehavior.floating), + ); + } + } finally { + if (mounted) setState(() => _acceptingAll = false); + } + } + + @override + Widget build(BuildContext context) { + final companyName = ref.watch(authProvider).activeTenant?.tenant.companyName ?? ''; + return Scaffold( + backgroundColor: AppColors.background, + body: LayoutBuilder( + builder: (context, constraints) { + const maxContent = 1040.0; + final hPad = constraints.maxWidth > maxContent + ? (constraints.maxWidth - maxContent) / 2 + : 16.0; + + return RefreshIndicator( + color: AppColors.accent, + onRefresh: () async => _load(), + child: FutureBuilder<_DashboardData>( + future: _future, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return _DashboardSkeleton(companyName: companyName, hPad: hPad); + } + if (snap.hasError) return _ErrorBody(onRetry: _load); + final data = snap.data!; + final isDesktop = MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint; + return CustomScrollView( + slivers: [ + _DashboardHeader(companyName: companyName), + if (isDesktop) + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + sliver: SliverToBoxAdapter( + child: _StatsRow( + pending: data.pendingJobs.length, + inProgress: data.inProgressJobs.length, + sent: data.sentCount, + delivered: data.deliveredCount, + ), + ), + ), + if (data.pendingJobs.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), + sliver: SliverToBoxAdapter( + child: _AcceptAllBanner( + count: data.pendingJobs.length, + loading: _acceptingAll, + onTap: _bulkAccept, + ).animate().fadeIn(duration: 300.ms).slideY(begin: 0.1, end: 0), + ), + ), + if (isDesktop) ...[ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + sliver: SliverToBoxAdapter( + child: _MonthlyReportSection(data: data) + .animate().fadeIn(duration: 300.ms).slideY(begin: 0.08, end: 0), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + sliver: SliverToBoxAdapter( + child: _GamificationRow(data: data) + .animate().fadeIn(duration: 300.ms, delay: 60.ms).slideY(begin: 0.08, end: 0), + ), + ), + ], + // ── Yapılacaklar (at_lab) ──────────────────────────── + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 4), + sliver: SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Yapılacaklar', style: Theme.of(context).textTheme.titleMedium), + TextButton( + onPressed: () => context.go(routeLabJobsAll), + style: TextButton.styleFrom(foregroundColor: AppColors.accent, padding: const EdgeInsets.symmetric(horizontal: 8)), + child: const Text('Tümünü Gör'), + ), + ], + ), + ), + ), + if (data.atLabJobs.isEmpty) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB(16, 4, 16, 0), + child: _EmptySection(message: 'Yapılacak iş yok'), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + sliver: SliverList.separated( + itemCount: data.atLabJobs.take(5).length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (ctx, i) => _JobCard(job: data.atLabJobs[i]) + .animate(delay: (i * 60).ms) + .fadeIn(duration: 300.ms) + .slideY(begin: 0.12, end: 0), + ), + ), + // ── Klinikte Onay Bekliyor (at_clinic) ─────────────── + if (data.atClinicJobs.isNotEmpty) ...[ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 4), + sliver: SliverToBoxAdapter( + child: Text('Klinikte Onay Bekliyor', style: Theme.of(context).textTheme.titleMedium), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + sliver: SliverList.separated( + itemCount: data.atClinicJobs.take(5).length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (ctx, i) => _JobCard(job: data.atClinicJobs[i]) + .animate(delay: (i * 60).ms) + .fadeIn(duration: 300.ms) + .slideY(begin: 0.12, end: 0), + ), + ), + ], + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], + ); + }, + ), + ); + }, + ), + ); + } +} + +class _DashboardHeader extends StatelessWidget { + const _DashboardHeader({required this.companyName}); + final String companyName; + + // Must stay in sync with _DesktopSidebar.headerHeight in app_router.dart + static const double _desktopToolbarHeight = 64; + + @override + Widget build(BuildContext context) { + final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + + if (isDesktop) { + return SliverAppBar( + pinned: true, + toolbarHeight: _desktopToolbarHeight, + backgroundColor: AppColors.surface, + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: false, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Genel Bakış', style: TextStyle(fontSize: 11, color: AppColors.textSecondary.withValues(alpha: 0.8), letterSpacing: 0.3)), + const Text('Bugünkü Durum', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + ], + ), + ), + actions: [ + IconButton( + onPressed: () => context.go(routeLabSettings), + icon: const Icon(Icons.settings_outlined, color: AppColors.textSecondary, size: 22), + ), + const SizedBox(width: 8), + ], + ); + } + + return SliverAppBar( + pinned: true, + expandedHeight: 148, + backgroundColor: AppColors.primary, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + systemOverlayStyle: SystemUiOverlayStyle.light, + centerTitle: false, + leadingWidth: 60, + leading: Padding( + padding: const EdgeInsets.only(left: 16, top: 8, bottom: 8), + child: Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: const Center(child: ToothLogo(size: 20, color: Colors.white)), + ), + ), + titleSpacing: 8, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('DLS', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 1.5)), + Text(companyName, style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700), maxLines: 1, overflow: TextOverflow.ellipsis), + ], + ), + actions: [ + IconButton( + onPressed: () => context.go(routeLabSettings), + icon: const Icon(Icons.settings_outlined, color: Colors.white, size: 22), + ), + ], + flexibleSpace: FlexibleSpaceBar( + collapseMode: CollapseMode.pin, + background: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.primary, AppColors.accent], + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text('Genel Bakış', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 12, fontWeight: FontWeight.w500, letterSpacing: 0.5)), + const SizedBox(height: 4), + const Text('Bugünkü Durum', style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.w800, letterSpacing: -0.5)), + ], + ), + ), + ), + ), + ); + } +} + +class _StatsRow extends StatelessWidget { + const _StatsRow({ + required this.pending, + required this.inProgress, + required this.sent, + required this.delivered, + }); + final int pending; + final int inProgress; + final int sent; + final int delivered; + + @override + Widget build(BuildContext context) { + final isWideDesktop = MediaQuery.sizeOf(context).width >= AppLayout.wideBreakpoint; + + final pendingCard = _StatCard(label: 'Bekleyen', value: '$pending', icon: Icons.hourglass_top_rounded, color: AppColors.pending, bgColor: AppColors.pendingBg) + .animate().fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); + final inProgressCard = _StatCard(label: 'Devam Eden', value: '$inProgress', icon: Icons.autorenew_rounded, color: AppColors.inProgress, bgColor: AppColors.inProgressBg) + .animate(delay: 80.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); + + if (isWideDesktop) { + final sentCard = _StatCard(label: 'Klinik\'te', value: '$sent', icon: Icons.local_hospital_outlined, color: AppColors.accent, bgColor: AppColors.inProgressBg) + .animate(delay: 120.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); + final deliveredCard = _StatCard(label: 'Tamamlanan', value: '$delivered', icon: Icons.task_alt, color: AppColors.success, bgColor: AppColors.successBg) + .animate(delay: 160.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0); + + return Row( + children: [ + Expanded(child: pendingCard), + const SizedBox(width: 12), + Expanded(child: inProgressCard), + const SizedBox(width: 12), + Expanded(child: sentCard), + const SizedBox(width: 12), + Expanded(child: deliveredCard), + ], + ); + } + + return Row( + children: [ + Expanded(child: pendingCard), + const SizedBox(width: 12), + Expanded(child: inProgressCard), + ], + ); + } +} + +class _StatCard extends StatelessWidget { + const _StatCard({required this.label, required this.value, required this.icon, required this.color, required this.bgColor}); + final String label; + final String value; + final IconData icon; + final Color color; + final Color bgColor; + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 12, offset: const Offset(0, 4))], + ), + child: Row( + children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(12)), + child: Icon(icon, color: color, size: 22), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(value, style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800, color: color, height: 1)), + const SizedBox(height: 3), + Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary, fontWeight: FontWeight.w500)), + ], + ), + ], + ), + ); + } +} + +class _AcceptAllBanner extends StatelessWidget { + const _AcceptAllBanner({required this.count, required this.loading, required this.onTap}); + final int count; + final bool loading; + final VoidCallback onTap; + @override + Widget build(BuildContext context) { + return Material( + color: AppColors.pendingBg, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: loading ? null : onTap, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.pending.withValues(alpha: 0.35))), + child: Row( + children: [ + Container( + width: 38, height: 38, + decoration: BoxDecoration(color: AppColors.pending.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10)), + child: const Icon(Icons.notifications_active_outlined, color: AppColors.pending, size: 18), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('$count yeni iş bekliyor', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + const Text('Tümünü hızlıca kabul et', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)), + ], + ), + ), + loading + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.pending)) + : Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(8)), + child: const Text('Kabul Et', style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)), + ), + ], + ), + ), + ), + ); + } +} + +class _JobCard extends StatelessWidget { + const _JobCard({required this.job}); + final Job job; + @override + Widget build(BuildContext context) { + final due = job.dueDate; + final isOverdue = due != null && due.isBefore(DateTime.now()); + final dueText = due != null ? '${due.day.toString().padLeft(2, '0')}.${due.month.toString().padLeft(2, '0')}.${due.year}' : null; + return Semantics( + label: job.patientCode, + button: true, + excludeSemantics: true, + child: Material( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: () => context.push('/lab/jobs/${job.id}'), + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.border)), + child: Row( + children: [ + Container( + width: 46, height: 46, + decoration: BoxDecoration(color: AppColors.inProgressBg, borderRadius: BorderRadius.circular(12)), + child: Center(child: Text('${job.memberCount}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: AppColors.inProgress))), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(job.patientCode, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + const SizedBox(height: 2), + Text(job.clinicName ?? 'Klinik', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)), + const SizedBox(height: 6), + Wrap( + spacing: 6, + children: [ + _Tag(label: job.prostheticType.label, color: AppColors.inProgress, bg: AppColors.inProgressBg), + if (job.currentStep != null) _Tag(label: job.currentStep!.label, color: AppColors.success, bg: AppColors.successBg), + ], + ), + ], + ), + ), + if (dueText != null) ...[ + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Icon(Icons.calendar_today_outlined, size: 13, color: isOverdue ? AppColors.cancelled : AppColors.textMuted), + const SizedBox(height: 3), + Text(dueText, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: isOverdue ? AppColors.cancelled : AppColors.textSecondary)), + ], + ), + ], + ], + ), + ), + ), + ), + ); + } +} + +class _Tag extends StatelessWidget { + const _Tag({required this.label, required this.color, required this.bg}); + final String label; + final Color color; + final Color bg; + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)), + child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)), + ); + } +} + + +class _EmptySection extends StatelessWidget { + const _EmptySection({required this.message}); + final String message; + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + Icon(Icons.check_circle_outline_rounded, color: AppColors.textSecondary.withValues(alpha: 0.5), size: 20), + const SizedBox(width: 10), + Text(message, style: TextStyle(fontSize: 14, color: AppColors.textSecondary)), + ], + ), + ); + } +} + +class _ErrorBody extends StatelessWidget { + const _ErrorBody({required this.onRetry}); + final VoidCallback onRetry; + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, height: 64, + decoration: BoxDecoration(color: AppColors.cancelledBg, borderRadius: BorderRadius.circular(16)), + child: const Icon(Icons.wifi_off_rounded, color: AppColors.cancelled, size: 30), + ), + const SizedBox(height: 16), + const Text('Bağlantı hatası', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + const SizedBox(height: 12), + FilledButton.icon(onPressed: onRetry, icon: const Icon(Icons.refresh_rounded, size: 18), label: const Text('Tekrar Dene')), + ], + ), + ), + ); + } +} + +class _DashboardSkeleton extends StatelessWidget { + const _DashboardSkeleton({required this.companyName, required this.hPad}); + final String companyName; + final double hPad; + @override + Widget build(BuildContext context) { + return CustomScrollView( + physics: const NeverScrollableScrollPhysics(), + slivers: [ + _DashboardHeader(companyName: companyName), + SliverPadding( + padding: EdgeInsets.fromLTRB(hPad, 16, hPad, 0), + sliver: const SliverToBoxAdapter( + child: Row(children: [ + Expanded(child: _ShimmerBox(height: 84, radius: 16)), + SizedBox(width: 12), + Expanded(child: _ShimmerBox(height: 84, radius: 16)), + ]), + ), + ), + SliverPadding( + padding: EdgeInsets.fromLTRB(hPad, 8, hPad, 0), + sliver: SliverList.builder( + itemCount: 4, + itemBuilder: (_, i) => const Padding(padding: EdgeInsets.only(bottom: 10), child: _ShimmerBox(height: 92, radius: 14)), + ), + ), + ], + ); + } +} + +class _ShimmerBox extends StatefulWidget { + const _ShimmerBox({required this.height, required this.radius}); + final double height; + final double radius; + @override + State<_ShimmerBox> createState() => _ShimmerBoxState(); +} + +class _ShimmerBoxState extends State<_ShimmerBox> with SingleTickerProviderStateMixin { + late AnimationController _ctrl; + late Animation _anim; + @override + void initState() { + super.initState(); + _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1100))..repeat(reverse: true); + _anim = CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut); + } + @override + void dispose() { _ctrl.dispose(); super.dispose(); } + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _anim, + builder: (_, __) => Container( + height: widget.height, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(widget.radius), color: Color.lerp(const Color(0xFFE2E8F0), const Color(0xFFF1F5F9), _anim.value)), + ), + ); + } +} + +// ── Monthly Report ────────────────────────────────────────────────────────── + +class _MonthlyReportSection extends StatelessWidget { + const _MonthlyReportSection({required this.data}); + final _DashboardData data; + + @override + Widget build(BuildContext context) { + final pct = data.changePercent; + final isUp = pct >= 0; + final pctStr = '${isUp ? '+' : ''}${pct.toStringAsFixed(0)}%'; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.bar_chart_rounded, size: 18, color: AppColors.accent), + const SizedBox(width: 6), + Text('Aylık Rapor', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MonthStat( + label: 'Bu Ay', + value: data.thisMonthDelivered, + highlighted: true, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _MonthStat( + label: 'Geçen Ay', + value: data.lastMonthDelivered, + highlighted: false, + ), + ), + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: isUp ? AppColors.successBg : AppColors.cancelledBg, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded, + size: 16, + color: isUp ? AppColors.success : AppColors.cancelled, + ), + const SizedBox(width: 4), + Text( + pctStr, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: isUp ? AppColors.success : AppColors.cancelled, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } +} + +class _MonthStat extends StatelessWidget { + const _MonthStat({required this.label, required this.value, required this.highlighted}); + final String label; + final int value; + final bool highlighted; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: highlighted ? AppColors.accent.withValues(alpha: 0.06) : AppColors.background, + borderRadius: BorderRadius.circular(8), + border: highlighted ? Border.all(color: AppColors.accent.withValues(alpha: 0.2)) : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontSize: 11, color: AppColors.textSecondary, fontWeight: FontWeight.w500)), + const SizedBox(height: 2), + Text( + '$value iş', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: highlighted ? AppColors.accent : AppColors.textPrimary, + ), + ), + ], + ), + ); + } +} + +// ── Gamification Row ───────────────────────────────────────────────────────── + +const _monthlyGoal = 50; + +class _GamificationRow extends StatelessWidget { + const _GamificationRow({required this.data}); + final _DashboardData data; + + @override + Widget build(BuildContext context) { + final progress = (data.thisMonthDelivered / _monthlyGoal).clamp(0.0, 1.0); + final remaining = (_monthlyGoal - data.thisMonthDelivered).clamp(0, _monthlyGoal); + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('🏆', style: TextStyle(fontSize: 16)), + const SizedBox(width: 6), + Text('Aylık Hedef', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${data.points} puan', + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.primary), + ), + ), + ], + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: progress, + minHeight: 8, + backgroundColor: AppColors.background, + valueColor: AlwaysStoppedAnimation( + progress >= 1.0 ? AppColors.success : AppColors.accent, + ), + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${data.thisMonthDelivered} / $_monthlyGoal iş teslim edildi', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + Text( + progress >= 1.0 ? 'Hedef tamamlandı!' : '$remaining iş kaldı', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: progress >= 1.0 ? AppColors.success : AppColors.textSecondary, + ), + ), + ], + ), + ], + ), + ); + } +} + +// ── Data Model ─────────────────────────────────────────────────────────────── + +class _DashboardData { + final List pendingJobs; + final List inProgressJobs; + final List atLabJobs; + final List atClinicJobs; + final int sentCount; + final int deliveredCount; + final int thisMonthDelivered; + final int lastMonthDelivered; + const _DashboardData({ + required this.pendingJobs, + required this.inProgressJobs, + required this.atLabJobs, + required this.atClinicJobs, + required this.sentCount, + required this.deliveredCount, + required this.thisMonthDelivered, + required this.lastMonthDelivered, + }); + + int get points => thisMonthDelivered * 10; + double get changePercent => lastMonthDelivered == 0 + ? (thisMonthDelivered > 0 ? 100 : 0) + : (thisMonthDelivered - lastMonthDelivered) / lastMonthDelivered * 100; +} diff --git a/lib/features/lab/discounts/discount_repository.dart b/lib/features/lab/discounts/discount_repository.dart new file mode 100644 index 0000000..97fde40 --- /dev/null +++ b/lib/features/lab/discounts/discount_repository.dart @@ -0,0 +1,92 @@ +import 'package:pocketbase/pocketbase.dart'; +import '../../../core/api/pocketbase_client.dart'; +import '../../../models/clinic_discount.dart'; + +class DiscountRepository { + DiscountRepository._(); + static final instance = DiscountRepository._(); + + PocketBase get _pb => PocketBaseClient.instance.pb; + + Future> listDiscounts(String labTenantId) async { + final result = await _pb.collection('clinic_discounts').getList( + filter: 'lab_tenant_id = "$labTenantId"', + expand: 'clinic_tenant_id', + perPage: 200, + ); + final list = result.items + .map((r) => ClinicDiscount.fromJson(r.toJson())) + .toList(); + list.sort((a, b) { + // Active first, then by clinic name + if (a.isActive != b.isActive) return a.isActive ? -1 : 1; + final ca = a.clinicName ?? ''; + final cb = b.clinicName ?? ''; + return ca.compareTo(cb); + }); + return list; + } + + Future createDiscount({ + required String labTenantId, + String? clinicTenantId, + String? prostheticType, + required DiscountType discountType, + required double discountValue, + int minQuantity = 0, + bool isActive = true, + String? notes, + }) async { + final body = { + 'lab_tenant_id': labTenantId, + 'discount_type': discountType.value, + 'discount_value': discountValue, + 'is_active': isActive, + }; + if (clinicTenantId != null && clinicTenantId.isNotEmpty) { + body['clinic_tenant_id'] = clinicTenantId; + } + if (prostheticType != null && prostheticType.isNotEmpty) { + body['prosthetic_type'] = prostheticType; + } + if (minQuantity > 0) body['min_quantity'] = minQuantity; + if (notes != null && notes.isNotEmpty) body['notes'] = notes; + + final record = await _pb.collection('clinic_discounts').create( + body: body, + expand: 'clinic_tenant_id', + ); + return ClinicDiscount.fromJson(record.toJson()); + } + + Future updateDiscount( + String id, { + String? clinicTenantId, + String? prostheticType, + DiscountType? discountType, + double? discountValue, + int? minQuantity, + bool? isActive, + String? notes, + }) async { + final body = {}; + if (clinicTenantId != null) body['clinic_tenant_id'] = clinicTenantId.isEmpty ? null : clinicTenantId; + if (prostheticType != null) body['prosthetic_type'] = prostheticType.isEmpty ? '' : prostheticType; + if (discountType != null) body['discount_type'] = discountType.value; + if (discountValue != null) body['discount_value'] = discountValue; + if (minQuantity != null) body['min_quantity'] = minQuantity; + if (isActive != null) body['is_active'] = isActive; + if (notes != null) body['notes'] = notes; + + final record = await _pb.collection('clinic_discounts').update( + id, + body: body, + expand: 'clinic_tenant_id', + ); + return ClinicDiscount.fromJson(record.toJson()); + } + + Future deleteDiscount(String id) async { + await _pb.collection('clinic_discounts').delete(id); + } +} diff --git a/lib/features/lab/discounts/discounts_screen.dart b/lib/features/lab/discounts/discounts_screen.dart new file mode 100644 index 0000000..39c975a --- /dev/null +++ b/lib/features/lab/discounts/discounts_screen.dart @@ -0,0 +1,940 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/api/pocketbase_client.dart'; +import '../../../core/providers/auth_provider.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/gradient_app_bar.dart'; +import '../../../models/clinic_discount.dart'; +import 'discount_repository.dart'; + +// Simple local record for clinic picker +class _ClinicOption { + const _ClinicOption({required this.id, required this.name}); + final String id; + final String name; +} + +class DiscountsScreen extends ConsumerStatefulWidget { + const DiscountsScreen({super.key}); + + @override + ConsumerState createState() => _DiscountsScreenState(); +} + +class _DiscountsScreenState extends ConsumerState { + late Future> _future; + String _searchQuery = ''; + final _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _load() { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + setState(() { + _future = DiscountRepository.instance.listDiscounts(tenantId); + }); + } + + Future _showSheet({ClinicDiscount? existing}) async { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _DiscountSheet( + labTenantId: tenantId, + existing: existing, + ), + ); + if (result == true) _load(); + } + + Future _delete(ClinicDiscount discount) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('İndirimi Sil'), + content: Text( + '${discount.clinicName ?? 'Tüm Klinikler'} — ${discount.displayValue} indirimi silinsin mi?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('İptal')), + FilledButton( + style: + FilledButton.styleFrom(backgroundColor: AppColors.cancelled), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Sil'), + ), + ], + ), + ); + if (confirmed != true) return; + try { + await DiscountRepository.instance.deleteDiscount(discount.id); + _load(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Hata: $e'), + backgroundColor: AppColors.cancelled), + ); + } + } + } + + Future _toggleActive(ClinicDiscount discount) async { + try { + await DiscountRepository.instance + .updateDiscount(discount.id, isActive: !discount.isActive); + _load(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Hata: $e'), + backgroundColor: AppColors.cancelled), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: GradientAppBar( + title: 'İndirimler', + category: 'LABORATUVAR', + searchController: _searchController, + onSearchChanged: (v) => setState(() => _searchQuery = v), + searchHint: 'Klinik veya ürün tipi ara...', + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _showSheet(), + backgroundColor: AppColors.accent, + foregroundColor: Colors.white, + icon: const Icon(Icons.add), + label: const Text('Yeni İndirim'), + ), + body: FutureBuilder>( + future: _future, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator(color: AppColors.accent)); + } + if (snap.hasError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.wifi_off_rounded, + color: AppColors.cancelled, size: 40), + const SizedBox(height: 12), + Text('Hata: ${snap.error}', + style: + const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh_rounded, size: 16), + label: const Text('Tekrar Dene')), + ], + ), + ); + } + + final allDiscounts = snap.data!; + final q = _searchQuery.toLowerCase().trim(); + final discounts = q.isEmpty + ? allDiscounts + : allDiscounts + .where((d) => + (d.clinicName ?? 'tüm klinikler') + .toLowerCase() + .contains(q) || + d.prostheticLabel.toLowerCase().contains(q)) + .toList(); + + if (allDiscounts.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(20), + ), + child: const Icon(Icons.discount_outlined, + size: 32, color: AppColors.inProgress), + ), + const SizedBox(height: 16), + const Text('Henüz indirim tanımlanmadı', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary)), + const SizedBox(height: 8), + const Text( + 'Klinik ve ürün bazlı özel indirimler ekleyin.', + style: TextStyle( + color: AppColors.textSecondary, fontSize: 13), + textAlign: TextAlign.center), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: () => _showSheet(), + icon: const Icon(Icons.add), + label: const Text('İlk İndirimi Ekle'), + ), + ], + ), + ); + } + + if (discounts.isEmpty) { + return const Center( + child: Text('Sonuç bulunamadı', + style: TextStyle(color: AppColors.textSecondary)), + ); + } + + final active = discounts.where((d) => d.isActive).toList(); + final inactive = discounts.where((d) => !d.isActive).toList(); + + return RefreshIndicator( + color: AppColors.accent, + onRefresh: () async => _load(), + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 100), + children: [ + if (active.isNotEmpty) ...[ + _GroupHeader('Aktif (${active.length})'), + for (final d in active) + _DiscountCard( + discount: d, + onEdit: () => _showSheet(existing: d), + onDelete: () => _delete(d), + onToggle: () => _toggleActive(d), + ), + ], + if (inactive.isNotEmpty) ...[ + const SizedBox(height: 8), + _GroupHeader('Pasif (${inactive.length})'), + for (final d in inactive) + _DiscountCard( + discount: d, + onEdit: () => _showSheet(existing: d), + onDelete: () => _delete(d), + onToggle: () => _toggleActive(d), + ), + ], + ], + ), + ); + }, + ), + ); + } +} + +// ── Group header ────────────────────────────────────────────────────────────── + +class _GroupHeader extends StatelessWidget { + const _GroupHeader(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8, top: 4), + child: Text(text, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textMuted, + letterSpacing: 0.5)), + ); + } +} + +// ── Discount card ───────────────────────────────────────────────────────────── + +class _DiscountCard extends StatelessWidget { + const _DiscountCard({ + required this.discount, + required this.onEdit, + required this.onDelete, + required this.onToggle, + }); + + final ClinicDiscount discount; + final VoidCallback onEdit; + final VoidCallback onDelete; + final VoidCallback onToggle; + + @override + Widget build(BuildContext context) { + final d = discount; + final isActive = d.isActive; + + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Material( + color: isActive ? AppColors.surface : AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onEdit, + borderRadius: BorderRadius.circular(14), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isActive ? AppColors.border : AppColors.muted), + boxShadow: isActive + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 6, + offset: const Offset(0, 2)) + ] + : [], + ), + child: IntrinsicHeight( + child: Row( + children: [ + Container( + width: 4, + decoration: BoxDecoration( + color: + isActive ? AppColors.success : AppColors.border, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(14), + bottomLeft: Radius.circular(14), + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 4, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: isActive + ? AppColors.successBg + : AppColors.background, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${d.displayValue} İndirim', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: isActive + ? AppColors.success + : AppColors.textMuted, + ), + ), + ), + if (d.minQuantity > 0) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, vertical: 4), + decoration: BoxDecoration( + color: AppColors.pendingBg, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '≥${d.minQuantity} adet', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.pending), + ), + ), + ], + const Spacer(), + Transform.scale( + scale: 0.8, + child: Switch( + value: isActive, + onChanged: (_) => onToggle(), + activeColor: AppColors.success, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + _Tag( + icon: Icons.local_hospital_outlined, + label: d.appliesToAll + ? 'Tüm Klinikler' + : (d.clinicName ?? 'Klinik'), + color: AppColors.inProgress, + ), + const SizedBox(width: 6), + _Tag( + icon: Icons.science_outlined, + label: d.prostheticLabel, + color: AppColors.accent, + ), + ], + ), + if (d.notes != null && d.notes!.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(d.notes!, + style: const TextStyle( + fontSize: 12, + color: AppColors.textMuted), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + ], + ), + ), + ), + IconButton( + onPressed: onDelete, + icon: const Icon(Icons.delete_outline_rounded, + size: 18, color: AppColors.cancelled), + tooltip: 'Sil', + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _Tag extends StatelessWidget { + const _Tag( + {required this.icon, required this.label, required this.color}); + final IconData icon; + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 11, color: color), + const SizedBox(width: 4), + Text(label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: color)), + ], + ), + ); + } +} + +// ── Discount sheet ──────────────────────────────────────────────────────────── + +class _DiscountSheet extends StatefulWidget { + const _DiscountSheet({required this.labTenantId, this.existing}); + final String labTenantId; + final ClinicDiscount? existing; + + @override + State<_DiscountSheet> createState() => _DiscountSheetState(); +} + +class _DiscountSheetState extends State<_DiscountSheet> { + final _valueCtrl = TextEditingController(); + final _minQtyCtrl = TextEditingController(); + final _notesCtrl = TextEditingController(); + + DiscountType _discountType = DiscountType.percentage; + String? _selectedClinicId; + String? _selectedType; + bool _isActive = true; + bool _saving = false; + + List<_ClinicOption>? _clinics; + + @override + void initState() { + super.initState(); + final e = widget.existing; + if (e != null) { + _valueCtrl.text = e.discountValue.toStringAsFixed( + e.discountValue % 1 == 0 ? 0 : 2); + _minQtyCtrl.text = + e.minQuantity > 0 ? e.minQuantity.toString() : ''; + _notesCtrl.text = e.notes ?? ''; + _discountType = e.discountType; + _selectedClinicId = e.clinicTenantId; + _selectedType = e.prostheticType; + _isActive = e.isActive; + } + _loadClinics(); + } + + Future _loadClinics() async { + try { + final pb = PocketBaseClient.instance.pb; + final result = await pb.collection('tenant_connections').getList( + filter: + 'lab_tenant_id = "${widget.labTenantId}" && status = "approved"', + expand: 'clinic_tenant_id', + perPage: 100, + ); + final clinics = result.items.map((r) { + final j = r.toJson(); + final expand = j['expand'] as Map?; + final clinic = + expand?['clinic_tenant_id'] as Map?; + return _ClinicOption( + id: j['clinic_tenant_id'] as String? ?? '', + name: clinic?['company_name'] as String? ?? 'Klinik', + ); + }).where((c) => c.id.isNotEmpty).toList(); + if (mounted) setState(() => _clinics = clinics); + } catch (_) { + if (mounted) setState(() => _clinics = []); + } + } + + @override + void dispose() { + _valueCtrl.dispose(); + _minQtyCtrl.dispose(); + _notesCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + final valueStr = _valueCtrl.text.trim().replaceAll(',', '.'); + final value = double.tryParse(valueStr); + if (value == null || value <= 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Geçerli bir indirim değeri girin.'), + backgroundColor: AppColors.cancelled), + ); + return; + } + if (_discountType == DiscountType.percentage && value > 100) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Yüzde indirim 100'ü geçemez."), + backgroundColor: AppColors.cancelled), + ); + return; + } + + final minQty = int.tryParse(_minQtyCtrl.text.trim()) ?? 0; + setState(() => _saving = true); + final navigator = Navigator.of(context); + final messenger = ScaffoldMessenger.of(context); + try { + if (widget.existing != null) { + await DiscountRepository.instance.updateDiscount( + widget.existing!.id, + clinicTenantId: _selectedClinicId ?? '', + prostheticType: _selectedType ?? '', + discountType: _discountType, + discountValue: value, + minQuantity: minQty, + isActive: _isActive, + notes: _notesCtrl.text.trim(), + ); + } else { + await DiscountRepository.instance.createDiscount( + labTenantId: widget.labTenantId, + clinicTenantId: _selectedClinicId, + prostheticType: _selectedType, + discountType: _discountType, + discountValue: value, + minQuantity: minQty, + isActive: _isActive, + notes: _notesCtrl.text.trim(), + ); + } + navigator.pop(true); + } catch (e) { + if (mounted) setState(() => _saving = false); + messenger.showSnackBar( + SnackBar( + content: Text('Hata: $e'), + backgroundColor: AppColors.cancelled), + ); + } + } + + static const _prostheticTypes = [ + ('', 'Tüm Türler'), + ('metal_porselen', 'Metal Porselen'), + ('zirkonyum', 'Zirkonyum'), + ('implant_ustu_zirkonyum', 'İmplant Üstü Zirkonyum'), + ('gecici', 'Geçici'), + ('e_max', 'E-Max'), + ('tam_protez', 'Tam Protez'), + ('parsiyel', 'Parsiyel Protez'), + ('diger', 'Diğer'), + ]; + + @override + Widget build(BuildContext context) { + final bottom = MediaQuery.paddingOf(context).bottom; + return Container( + decoration: const BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: EdgeInsets.only(bottom: bottom), + child: SingleChildScrollView( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.viewInsetsOf(context).bottom + 20, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: BorderRadius.circular(2)), + ), + ), + const SizedBox(height: 16), + Text( + widget.existing != null + ? 'İndirimi Düzenle' + : 'Yeni İndirim', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary), + ), + const SizedBox(height: 20), + + const Text('İndirim Türü', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary)), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _TypeButton( + label: 'Yüzde (%)', + icon: Icons.percent_rounded, + selected: _discountType == DiscountType.percentage, + onTap: () => setState( + () => _discountType = DiscountType.percentage), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _TypeButton( + label: 'Sabit Tutar', + icon: Icons.currency_lira_rounded, + selected: _discountType == DiscountType.fixed, + onTap: () => + setState(() => _discountType = DiscountType.fixed), + ), + ), + ], + ), + const SizedBox(height: 16), + + const Text('İndirim Değeri', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary)), + const SizedBox(height: 8), + TextField( + controller: _valueCtrl, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + hintText: _discountType == DiscountType.percentage + ? 'Örn: 10' + : 'Örn: 150', + suffixText: + _discountType == DiscountType.percentage ? '%' : 'TL', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12)), + ), + ), + const SizedBox(height: 16), + + const Text('Klinik', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary)), + const SizedBox(height: 8), + _ClinicDropdown( + selectedId: _selectedClinicId, + clinics: _clinics, + onChanged: (id, _) => setState(() { + _selectedClinicId = id; + }), + ), + const SizedBox(height: 16), + + const Text('Ürün Tipi', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary)), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _selectedType ?? '', + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 12), + ), + items: _prostheticTypes + .map((t) => + DropdownMenuItem(value: t.$1, child: Text(t.$2))) + .toList(), + onChanged: (v) => + setState(() => _selectedType = v == '' ? null : v), + ), + const SizedBox(height: 16), + + const Text('Minimum Sipariş Adedi (İsteğe Bağlı)', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary)), + const SizedBox(height: 4), + const Text( + 'Aylık bu adede ulaşılınca indirim devreye girer. 0 = koşulsuz.', + style: + TextStyle(fontSize: 11, color: AppColors.textMuted)), + const SizedBox(height: 8), + TextField( + controller: _minQtyCtrl, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: '0', + suffixText: 'adet', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12)), + ), + ), + const SizedBox(height: 16), + + const Text('Not (İsteğe Bağlı)', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary)), + const SizedBox(height: 8), + TextField( + controller: _notesCtrl, + maxLines: 2, + decoration: InputDecoration( + hintText: 'Açıklama...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12)), + ), + ), + const SizedBox(height: 16), + + Row( + children: [ + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Aktif', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary)), + Text('Pasif indirimler uygulanmaz.', + style: TextStyle( + fontSize: 12, color: AppColors.textMuted)), + ], + ), + ), + Switch( + value: _isActive, + onChanged: (v) => setState(() => _isActive = v), + activeColor: AppColors.success, + ), + ], + ), + const SizedBox(height: 24), + + SizedBox( + width: double.infinity, + height: 50, + child: FilledButton( + onPressed: _saving ? null : _save, + style: FilledButton.styleFrom( + backgroundColor: AppColors.accent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14)), + ), + child: _saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : Text( + widget.existing != null ? 'Güncelle' : 'Kaydet', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600)), + ), + ), + ], + ), + ), + ); + } +} + +class _TypeButton extends StatelessWidget { + const _TypeButton({ + required this.label, + required this.icon, + required this.selected, + required this.onTap, + }); + final String label; + final IconData icon; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: selected ? AppColors.accent : AppColors.background, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: selected ? AppColors.accent : AppColors.border), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, + size: 16, + color: selected ? Colors.white : AppColors.textSecondary), + const SizedBox(width: 6), + Text(label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: selected + ? Colors.white + : AppColors.textSecondary)), + ], + ), + ), + ); + } +} + +class _ClinicDropdown extends StatelessWidget { + const _ClinicDropdown({ + required this.selectedId, + required this.clinics, + required this.onChanged, + }); + + final String? selectedId; + final List<_ClinicOption>? clinics; + final void Function(String? id, String? name) onChanged; + + @override + Widget build(BuildContext context) { + if (clinics == null) { + return Container( + height: 48, + decoration: BoxDecoration( + border: Border.all(color: AppColors.border), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2))), + ); + } + + final items = >[ + const DropdownMenuItem(value: '', child: Text('Tüm Klinikler')), + for (final c in clinics!) + DropdownMenuItem(value: c.id, child: Text(c.name)), + ]; + + return DropdownButtonFormField( + value: selectedId ?? '', + decoration: InputDecoration( + border: + OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + ), + items: items, + onChanged: (v) { + if (v == null || v.isEmpty) { + onChanged(null, null); + } else { + final clinic = clinics!.firstWhere((c) => c.id == v); + onChanged(v, clinic.name); + } + }, + ); + } +} diff --git a/lib/features/lab/finance/lab_finance_repository.dart b/lib/features/lab/finance/lab_finance_repository.dart new file mode 100644 index 0000000..07646fc --- /dev/null +++ b/lib/features/lab/finance/lab_finance_repository.dart @@ -0,0 +1,42 @@ +import 'package:pocketbase/pocketbase.dart'; +import '../../../core/api/pocketbase_client.dart'; +import '../../../models/finance_entry.dart'; + +class LabFinanceRepository { + LabFinanceRepository._(); + static final instance = LabFinanceRepository._(); + + PocketBase get _pb => PocketBaseClient.instance.pb; + + Future> listEntries( + String tenantId, { + String? status, + int page = 1, + int limit = 30, + }) async { + final filterParts = ['tenant_id = "$tenantId"', 'type = "receivable"']; + if (status != null) filterParts.add('status = "$status"'); + + final result = await _pb.collection('finance_entries').getList( + page: page, + perPage: limit, + filter: filterParts.join(' && '), + expand: 'job_id', + ); + return (result.items.map((r) => FinanceEntry.fromJson(r.toJson())).toList() + ..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? ''))); + } + + Future> summary(String tenantId) async { + final all = await listEntries(tenantId, limit: 200); + double pending = 0, paid = 0; + for (final e in all) { + if (e.status == FinanceStatus.pending) { + pending += e.amount; + } else { + paid += e.amount; + } + } + return {'pending': pending, 'paid': paid}; + } +} diff --git a/lib/features/lab/finance/lab_finance_screen.dart b/lib/features/lab/finance/lab_finance_screen.dart new file mode 100644 index 0000000..56f342d --- /dev/null +++ b/lib/features/lab/finance/lab_finance_screen.dart @@ -0,0 +1,467 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/providers/auth_provider.dart'; +import '../../../core/providers/locale_provider.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/utils/currency_formatter.dart'; +import '../../../core/widgets/gradient_app_bar.dart'; +import '../../../core/widgets/pill_tabs.dart'; +import '../../../models/finance_entry.dart'; +import 'lab_finance_repository.dart'; + +enum _FinanceSort { newestFirst, byAmountDesc, byAmountAsc } + +class LabFinanceScreen extends ConsumerStatefulWidget { + const LabFinanceScreen({super.key}); + + @override + ConsumerState createState() => _LabFinanceScreenState(); +} + +class _LabFinanceScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + late Future<_FinanceData> _future; + _FinanceSort _sort = _FinanceSort.newestFirst; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _tabController.addListener(() { + if (mounted) setState(() {}); + }); + _load(); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + void _load() { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + setState(() { + _future = Future.wait([ + LabFinanceRepository.instance.listEntries(tenantId, status: 'pending'), + LabFinanceRepository.instance.listEntries(tenantId, status: 'paid'), + LabFinanceRepository.instance.summary(tenantId), + ]).then((results) => _FinanceData( + pending: results[0] as List, + paid: results[1] as List, + summary: results[2] as Map, + )); + }); + } + + Future _showSortOptions() async { + final s = ref.read(stringsProvider); + final result = await showSortSheet( + context, + title: s.sort, + options: [s.sortNewest, s.sortAmountDesc, s.sortAmountAsc], + current: _sort.index, + ); + if (result != null) { + setState(() => _sort = _FinanceSort.values[result]); + } + } + + List _sorted(List entries) { + final list = List.from(entries); + switch (_sort) { + case _FinanceSort.newestFirst: + list.sort((a, b) { + final da = a.dateCreated != null ? DateTime.tryParse(a.dateCreated!) : null; + final db = b.dateCreated != null ? DateTime.tryParse(b.dateCreated!) : null; + if (da == null && db == null) return 0; + if (da == null) return 1; + if (db == null) return -1; + return db.compareTo(da); + }); + case _FinanceSort.byAmountDesc: + list.sort((a, b) => b.amount.compareTo(a.amount)); + case _FinanceSort.byAmountAsc: + list.sort((a, b) => a.amount.compareTo(b.amount)); + } + return list; + } + + String _formatDate(String? raw) { + if (raw == null) return ''; + try { + final dt = DateTime.parse(raw); + return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}'; + } catch (_) { + return ''; + } + } + + @override + Widget build(BuildContext context) { + final isSortActive = _sort != _FinanceSort.newestFirst; + final s = ref.watch(stringsProvider); + final currencyCode = + ref.watch(authProvider).activeTenant?.tenant.defaultCurrency ?? 'TRY'; + String formatAmount(double amount) => + CurrencyFormatter.format(amount, currencyCode); + + return Scaffold( + backgroundColor: AppColors.background, + appBar: GradientAppBar( + title: s.finance, + category: s.laboratoryCategory, + actions: [ + IconButton( + onPressed: _showSortOptions, + tooltip: 'Sırala', + icon: Badge( + isLabelVisible: isSortActive, + smallSize: 8, + backgroundColor: AppColors.accent, + child: const Icon(Icons.sort_rounded), + ), + ), + ], + ), + body: RefreshIndicator( + color: AppColors.accent, + onRefresh: () async => _load(), + child: FutureBuilder<_FinanceData>( + future: _future, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator(color: AppColors.accent)); + } + if (snap.hasError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(16)), + child: const Icon(Icons.wifi_off_rounded, + color: AppColors.cancelled, size: 30), + ), + const SizedBox(height: 16), + Text('Hata: ${snap.error}', + style: const TextStyle( + color: AppColors.textSecondary)), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Tekrar Dene'), + ), + ], + ), + ); + } + + final data = snap.data!; + final pendingTotal = data.summary['pending'] ?? 0.0; + final paidTotal = data.summary['paid'] ?? 0.0; + final pending = _sorted(data.pending); + final paid = _sorted(data.paid); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: _SummaryCard( + label: s.pendingReceivable, + amount: formatAmount(pendingTotal), + color: AppColors.pending, + bgColor: AppColors.pendingBg, + icon: Icons.hourglass_empty_rounded, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _SummaryCard( + label: s.collected, + amount: formatAmount(paidTotal), + color: AppColors.success, + bgColor: AppColors.successBg, + icon: Icons.check_circle_outline, + ), + ), + ], + ), + ), + PillTabs( + tabs: [s.pending, s.collected], + selected: _tabController.index, + onSelect: (i) => _tabController.animateTo(i), + counts: [pending.length, paid.length], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _EntriesList( + entries: pending, + emptyMessage: s.noPendingEntries, + emptyIcon: Icons.hourglass_empty_rounded, + formatDate: _formatDate, + formatAmount: formatAmount, + ), + _EntriesList( + entries: paid, + emptyMessage: s.noPaidEntries, + emptyIcon: Icons.check_circle_outline, + formatDate: _formatDate, + formatAmount: formatAmount, + ), + ], + ), + ), + ], + ); + }, + ), + ), + ); + } +} + +class _FinanceData { + const _FinanceData({ + required this.pending, + required this.paid, + required this.summary, + }); + + final List pending; + final List paid; + final Map summary; +} + +class _SummaryCard extends StatelessWidget { + const _SummaryCard({ + required this.label, + required this.amount, + required this.color, + required this.bgColor, + required this.icon, + }); + + final String label; + final String amount; + final Color color; + final Color bgColor; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: bgColor, borderRadius: BorderRadius.circular(12)), + child: Icon(icon, color: color, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + amount, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: color, + height: 1), + ), + const SizedBox(height: 3), + Text(label, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500)), + ], + ), + ), + ], + ), + ); + } +} + +class _EntriesList extends StatelessWidget { + const _EntriesList({ + required this.entries, + required this.emptyMessage, + required this.emptyIcon, + required this.formatDate, + required this.formatAmount, + }); + + final List entries; + final String emptyMessage; + final IconData emptyIcon; + final String Function(String?) formatDate; + final String Function(double) formatAmount; + + @override + Widget build(BuildContext context) { + if (entries.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(20)), + child: Icon(emptyIcon, size: 32, color: AppColors.inProgress), + ), + const SizedBox(height: 16), + Text(emptyMessage, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary)), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + itemCount: entries.length, + itemBuilder: (ctx, i) { + final entry = entries[i]; + final isPending = entry.status == FinanceStatus.pending; + final statusColor = isPending ? AppColors.pending : AppColors.success; + final statusBg = isPending ? AppColors.pendingBg : AppColors.successBg; + + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2)) + ], + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: statusBg, + borderRadius: BorderRadius.circular(12)), + child: Icon( + isPending + ? Icons.hourglass_empty_rounded + : Icons.check_circle_outline, + color: statusColor, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.counterpartyName ?? 'Klinik', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary), + ), + if (entry.patientCode != null) ...[ + const SizedBox(height: 2), + Text( + 'Protokol: ${entry.patientCode}', + style: const TextStyle( + fontSize: 12, color: AppColors.textSecondary), + ), + ], + if (entry.dateCreated != null) ...[ + const SizedBox(height: 2), + Text( + formatDate(entry.dateCreated), + style: const TextStyle( + fontSize: 12, color: AppColors.textMuted), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + formatAmount(entry.amount), + style: TextStyle( + fontWeight: FontWeight.w700, + color: statusColor, + fontSize: 15, + ), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: statusBg, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + entry.status.label, + style: TextStyle( + color: statusColor, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/lab/jobs/lab_all_jobs_screen.dart b/lib/features/lab/jobs/lab_all_jobs_screen.dart new file mode 100644 index 0000000..482cbe7 --- /dev/null +++ b/lib/features/lab/jobs/lab_all_jobs_screen.dart @@ -0,0 +1,896 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/providers/auth_provider.dart'; +import '../../../core/services/realtime_service.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/gradient_app_bar.dart'; +import '../../../core/widgets/pill_tabs.dart'; +import '../../../models/job.dart'; +import 'lab_jobs_repository.dart'; + +enum _JobSort { newestFirst, oldestFirst, byDueDate, byType } + +const _kSortLabels = [ + 'Yeniden Eskiye', + 'Eskiden Yeniye', + 'Vade Tarihine Göre', + 'Türe Göre', +]; + +class LabAllJobsScreen extends ConsumerStatefulWidget { + const LabAllJobsScreen({super.key}); + + @override + ConsumerState createState() => _LabAllJobsScreenState(); +} + +class _LabAllJobsScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + final _searchController = TextEditingController(); + String _searchQuery = ''; + _JobSort _sort = _JobSort.newestFirst; + bool _bulkAccepting = false; + final Map _counts = { + 'all': null, + 'pending': null, + 'in_progress': null, + 'sent': null, + 'delivered': null, + }; + final _pendingTabKey = GlobalKey<_PendingJobsTabState>(); + + // null entry = Tümü (bütün statüsler) + static const List _statuses = [null, 'pending', 'in_progress', 'sent', 'delivered']; + static const _tabLabels = ['Tümü', 'Onay Bekleyen', 'Devam Eden', 'Gönderildi', 'Teslim Edildi']; + String _countKey(String? s) => s ?? 'all'; + + @override + void initState() { + super.initState(); + final isDelivery = ref.read(authProvider).activeTenant?.isDeliveryOnly ?? false; + _tabController = TabController(length: 5, vsync: this, initialIndex: isDelivery ? 3 : 0); + _tabController.addListener(() { + if (mounted) setState(() {}); + }); + _fetchAllCounts(); + } + + Future _fetchAllCounts() async { + final tenantId = ref.read(authProvider).activeTenant?.tenant.id; + if (tenantId == null) return; + final results = await Future.wait( + _statuses.map((s) => LabJobsRepository.instance.countByStatus(tenantId, s)), + ); + if (!mounted) return; + setState(() { + for (var i = 0; i < _statuses.length; i++) { + _counts[_countKey(_statuses[i])] = results[i]; + } + }); + } + + @override + void dispose() { + _tabController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + void _onSearchChanged(String value) { + setState(() => _searchQuery = value); + } + + Future _showSortOptions() async { + final result = await showSortSheet( + context, + title: 'Sıralama', + options: _kSortLabels, + current: _sort.index, + ); + if (result != null) { + setState(() => _sort = _JobSort.values[result]); + } + } + + Future _bulkAccept() async { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + setState(() => _bulkAccepting = true); + try { + await LabJobsRepository.instance.bulkAcceptPending(tenantId); + _pendingTabKey.currentState?._load(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Tüm işler kabul edildi')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } finally { + if (mounted) setState(() => _bulkAccepting = false); + } + } + + @override + Widget build(BuildContext context) { + final isSortActive = _sort != _JobSort.newestFirst; + final onPendingTab = _tabController.index == 1; + final pendingCount = _counts['pending']; + + return Scaffold( + backgroundColor: AppColors.background, + appBar: GradientAppBar( + title: 'İşler', + category: 'LABORATUVAR', + searchController: _searchController, + onSearchChanged: _onSearchChanged, + searchHint: 'Protokol, klinik veya tür ara...', + actions: [ + if (!onPendingTab) + IconButton( + onPressed: _showSortOptions, + tooltip: 'Sırala', + icon: Badge( + isLabelVisible: isSortActive, + smallSize: 8, + backgroundColor: AppColors.accent, + child: const Icon(Icons.sort_rounded), + ), + ), + ], + ), + floatingActionButton: onPendingTab && (pendingCount == null || pendingCount > 0) + ? FloatingActionButton.extended( + onPressed: _bulkAccepting ? null : _bulkAccept, + icon: _bulkAccepting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.done_all), + label: Text(_bulkAccepting ? 'Kabul ediliyor...' : 'Tümünü Kabul Et'), + backgroundColor: AppColors.pending, + foregroundColor: Colors.white, + ) + : null, + body: Column( + children: [ + PillTabs( + tabs: _tabLabels, + selected: _tabController.index, + onSelect: (i) => _tabController.animateTo(i), + counts: _statuses.map((s) => _counts[_countKey(s)]).toList(), + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _LabJobsTab( + status: null, + searchQuery: _searchQuery, + sort: _sort, + onCountLoaded: (c) => setState(() => _counts['all'] = c), + ), + _PendingJobsTab( + key: _pendingTabKey, + searchQuery: _searchQuery, + onCountLoaded: (c) => setState(() => _counts['pending'] = c), + ), + _LabJobsTab( + status: 'in_progress', + searchQuery: _searchQuery, + sort: _sort, + onCountLoaded: (c) => setState(() => _counts['in_progress'] = c), + ), + _LabJobsTab( + status: 'sent', + searchQuery: _searchQuery, + sort: _sort, + onCountLoaded: (c) => setState(() => _counts['sent'] = c), + ), + _LabJobsTab( + status: 'delivered', + searchQuery: _searchQuery, + sort: _sort, + onCountLoaded: (c) => setState(() => _counts['delivered'] = c), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ── Pending (Onay Bekleyen) tab ─────────────────────────────────────────────── + +class _PendingJobsTab extends ConsumerStatefulWidget { + const _PendingJobsTab({super.key, required this.searchQuery, this.onCountLoaded}); + final String searchQuery; + final void Function(int)? onCountLoaded; + + @override + ConsumerState<_PendingJobsTab> createState() => _PendingJobsTabState(); +} + +class _PendingJobsTabState extends ConsumerState<_PendingJobsTab> { + late Future> _future; + late UnsubFn _unsub; + + @override + void initState() { + super.initState(); + _load(); + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + _unsub = RealtimeService.instance.watch( + 'jobs', + filter: 'lab_tenant_id="$tenantId" && status="pending"', + onEvent: (_) { if (mounted) _load(); }, + ); + } + + @override + void dispose() { + _unsub(); + super.dispose(); + } + + void _load() { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + setState(() { + _future = LabJobsRepository.instance.listInbound(tenantId, status: 'pending', limit: 50); + }); + } + + Future _acceptJob(Job job) async { + try { + await LabJobsRepository.instance.acceptJob(job); + _load(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('İş kabul edildi')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } + } + + List _filtered(List jobs) { + final q = widget.searchQuery.toLowerCase().trim(); + if (q.isEmpty) return jobs; + return jobs.where((j) => + j.patientCode.toLowerCase().contains(q) || + (j.clinicName?.toLowerCase().contains(q) ?? false) || + j.prostheticType.label.toLowerCase().contains(q) + ).toList(); + } + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + color: AppColors.accent, + onRefresh: () async => _load(), + child: FutureBuilder>( + future: _future, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator(color: AppColors.accent)); + } + if (snap.hasError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(16), + ), + child: const Icon(Icons.wifi_off_rounded, color: AppColors.cancelled, size: 30), + ), + const SizedBox(height: 16), + Text('Hata: ${snap.error}', style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Tekrar Dene'), + ), + ], + ), + ); + } + + final all = snap.data!; + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onCountLoaded?.call(all.length); + }); + + final jobs = _filtered(all); + + if (jobs.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.successBg, + borderRadius: BorderRadius.circular(20), + ), + child: const Icon(Icons.inbox_outlined, color: AppColors.success, size: 32), + ), + const SizedBox(height: 16), + Text( + widget.searchQuery.isNotEmpty ? 'Sonuç bulunamadı' : 'Onay bekleyen iş yok', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + ), + if (widget.searchQuery.isEmpty) ...[ + const SizedBox(height: 6), + const Text('Tüm işler kabul edildi', style: TextStyle(color: AppColors.textSecondary, fontSize: 13)), + ], + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 100), + itemCount: jobs.length, + itemBuilder: (ctx, i) { + final job = jobs[i]; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _PendingJobCard( + job: job, + onAccept: () => _acceptJob(job), + ), + ); + }, + ); + }, + ), + ); + } +} + +class _PendingJobCard extends StatefulWidget { + const _PendingJobCard({required this.job, required this.onAccept}); + final Job job; + final VoidCallback onAccept; + + @override + State<_PendingJobCard> createState() => _PendingJobCardState(); +} + +class _PendingJobCardState extends State<_PendingJobCard> { + bool _accepting = false; + + @override + Widget build(BuildContext context) { + final job = widget.job; + return Dismissible( + key: ValueKey(job.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + decoration: BoxDecoration( + color: AppColors.success, + borderRadius: BorderRadius.circular(14), + ), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_rounded, color: Colors.white, size: 28), + SizedBox(height: 4), + Text('Kabul Et', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)), + ], + ), + ), + confirmDismiss: (_) async { + setState(() => _accepting = true); + try { + await LabJobsRepository.instance.acceptJob(job); + return true; + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + return false; + } finally { + if (mounted) setState(() => _accepting = false); + } + }, + child: Material( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: () => context.push('/lab/jobs/${job.id}'), + borderRadius: BorderRadius.circular(14), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 8, offset: const Offset(0, 2)), + ], + ), + child: IntrinsicHeight( + child: Row( + children: [ + Container( + width: 4, + decoration: const BoxDecoration( + color: AppColors.pending, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(14), + bottomLeft: Radius.circular(14), + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + job.patientCode, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary), + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.local_hospital_outlined, size: 12, color: AppColors.textMuted), + const SizedBox(width: 4), + Expanded( + child: Text( + job.clinicName ?? 'Klinik', + style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: AppColors.pendingBg, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + job.prostheticType.label, + style: const TextStyle(fontSize: 11, color: AppColors.pending, fontWeight: FontWeight.w600), + ), + ), + if (job.dueDate != null) ...[ + const SizedBox(width: 6), + const Icon(Icons.calendar_today_outlined, size: 11, color: AppColors.textMuted), + const SizedBox(width: 3), + Text( + '${job.dueDate!.day.toString().padLeft(2, '0')}.${job.dueDate!.month.toString().padLeft(2, '0')}.${job.dueDate!.year}', + style: const TextStyle(fontSize: 11, color: AppColors.textMuted), + ), + ], + ], + ), + ], + ), + ), + const SizedBox(width: 8), + _accepting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.success), + ) + : Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: AppColors.successBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.success.withValues(alpha: 0.3)), + ), + child: const Text( + 'Kabul Et', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.success), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _LabJobsTab extends ConsumerStatefulWidget { + const _LabJobsTab({ + required this.status, + required this.searchQuery, + required this.sort, + this.onCountLoaded, + }); + + final String? status; // null = tüm statüsler + final String searchQuery; + final _JobSort sort; + final void Function(int)? onCountLoaded; + + @override + ConsumerState<_LabJobsTab> createState() => _LabJobsTabState(); +} + +class _LabJobsTabState extends ConsumerState<_LabJobsTab> { + late Future> _future; + late UnsubFn _unsub; + + @override + void initState() { + super.initState(); + _load(); + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + _unsub = RealtimeService.instance.watch( + 'jobs', + filter: 'lab_tenant_id="$tenantId"', + onEvent: (_) { if (mounted) _load(); }, + ); + } + + @override + void dispose() { + _unsub(); + super.dispose(); + } + + void _load() { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + setState(() { + _future = LabJobsRepository.instance + .listInbound(tenantId, status: widget.status, limit: 50); + }); + } + + List _applyFilters(List jobs) { + var list = jobs; + + final q = widget.searchQuery.toLowerCase().trim(); + if (q.isNotEmpty) { + list = list.where((j) { + return j.patientCode.toLowerCase().contains(q) || + (j.clinicName?.toLowerCase().contains(q) ?? false) || + j.prostheticType.label.toLowerCase().contains(q) || + (j.currentStep?.label.toLowerCase().contains(q) ?? false); + }).toList(); + } + + final sorted = List.from(list); + switch (widget.sort) { + case _JobSort.newestFirst: + sorted.sort((a, b) => b.dateCreated.compareTo(a.dateCreated)); + case _JobSort.oldestFirst: + sorted.sort((a, b) => a.dateCreated.compareTo(b.dateCreated)); + case _JobSort.byDueDate: + sorted.sort((a, b) { + if (a.dueDate == null && b.dueDate == null) return 0; + if (a.dueDate == null) return 1; + if (b.dueDate == null) return -1; + return a.dueDate!.compareTo(b.dueDate!); + }); + case _JobSort.byType: + sorted.sort( + (a, b) => a.prostheticType.label.compareTo(b.prostheticType.label)); + } + return sorted; + } + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + color: AppColors.accent, + onRefresh: () async => _load(), + child: FutureBuilder>( + future: _future, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator(color: AppColors.accent)); + } + if (snap.hasError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(16)), + child: const Icon(Icons.wifi_off_rounded, + color: AppColors.cancelled, size: 30), + ), + const SizedBox(height: 16), + Text('Hata: ${snap.error}', + style: + const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Tekrar Dene'), + ), + ], + ), + ); + } + + final all = snap.data!; + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onCountLoaded?.call(all.length); + }); + + final jobs = _applyFilters(all); + + if (jobs.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(20)), + child: const Icon(Icons.work_off_outlined, + color: AppColors.inProgress, size: 32), + ), + const SizedBox(height: 16), + Text( + widget.searchQuery.isNotEmpty + ? 'Sonuç bulunamadı' + : 'Henüz iş yok', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary), + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + itemCount: jobs.length, + itemBuilder: (ctx, i) { + final job = jobs[i]; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _LabJobCard( + job: job, + onTap: () => context.push('/lab/jobs/${job.id}'), + ), + ); + }, + ); + }, + ), + ); + } +} + +class _LabJobCard extends StatelessWidget { + const _LabJobCard({required this.job, required this.onTap}); + + final Job job; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final isOverdue = + job.dueDate != null && job.dueDate!.isBefore(DateTime.now()); + final accentColor = _statusColor(job.status); + + return Semantics( + label: job.patientCode, + button: true, + excludeSemantics: true, + child: Material( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: IntrinsicHeight( + child: Row( + children: [ + Container( + width: 4, + decoration: BoxDecoration( + color: accentColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(14), + bottomLeft: Radius.circular(14), + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + job.patientCode, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + if (job.currentStep != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + job.currentStep!.label, + style: const TextStyle( + color: AppColors.inProgress, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 5), + Row( + children: [ + const Icon(Icons.local_hospital_outlined, + size: 12, color: AppColors.textMuted), + const SizedBox(width: 4), + Expanded( + child: Text( + job.clinicName ?? 'Klinik', + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 5), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + job.prostheticType.label, + style: const TextStyle( + fontSize: 11, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ), + if (job.dueDate != null) ...[ + const SizedBox(width: 8), + Icon(Icons.calendar_today_outlined, + size: 11, + color: isOverdue + ? AppColors.cancelled + : AppColors.textMuted), + const SizedBox(width: 3), + Text( + _fmt(job.dueDate!), + style: TextStyle( + fontSize: 11, + color: isOverdue + ? AppColors.cancelled + : AppColors.textMuted, + fontWeight: isOverdue + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ], + ], + ), + ], + ), + ), + ), + const Padding( + padding: EdgeInsets.only(right: 10), + child: Icon(Icons.chevron_right, + color: AppColors.textMuted, size: 20), + ), + ], + ), + ), + ), + ), + ), + ); + } + + String _fmt(DateTime d) => + '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}'; + + Color _statusColor(JobStatus status) { + switch (status) { + case JobStatus.pending: + return AppColors.pending; + case JobStatus.inProgress: + return AppColors.inProgress; + case JobStatus.sent: + return AppColors.accent; + case JobStatus.delivered: + return AppColors.success; + case JobStatus.cancelled: + return AppColors.cancelled; + } + } +} diff --git a/lib/features/lab/jobs/lab_job_detail_screen.dart b/lib/features/lab/jobs/lab_job_detail_screen.dart new file mode 100644 index 0000000..e46d171 --- /dev/null +++ b/lib/features/lab/jobs/lab_job_detail_screen.dart @@ -0,0 +1,764 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/providers/auth_provider.dart'; +import '../../../core/services/realtime_service.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../models/job.dart'; +import '../../../models/job_file.dart'; +import '../../../features/shared/job_files_repository.dart'; +import '../../../features/shared/job_files_panel.dart'; +import '../../../core/services/job_history_service.dart'; +import 'lab_jobs_repository.dart'; + +// ── Adaptive sheet helper ──────────────────────────────────────────────────── + +void _showAdaptive(BuildContext context, Widget content) { + final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + if (isDesktop) { + showDialog( + context: context, + builder: (_) => Dialog( + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: content, + ), + ), + ); + } else { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => content, + ); + } +} + +class LabJobDetailScreen extends ConsumerStatefulWidget { + const LabJobDetailScreen({super.key, required this.jobId}); + final String jobId; + + @override + ConsumerState createState() => _LabJobDetailScreenState(); +} + +class _LabJobDetailScreenState extends ConsumerState { + Job? _job; + bool _loadingJob = false; + String? _loadError; + bool _isActing = false; + late Future> _filesFuture; + late UnsubFn _unsub; + + @override + void initState() { + super.initState(); + _load(); + _loadFiles(); + _unsub = RealtimeService.instance.watch( + 'jobs', + topic: widget.jobId, + onEvent: (_) { if (mounted && !_isActing) _load(); }, + ); + } + + @override + void dispose() { + _unsub(); + super.dispose(); + } + + Future _load() async { + setState(() { _loadingJob = true; _loadError = null; }); + try { + final job = await LabJobsRepository.instance.getJob(widget.jobId); + if (mounted) setState(() { _job = job; _loadingJob = false; }); + } catch (e) { + if (mounted) setState(() { _loadError = e.toString(); _loadingJob = false; }); + } + } + + void _loadFiles() { + setState(() { + _filesFuture = JobFilesRepository.instance.listForJob(widget.jobId); + }); + } + + Future _cancelJob(Job job) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('İşi İptal Et'), + content: const Text('Bu iş geri alınamaz şekilde iptal edilir. Onaylıyor musunuz?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Vazgeç')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('İptal Et'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _isActing = true); + try { + final updated = await LabJobsRepository.instance.cancelJob(job.id, job); + if (mounted) { + setState(() { _job = _job!.copyWith(status: updated.status); _isActing = false; }); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('İş iptal edildi.'))); + } + } catch (e) { + if (mounted) { + setState(() => _isActing = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e'))); + } + } + } + + Future _acceptJob(Job job) async { + setState(() => _isActing = true); + try { + final updated = await LabJobsRepository.instance.acceptJob(job); + if (mounted) { + setState(() { _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName); _isActing = false; }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('İş kabul edildi')), + ); + } + } catch (e) { + if (mounted) { + setState(() => _isActing = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e'))); + } + } + } + + void _showHandToClinicSheet(Job job) { + _showAdaptive( + context, + _HandToClinicSheet( + job: job, + onDone: (Job updated) { + if (mounted) setState(() => _job = updated.copyWith(clinicName: job.clinicName, labName: job.labName)); + }, + ), + ); + } + + Color _statusColor(JobStatus status) { + return switch (status) { + JobStatus.pending => AppColors.pending, + JobStatus.inProgress => AppColors.inProgress, + JobStatus.sent => AppColors.accent, + JobStatus.delivered => AppColors.success, + JobStatus.cancelled => AppColors.cancelled, + }; + } + + Color _statusBg(JobStatus status) { + return switch (status) { + JobStatus.pending => AppColors.pendingBg, + JobStatus.inProgress => AppColors.inProgressBg, + JobStatus.sent => AppColors.inProgressBg, + JobStatus.delivered => AppColors.successBg, + JobStatus.cancelled => AppColors.cancelledBg, + }; + } + + String _formatDate(DateTime dt, {bool withTime = false}) { + final d = '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}'; + if (!withTime || (dt.hour == 0 && dt.minute == 0)) return d; + return '$d ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('İş Detayı'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_loadingJob && _job == null) { + return const Center( + child: CircularProgressIndicator(color: AppColors.accent)); + } + if (_loadError != null && _job == null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(16)), + child: const Icon(Icons.wifi_off_rounded, + color: AppColors.cancelled, size: 30), + ), + const SizedBox(height: 16), + Text('Hata: $_loadError', + style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Tekrar Dene'), + ), + ], + ), + ); + } + + if (_job == null) return const SizedBox.shrink(); + + { + final job = _job!; + final membership = ref.read(authProvider).activeTenant; + final isDeliveryOnly = membership?.isDeliveryOnly ?? false; + final canCancelJobs = membership?.canCancelJobs ?? true; + final canSendToClinic = !isDeliveryOnly && + job.status == JobStatus.inProgress && + job.location == JobLocation.atLab; + final canAccept = !isDeliveryOnly && job.status == JobStatus.pending; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // Header card + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + job.patientCode, + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.bold, + color: AppColors.textPrimary), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 5), + decoration: BoxDecoration( + color: _statusBg(job.status), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + job.status.label, + style: TextStyle( + color: _statusColor(job.status), + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + _InfoRow( + icon: Icons.business, + label: 'Klinik', + value: job.clinicName ?? '-'), + _InfoRow( + icon: Icons.medical_services_outlined, + label: 'Protez Tipi', + value: job.prostheticType.label), + _InfoRow( + icon: Icons.format_list_numbered, + label: 'Üye Sayısı', + value: '${job.memberCount} üye'), + if (job.color != null) + _InfoRow( + icon: Icons.color_lens_outlined, + label: 'Renk', + value: job.color!), + if (job.dueDate != null) + _InfoRow( + icon: Icons.calendar_today, + label: 'Teslim Tarihi', + value: _formatDate(job.dueDate!, withTime: true), + valueColor: job.dueDate!.isBefore(DateTime.now()) + ? AppColors.cancelled + : null), + _InfoRow( + icon: Icons.add_circle_outline, + label: 'Oluşturulma', + value: _formatDate(job.dateCreated)), + if (job.price != null && job.currency != null) + _InfoRow( + icon: Icons.attach_money, + label: 'Fiyat', + value: + '${job.price!.toStringAsFixed(2)} ${job.currency}'), + if (job.description != null && + job.description!.isNotEmpty) + _InfoRow( + icon: Icons.notes, + label: 'Açıklama', + value: job.description!), + ], + ), + ), + + const SizedBox(height: 16), + + // Stepper + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'İş Adımları', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w600, + color: AppColors.textPrimary), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: job.provaRequired + ? AppColors.inProgressBg + : AppColors.successBg, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + job.provaRequired ? 'Provalı' : 'Provasız', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: job.provaRequired + ? AppColors.inProgress + : AppColors.success, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + _JobStepper( + steps: job.stepTemplate, + currentStep: job.currentStep, + historyFuture: JobHistoryService.instance + .listForJob(job.id), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Action buttons + if (_isActing) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Center(child: CircularProgressIndicator(color: AppColors.accent)), + ) + else ...[ + if (canAccept) + FilledButton.icon( + onPressed: () => _acceptJob(job), + icon: const Icon(Icons.check_circle_outline), + label: const Text('Kabul Et'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + backgroundColor: AppColors.success, + ), + ), + + if (canSendToClinic) + FilledButton.icon( + onPressed: () => _showHandToClinicSheet(job), + icon: const Icon(Icons.send_outlined), + label: Text( + (job.isLastStep) + ? 'Son Prova - Teslime Gönder' + : 'Prova için Kliniğe Gönder', + ), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + backgroundColor: (job.isLastStep) + ? AppColors.success + : AppColors.inProgress, + ), + ), + + if (canCancelJobs && job.status == JobStatus.pending) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () => _cancelJob(job), + icon: const Icon(Icons.close_rounded), + label: const Text('İşi İptal Et'), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(50), + foregroundColor: AppColors.cancelled, + side: const BorderSide(color: AppColors.cancelled), + ), + ), + ], + ], + + const SizedBox(height: 20), + + JobFilesPanel( + job: job, + filesFuture: _filesFuture, + onRefresh: _loadFiles, + ), + + const SizedBox(height: 16), + ], + ); + } + } +} + +// ── Hand to Clinic Sheet ───────────────────────────────────────────────────── + +class _HandToClinicSheet extends StatefulWidget { + const _HandToClinicSheet({required this.job, required this.onDone}); + final Job job; + final void Function(Job updatedJob) onDone; + + @override + State<_HandToClinicSheet> createState() => _HandToClinicSheetState(); +} + +class _HandToClinicSheetState extends State<_HandToClinicSheet> { + final _noteController = TextEditingController(); + bool _sending = false; + + @override + void dispose() { + _noteController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + final isLast = widget.job.isLastStep; + final stepLabel = widget.job.currentStep?.label ?? ''; + final buttonLabel = isLast + ? (widget.job.provaRequired ? 'Son Prova · Teslime Gönder' : 'Teslime Gönder') + : '$stepLabel için Kliniğe Gönder'; + final buttonColor = isLast ? AppColors.success : AppColors.inProgress; + + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.vertical( + top: isDesktop ? Radius.zero : const Radius.circular(20), + ), + ), + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 24, + bottom: isDesktop + ? 24 + : MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + buttonLabel, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold, + color: AppColors.textPrimary), + ), + const SizedBox(height: 8), + Text( + isLast + ? 'İş teslim edilecek olarak işaretlenecek.' + : 'İş klinikteki prova için gönderilecek.', + style: const TextStyle(color: AppColors.textSecondary), + ), + const SizedBox(height: 16), + TextField( + controller: _noteController, + decoration: const InputDecoration( + labelText: 'Not (isteğe bağlı)', + hintText: 'Klinik için not ekleyin...', + ), + maxLines: 3, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _sending + ? null + : () async { + setState(() => _sending = true); + final navigator = Navigator.of(context); + final messenger = ScaffoldMessenger.of(context); + try { + final updated = await LabJobsRepository.instance.handToClinic( + widget.job.id, + widget.job, + note: _noteController.text.trim().isEmpty + ? null + : _noteController.text.trim(), + ); + navigator.pop(); + messenger.showSnackBar( + SnackBar( + content: Text(isLast + ? 'İş teslim için gönderildi' + : 'Prova için klinik\'e gönderildi')), + ); + if (context.mounted) widget.onDone(updated); + } catch (e) { + if (context.mounted) { + setState(() => _sending = false); + messenger.showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } + }, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + backgroundColor: buttonColor, + ), + child: _sending + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : Text(buttonLabel), + ), + ], + ), + ); + } +} + +// ── Info Row ───────────────────────────────────────────────────────────────── + +class _InfoRow extends StatelessWidget { + const _InfoRow({ + required this.icon, + required this.label, + required this.value, + this.valueColor, + }); + final IconData icon; + final String label; + final String value; + final Color? valueColor; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: AppColors.textMuted), + const SizedBox(width: 10), + SizedBox( + width: 110, + child: Text( + label, + style: const TextStyle(color: AppColors.textSecondary, fontSize: 13), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + fontWeight: FontWeight.w500, + color: valueColor ?? AppColors.textPrimary, + fontSize: 14, + ), + ), + ), + ], + ), + ); + } +} + +// ── Job Stepper ─────────────────────────────────────────────────────────────── + +class _JobStepper extends StatelessWidget { + const _JobStepper({ + required this.steps, + required this.currentStep, + required this.historyFuture, + }); + final List steps; + final JobStep? currentStep; + final Future> historyFuture; + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: historyFuture, + builder: (ctx, snap) { + final history = snap.data ?? []; + // Revizyon sayısı per adım + final Map revisionCounts = {}; + for (final e in history) { + if (e.action == JobHistoryAction.revisionRequested && e.step != null) { + revisionCounts[e.step!] = (revisionCounts[e.step!] ?? 0) + 1; + } + } + final currentIndex = + currentStep != null ? steps.indexOf(currentStep!) : -1; + + return Column( + children: List.generate(steps.length, (i) { + final step = steps[i]; + final isCompleted = i < currentIndex; + final isCurrent = i == currentIndex; + final isLastItem = i == steps.length - 1; + final revCount = revisionCounts[step] ?? 0; + + Color dotColor; + IconData dotIcon; + if (isCompleted) { + dotColor = AppColors.success; + dotIcon = Icons.check_circle; + } else if (isCurrent) { + dotColor = AppColors.inProgress; + dotIcon = Icons.radio_button_checked; + } else { + dotColor = AppColors.muted; + dotIcon = Icons.radio_button_unchecked; + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + Icon(dotIcon, color: dotColor, size: 24), + if (!isLastItem) + Container( + width: 2, + height: 44, + color: i < currentIndex + ? AppColors.success.withValues(alpha: 0.35) + : AppColors.border, + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 2, bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + step.label, + style: TextStyle( + fontWeight: isCurrent + ? FontWeight.bold + : FontWeight.normal, + color: isCompleted + ? AppColors.success + : isCurrent + ? AppColors.inProgress + : AppColors.textMuted, + fontSize: 15, + ), + ), + if (revCount > 0) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '$revCount revizyon', + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.cancelled, + ), + ), + ), + ], + ], + ), + if (isCurrent) + Text( + step.description, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ), + ], + ); + }), + ); + }, + ); + } +} + diff --git a/lib/features/lab/jobs/lab_jobs_inbound_screen.dart b/lib/features/lab/jobs/lab_jobs_inbound_screen.dart new file mode 100644 index 0000000..b8dea87 --- /dev/null +++ b/lib/features/lab/jobs/lab_jobs_inbound_screen.dart @@ -0,0 +1,335 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/providers/auth_provider.dart'; +import '../../../core/services/realtime_service.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/gradient_app_bar.dart'; +import '../../../models/job.dart'; +import 'lab_jobs_repository.dart'; + +class LabJobsInboundScreen extends ConsumerStatefulWidget { + const LabJobsInboundScreen({super.key}); + + @override + ConsumerState createState() => + _LabJobsInboundScreenState(); +} + +class _LabJobsInboundScreenState extends ConsumerState { + late Future> _future; + bool _acceptingAll = false; + late UnsubFn _unsub; + + @override + void initState() { + super.initState(); + _load(); + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + _unsub = RealtimeService.instance.watch( + 'jobs', + filter: "lab_tenant_id='$tenantId'", + onEvent: (_) { if (mounted) _load(); }, + ); + } + + @override + void dispose() { + _unsub(); + super.dispose(); + } + + void _load() { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + setState(() { + _future = + LabJobsRepository.instance.listInbound(tenantId, status: 'pending'); + }); + } + + Future _acceptJob(Job job) async { + try { + await LabJobsRepository.instance.acceptJob(job); + _load(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('İş kabul edildi')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } + } + + Future _bulkAccept() async { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + setState(() => _acceptingAll = true); + try { + await LabJobsRepository.instance.bulkAcceptPending(tenantId); + _load(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Tüm işler kabul edildi')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } finally { + if (mounted) setState(() => _acceptingAll = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: GradientAppBar( + title: 'Gelen İşler', + category: 'LABORATUVAR', + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _acceptingAll ? null : _bulkAccept, + icon: _acceptingAll + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.done_all), + label: + Text(_acceptingAll ? 'Kabul ediliyor...' : 'Tümünü Kabul Et'), + backgroundColor: AppColors.pending, + foregroundColor: Colors.white, + ), + body: RefreshIndicator( + onRefresh: () async => _load(), + child: FutureBuilder>( + future: _future, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Hata: ${snap.error}'), + const SizedBox(height: 12), + FilledButton( + onPressed: _load, + child: const Text('Tekrar Dene'), + ), + ], + ), + ); + } + + final jobs = snap.data!; + + if (jobs.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.inbox_outlined, size: 64, color: AppColors.textMuted), + const SizedBox(height: 16), + Text( + 'Bekleyen iş yok', + style: TextStyle( + fontSize: 16, color: AppColors.textSecondary), + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 80), + itemCount: jobs.length, + itemBuilder: (ctx, i) { + final job = jobs[i]; + return _InboundJobCard( + job: job, + onAccept: () => _acceptJob(job), + ); + }, + ); + }, + ), + ), + ); + } +} + +class _InboundJobCard extends StatefulWidget { + const _InboundJobCard({required this.job, required this.onAccept}); + final Job job; + final VoidCallback onAccept; + + @override + State<_InboundJobCard> createState() => _InboundJobCardState(); +} + +class _InboundJobCardState extends State<_InboundJobCard> { + bool _accepting = false; + + String _formatDate(DateTime dt) => + '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}'; + + @override + Widget build(BuildContext context) { + final job = widget.job; + return Semantics( + label: job.patientCode, + button: true, + excludeSemantics: true, + child: Dismissible( + key: ValueKey(job.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: AppColors.success, + borderRadius: BorderRadius.circular(12), + ), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check, color: Colors.white, size: 28), + SizedBox(height: 4), + Text('Kabul Et', + style: TextStyle(color: Colors.white, fontSize: 12)), + ], + ), + ), + confirmDismiss: (_) async { + setState(() => _accepting = true); + try { + await LabJobsRepository.instance.acceptJob(job); + return true; + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + return false; + } finally { + if (mounted) setState(() => _accepting = false); + } + }, + child: Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + CircleAvatar( + backgroundColor: AppColors.pendingBg, + child: const Icon(Icons.assignment_outlined, + color: AppColors.pending), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + job.patientCode, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 2), + Text( + job.clinicName ?? 'Klinik', + style: TextStyle( + color: AppColors.textSecondary, fontSize: 13), + ), + const SizedBox(height: 4), + Row( + children: [ + _Chip( + label: job.prostheticType.label, + color: AppColors.inProgressBg, + textColor: AppColors.inProgress, + ), + const SizedBox(width: 6), + _Chip( + label: '${job.memberCount} üye', + color: AppColors.background, + textColor: AppColors.textSecondary, + ), + ], + ), + const SizedBox(height: 4), + Text( + _formatDate(job.dateCreated), + style: TextStyle( + color: AppColors.textMuted, fontSize: 12), + ), + ], + ), + ), + const SizedBox(width: 8), + _accepting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : FilledButton( + onPressed: widget.onAccept, + style: FilledButton.styleFrom( + backgroundColor: AppColors.success, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Kabul Et', + style: TextStyle(fontSize: 13)), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _Chip extends StatelessWidget { + const _Chip( + {required this.label, + required this.color, + required this.textColor}); + final String label; + final Color color; + final Color textColor; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(10), + ), + child: Text(label, + style: TextStyle( + color: textColor, + fontSize: 11, + fontWeight: FontWeight.w500)), + ); + } +} diff --git a/lib/features/lab/jobs/lab_jobs_repository.dart b/lib/features/lab/jobs/lab_jobs_repository.dart new file mode 100644 index 0000000..711d2c2 --- /dev/null +++ b/lib/features/lab/jobs/lab_jobs_repository.dart @@ -0,0 +1,131 @@ +import 'dart:async'; +import 'package:pocketbase/pocketbase.dart'; +import '../../../core/api/pocketbase_client.dart'; +import '../../../core/services/job_history_service.dart'; +import '../../../models/job.dart'; + +const _listExpand = 'clinic_tenant_id,lab_tenant_id'; +const _detailExpand = 'clinic_tenant_id,lab_tenant_id,patient_id'; + +class LabJobsRepository { + LabJobsRepository._(); + static final instance = LabJobsRepository._(); + + PocketBase get _pb => PocketBaseClient.instance.pb; + + Future> listInbound( + String labTenantId, { + String? status, + int page = 1, + int limit = 30, + }) async { + final filterParts = ['lab_tenant_id = "$labTenantId"']; + if (status != null) filterParts.add('status = "$status"'); + + final result = await _pb.collection('jobs').getList( + page: page, + perPage: limit, + filter: filterParts.join(' && '), + expand: _listExpand, + ); + return (result.items.map((r) => Job.fromJson(r.toJson())).toList() + ..sort((a, b) => b.dateCreated.compareTo(a.dateCreated))); + } + + Future> listInProgress(String labTenantId, {int limit = 50, String? location}) async { + final filterParts = ['lab_tenant_id = "$labTenantId"', 'status = "in_progress"']; + if (location != null) filterParts.add('location = "$location"'); + final result = await _pb.collection('jobs').getList( + perPage: limit, + filter: filterParts.join(' && '), + expand: _listExpand, + ); + return (result.items.map((r) => Job.fromJson(r.toJson())).toList() + ..sort((a, b) { + if (a.dueDate == null && b.dueDate == null) return b.dateCreated.compareTo(a.dateCreated); + if (a.dueDate == null) return 1; + if (b.dueDate == null) return -1; + final cmp = a.dueDate!.compareTo(b.dueDate!); + return cmp != 0 ? cmp : b.dateCreated.compareTo(a.dateCreated); + })); + } + + Future getJob(String jobId) async { + final record = await _pb.collection('jobs').getOne(jobId, expand: _detailExpand); + return Job.fromJson(record.toJson()); + } + + Future acceptJob(Job pendingJob) async { + final firstStep = pendingJob.stepTemplate.first; + final record = await _pb.collection('jobs').update(pendingJob.id, body: { + 'status': 'in_progress', + 'current_step': firstStep.value, + 'location': 'at_lab', + }); + final job = Job.fromJson(record.toJson()); + unawaited(JobHistoryService.instance.append( + jobId: pendingJob.id, + clinicTenantId: job.clinicTenantId, + labTenantId: job.labTenantId, + action: JobHistoryAction.accepted, + step: firstStep, + )); + return job; + } + + Future handToClinic(String jobId, Job job, {String? note}) async { + final isFinal = job.currentStep == JobStep.cilaBitim; + final patch = isFinal + ? {'status': 'sent', 'location': 'at_clinic'} + : {'location': 'at_clinic'}; + + final record = await _pb.collection('jobs').update(jobId, body: patch); + final updated = Job.fromJson(record.toJson()); + unawaited(JobHistoryService.instance.append( + jobId: jobId, + clinicTenantId: job.clinicTenantId, + labTenantId: job.labTenantId, + action: JobHistoryAction.handedToClinic, + step: job.currentStep, + note: note, + )); + return updated; + } + + Future cancelJob(String jobId, Job job) async { + final record = await _pb.collection('jobs').update(jobId, body: { + 'status': 'cancelled', + }); + unawaited(JobHistoryService.instance.append( + jobId: jobId, + clinicTenantId: job.clinicTenantId, + labTenantId: job.labTenantId, + action: JobHistoryAction.cancelled, + step: job.currentStep, + )); + return Job.fromJson(record.toJson()); + } + + Future bulkAcceptPending(String labTenantId) async { + final pending = await listInbound(labTenantId, status: 'pending', limit: 200); + await Future.wait(pending.map((j) => acceptJob(j))); + } + + Future countByStatus(String labTenantId, String? status) async { + final filter = status != null + ? 'lab_tenant_id = "$labTenantId" && status = "$status"' + : 'lab_tenant_id = "$labTenantId"'; + final r = await _pb.collection('jobs').getList(perPage: 1, filter: filter); + return r.totalItems; + } + + Future countDelivered(String labTenantId, {DateTime? from, DateTime? to}) async { + final parts = ['lab_tenant_id = "$labTenantId"', 'status = "delivered"']; + if (from != null) parts.add('updated >= "${_date(from)}"'); + if (to != null) parts.add('updated < "${_date(to)}"'); + final r = await _pb.collection('jobs').getList(perPage: 1, filter: parts.join(' && ')); + return r.totalItems; + } + + static String _date(DateTime d) => d.toIso8601String().split('T').first; +} diff --git a/lib/features/lab/products/lab_products_repository.dart b/lib/features/lab/products/lab_products_repository.dart new file mode 100644 index 0000000..f00acb4 --- /dev/null +++ b/lib/features/lab/products/lab_products_repository.dart @@ -0,0 +1,39 @@ +import 'package:pocketbase/pocketbase.dart'; +import '../../../core/api/pocketbase_client.dart'; +import '../../../models/prosthetic_product.dart'; + +class LabProductsRepository { + LabProductsRepository._(); + static final instance = LabProductsRepository._(); + + PocketBase get _pb => PocketBaseClient.instance.pb; + + Future> listProducts( + String labTenantId, { + bool? isActive, + }) async { + final filterParts = ['lab_tenant_id = "$labTenantId"']; + if (isActive != null) filterParts.add('is_active = $isActive'); + + final result = await _pb.collection('prosthetic_products').getList( + filter: filterParts.join(' && '), + perPage: 200, + ); + return (result.items.map((r) => ProstheticProduct.fromJson(r.toJson())).toList() + ..sort((a, b) => a.name.compareTo(b.name))); + } + + Future createProduct(ProstheticProduct product) async { + final record = await _pb.collection('prosthetic_products').create(body: product.toJson()); + return ProstheticProduct.fromJson(record.toJson()); + } + + Future updateProduct(String id, Map patch) async { + final record = await _pb.collection('prosthetic_products').update(id, body: patch); + return ProstheticProduct.fromJson(record.toJson()); + } + + Future deleteProduct(String id) async { + await _pb.collection('prosthetic_products').delete(id); + } +} diff --git a/lib/features/lab/products/lab_products_screen.dart b/lib/features/lab/products/lab_products_screen.dart new file mode 100644 index 0000000..66d69a3 --- /dev/null +++ b/lib/features/lab/products/lab_products_screen.dart @@ -0,0 +1,618 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/providers/auth_provider.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/gradient_app_bar.dart'; +import '../../../models/prosthetic_product.dart'; +import 'lab_products_repository.dart'; + +const _prostheticTypes = [ + ('metal_porselen', 'Metal Porselen'), + ('zirkonyum', 'Zirkonyum'), + ('implant_ustu_zirkonyum', 'İmplant Üstü Zirkonyum'), + ('gecici', 'Geçici'), + ('e_max', 'E-Max'), + ('diger', 'Diğer'), +]; + +String _typeLabel(String value) { + for (final t in _prostheticTypes) { + if (t.$1 == value) return t.$2; + } + return value; +} + +// ── Adaptive sheet helper ──────────────────────────────────────────────────── + +void _showAdaptive(BuildContext context, Widget content) { + final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + if (isDesktop) { + showDialog( + context: context, + builder: (_) => Dialog( + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: content, + ), + ), + ); + } else { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => content, + ); + } +} + +class LabProductsScreen extends ConsumerStatefulWidget { + const LabProductsScreen({super.key}); + + @override + ConsumerState createState() => _LabProductsScreenState(); +} + +class _LabProductsScreenState extends ConsumerState { + late Future> _future; + final _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _load() { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + setState(() { + _future = LabProductsRepository.instance.listProducts(tenantId); + }); + } + + Future _toggleActive(ProstheticProduct product) async { + try { + await LabProductsRepository.instance + .updateProduct(product.id, {'is_active': !product.isActive}); + _load(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } + } + + Future _deleteProduct(ProstheticProduct product) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Ürünü Sil'), + content: Text( + '"${product.name}" ürününü silmek istediğinize emin misiniz?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('İptal'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: FilledButton.styleFrom( + backgroundColor: AppColors.cancelled), + child: const Text('Sil'), + ), + ], + ), + ); + if (confirmed != true) return; + try { + await LabProductsRepository.instance.deleteProduct(product.id); + _load(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Ürün silindi')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } + } + + void _showProductSheet({ProstheticProduct? existing}) { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + _showAdaptive( + context, + _ProductForm( + labTenantId: tenantId, + existing: existing, + onSaved: () { + Navigator.of(context).pop(); + _load(); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: GradientAppBar( + title: 'Ürün Kataloğu', + category: 'LABORATUVAR', + searchController: _searchController, + onSearchChanged: (v) => setState(() => _searchQuery = v), + searchHint: 'Ürün adı veya türü ara...', + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _showProductSheet(), + backgroundColor: AppColors.accent, + foregroundColor: Colors.white, + icon: const Icon(Icons.add), + label: const Text('Yeni Ürün'), + ), + body: RefreshIndicator( + color: AppColors.accent, + onRefresh: () async => _load(), + child: FutureBuilder>( + future: _future, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator(color: AppColors.accent)); + } + if (snap.hasError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(16)), + child: const Icon(Icons.wifi_off_rounded, + color: AppColors.cancelled, size: 30), + ), + const SizedBox(height: 16), + Text('Hata: ${snap.error}', + style: const TextStyle( + color: AppColors.textSecondary)), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Tekrar Dene')), + ], + ), + ); + } + + final allProducts = snap.data!; + final q = _searchQuery.toLowerCase().trim(); + final products = q.isEmpty + ? allProducts + : allProducts.where((p) => + p.name.toLowerCase().contains(q) || + _typeLabel(p.prostheticType).toLowerCase().contains(q)).toList(); + + if (allProducts.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(20)), + child: const Icon(Icons.inventory_2_outlined, + size: 32, color: AppColors.inProgress), + ), + const SizedBox(height: 16), + Text( + q.isNotEmpty ? 'Sonuç bulunamadı' : 'Henüz ürün eklenmedi', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary), + ), + const SizedBox(height: 12), + if (q.isEmpty) FilledButton.icon( + onPressed: () => _showProductSheet(), + icon: const Icon(Icons.add), + label: const Text('İlk Ürünü Ekle'), + ), + ], + ), + ); + } + + if (products.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(20)), + child: const Icon(Icons.search_off_rounded, + size: 32, color: AppColors.inProgress), + ), + const SizedBox(height: 16), + const Text( + 'Sonuç bulunamadı', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 80), + itemCount: products.length, + itemBuilder: (ctx, i) { + final product = products[i]; + final statusColor = + product.isActive ? AppColors.inProgress : AppColors.textMuted; + final statusBg = + product.isActive ? AppColors.inProgressBg : AppColors.surfaceVariant; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: GestureDetector( + onLongPress: () => _deleteProduct(product), + child: Material( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: () => _showProductSheet(existing: product), + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: + Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2)) + ]), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: statusBg, + borderRadius: BorderRadius.circular(12)), + child: Icon(Icons.medical_services_outlined, + color: statusColor, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.name, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: product.isActive + ? AppColors.textPrimary + : AppColors.textMuted), + ), + const SizedBox(height: 2), + Text( + _typeLabel(product.prostheticType), + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary), + ), + if (product.unitPrice != null) ...[ + const SizedBox(height: 2), + Text( + '${product.unitPrice!.toStringAsFixed(2)} ${product.currency ?? 'TRY'}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.success), + ), + ], + ], + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: product.isActive, + onChanged: (_) => _toggleActive(product), + activeTrackColor: AppColors.accent, + ), + IconButton( + icon: const Icon(Icons.edit_outlined, + color: AppColors.textSecondary, + size: 20), + onPressed: () => + _showProductSheet(existing: product), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + }, + ), + ), + ); + } +} + +// ── Product Form ───────────────────────────────────────────────────────────── + +class _ProductForm extends StatefulWidget { + const _ProductForm({ + required this.labTenantId, + required this.onSaved, + this.existing, + }); + + final String labTenantId; + final ProstheticProduct? existing; + final VoidCallback onSaved; + + @override + State<_ProductForm> createState() => _ProductFormState(); +} + +class _ProductFormState extends State<_ProductForm> { + final _formKey = GlobalKey(); + late final TextEditingController _nameCtrl; + late final TextEditingController _priceCtrl; + late final TextEditingController _descCtrl; + late String _selectedType; + late String _currency; + late bool _isActive; + bool _saving = false; + + @override + void initState() { + super.initState(); + final p = widget.existing; + _nameCtrl = TextEditingController(text: p?.name ?? ''); + _priceCtrl = TextEditingController( + text: p?.unitPrice != null ? p!.unitPrice!.toString() : ''); + _descCtrl = TextEditingController(text: p?.description ?? ''); + _selectedType = p?.prostheticType ?? _prostheticTypes.first.$1; + _currency = p?.currency ?? 'TRY'; + _isActive = p?.isActive ?? true; + } + + @override + void dispose() { + _nameCtrl.dispose(); + _priceCtrl.dispose(); + _descCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + + final price = double.tryParse(_priceCtrl.text.trim()); + final product = ProstheticProduct( + id: widget.existing?.id ?? '', + labTenantId: widget.labTenantId, + name: _nameCtrl.text.trim(), + prostheticType: _selectedType, + unitPrice: price, + currency: _currency, + isActive: _isActive, + description: + _descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(), + ); + + try { + if (widget.existing != null) { + await LabProductsRepository.instance.updateProduct( + widget.existing!.id, + product.toJson(), + ); + } else { + await LabProductsRepository.instance.createProduct(product); + } + widget.onSaved(); + } catch (e) { + setState(() => _saving = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Hata: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; + final isEdit = widget.existing != null; + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.vertical( + top: isDesktop ? Radius.zero : const Radius.circular(20), + ), + ), + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 24, + bottom: isDesktop + ? 24 + : MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + isEdit ? 'Ürünü Düzenle' : 'Yeni Ürün', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.textPrimary), + ), + ), + IconButton( + icon: const Icon(Icons.close, + color: AppColors.textSecondary), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 16), + + // Name + TextFormField( + controller: _nameCtrl, + decoration: + const InputDecoration(labelText: 'Ürün Adı *'), + validator: (v) => + v == null || v.trim().isEmpty ? 'Ürün adı gerekli' : null, + ), + const SizedBox(height: 12), + + // Prosthetic type dropdown + DropdownButtonFormField( + initialValue: _selectedType, + decoration: + const InputDecoration(labelText: 'Protez Tipi *'), + items: _prostheticTypes + .map((t) => DropdownMenuItem( + value: t.$1, + child: Text(t.$2), + )) + .toList(), + onChanged: (v) => setState(() => _selectedType = v!), + ), + const SizedBox(height: 12), + + // Price + currency row + Row( + children: [ + Expanded( + flex: 3, + child: TextFormField( + controller: _priceCtrl, + decoration: + const InputDecoration(labelText: 'Birim Fiyat'), + keyboardType: const TextInputType.numberWithOptions( + decimal: true), + validator: (v) { + if (v != null && v.isNotEmpty) { + if (double.tryParse(v) == null) { + return 'Geçerli fiyat girin'; + } + } + return null; + }, + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: DropdownButtonFormField( + initialValue: _currency, + decoration: + const InputDecoration(labelText: 'Para Birimi'), + items: ['TRY', 'USD', 'EUR'] + .map((c) => DropdownMenuItem( + value: c, + child: Text(c), + )) + .toList(), + onChanged: (v) => setState(() => _currency = v!), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Description + TextFormField( + controller: _descCtrl, + decoration: const InputDecoration( + labelText: 'Açıklama (isteğe bağlı)'), + maxLines: 2, + ), + const SizedBox(height: 12), + + // Active toggle + SwitchListTile( + title: const Text('Aktif', + style: TextStyle(color: AppColors.textPrimary)), + value: _isActive, + onChanged: (v) => setState(() => _isActive = v), + contentPadding: EdgeInsets.zero, + activeTrackColor: AppColors.accent, + ), + const SizedBox(height: 8), + + FilledButton( + onPressed: _saving ? null : _save, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48)), + child: _saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : Text(isEdit ? 'Kaydet' : 'Ekle'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/lab/settings/lab_settings_screen.dart b/lib/features/lab/settings/lab_settings_screen.dart new file mode 100644 index 0000000..6d21355 --- /dev/null +++ b/lib/features/lab/settings/lab_settings_screen.dart @@ -0,0 +1,785 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:go_router/go_router.dart'; + +import '../../../core/l10n/app_strings.dart'; +import '../../../core/providers/auth_provider.dart'; +import '../../../core/providers/locale_provider.dart'; +import '../../../core/router/app_router.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../models/tenant.dart'; +import '../../shared/tenant_team_screen.dart'; +import '../connections/lab_connections_screen.dart'; + +class LabSettingsScreen extends ConsumerWidget { + const LabSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final auth = ref.watch(authProvider); + final s = ref.watch(stringsProvider); + final profile = auth.profile; + final membership = auth.activeTenant; + final tenant = membership?.tenant; + final canEdit = membership?.isAdmin ?? false; + + return Scaffold( + appBar: AppBar(title: Text(s.settings)), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // User card + _SectionHeader(title: s.userInfo), + _UserCard(profile: profile), + const SizedBox(height: 20), + + // Lab info + _SectionHeader( + title: s.labInfo, + action: canEdit + ? IconButton( + icon: const Icon(Icons.edit_outlined, + size: 18, color: AppColors.accent), + tooltip: s.edit, + onPressed: () => _showEditSheet(context, ref, tenant, s), + ) + : null, + ), + _InfoCard(children: [ + _InfoTile( + icon: Icons.science_outlined, + label: s.labName, + value: tenant?.companyName ?? '-', + ), + _InfoTile( + icon: Icons.payments_outlined, + label: s.currency, + value: tenant?.defaultCurrency ?? 'TRY', + ), + _InfoTileBadge( + icon: Icons.circle_outlined, + label: s.status, + value: tenant?.status == 'active' ? s.active : (tenant?.status ?? '-'), + badgeColor: AppColors.success, + badgeBg: AppColors.successBg, + ), + _InfoTile( + icon: Icons.star_outline, + label: s.role, + value: _roleLabel(membership?.role, s), + ), + ]), + const SizedBox(height: 20), + + // Connections + if (membership?.showConnections ?? false) ...[ + _SectionHeader(title: s.connections), + _InfoCard(children: [ + _NavTile( + icon: Icons.link_rounded, + iconColor: AppColors.inProgress, + iconBg: AppColors.inProgressBg, + title: s.clinicConnections, + subtitle: s.clinicConnectionsSub, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const LabConnectionsScreen()), + ), + ), + ]), + const SizedBox(height: 20), + ], + + // Other memberships + if (auth.memberships.length > 1) ...[ + _SectionHeader(title: s.otherMemberships), + _InfoCard(children: [ + for (final m + in auth.memberships.where((m) => m.id != membership?.id)) + _NavTile( + icon: Icons.switch_account_outlined, + iconColor: AppColors.inProgress, + iconBg: AppColors.inProgressBg, + title: m.tenant.companyName, + subtitle: _tenantKindLabel(m.tenant.kind, s), + onTap: () { + ref.read(authProvider.notifier).setActiveTenant(m); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(s.tenantSelected(m.tenant.companyName))), + ); + }, + ), + ]), + const SizedBox(height: 20), + ], + + // Team management + Reports + if (membership?.canManageUsers ?? false) ...[ + _SectionHeader(title: s.management), + _InfoCard(children: [ + _NavTile( + icon: Icons.group_outlined, + iconColor: AppColors.inProgress, + iconBg: AppColors.inProgressBg, + title: s.team, + subtitle: s.teamSub, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const TenantTeamScreen()), + ), + ), + _NavTile( + icon: Icons.discount_outlined, + iconColor: AppColors.success, + iconBg: AppColors.successBg, + title: s.discounts, + subtitle: s.discountsSub, + onTap: () => context.push(routeLabDiscounts), + ), + _NavTile( + icon: Icons.bar_chart_rounded, + iconColor: AppColors.accent, + iconBg: AppColors.inProgressBg, + title: s.reports, + subtitle: s.reportsSub, + onTap: () => context.push(routeLabReports), + ), + _NavTile( + icon: Icons.auto_awesome_outlined, + iconColor: const Color(0xFF7C3AED), + iconBg: const Color(0xFFF3E8FF), + title: s.aiAssistant, + subtitle: s.aiAssistantSub, + onTap: () => context.push(routeLabAi), + ), + ]), + const SizedBox(height: 20), + ], + + // Preferences (language) + _SectionHeader(title: s.preferences), + _InfoCard(children: [ + _NavTile( + icon: Icons.language_outlined, + iconColor: AppColors.accent, + iconBg: AppColors.inProgressBg, + title: s.appLanguage, + subtitle: _currentLanguageLabel(ref.watch(localeProvider).languageCode, s), + onTap: () => _showLanguagePicker(context, ref, s), + ), + ]), + const SizedBox(height: 20), + + // Sign out + _SignOutCard(ref: ref, s: s), + const SizedBox(height: 32), + const Center( + child: Text('DLS — Dental Lab System', + style: TextStyle(fontSize: 12, color: AppColors.textMuted)), + ), + const SizedBox(height: 8), + ], + ), + ); + } + + void _showEditSheet(BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) { + if (tenant == null) return; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _EditTenantSheet( + tenant: tenant, + s: s, + onSave: (name, currency) async { + await ref.read(authProvider.notifier).updateTenantInfo( + tenantId: tenant.id, + companyName: name, + defaultCurrency: currency, + ); + }, + ), + ); + } + + void _showLanguagePicker(BuildContext context, WidgetRef ref, AppStrings s) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => _LanguagePickerSheet(s: s, ref: ref), + ); + } + + static String _tenantKindLabel(TenantKind? kind, AppStrings s) => + switch (kind) { + TenantKind.clinic => s.tenantKindClinic, + TenantKind.lab => s.tenantKindLab, + null => '-', + }; + + static String _currentLanguageLabel(String code, AppStrings s) => switch (code) { + 'en' => s.languageEnglish, + 'ru' => s.languageRussian, + 'ar' => s.languageArabic, + 'de' => s.languageGerman, + _ => s.languageTurkish, + }; + + static String _roleLabel(TenantRole? role, AppStrings s) => switch (role) { + TenantRole.owner => s.roleOwner, + TenantRole.admin => s.roleAdmin, + TenantRole.technician => s.roleTechnician, + TenantRole.delivery => s.roleDelivery, + TenantRole.finance => s.roleFinance, + TenantRole.doctor => s.roleDoctor, + TenantRole.member => s.roleMember, + null => '-', + }; +} + +// ── Language picker sheet ───────────────────────────────────────────────────── + +class _LanguagePickerSheet extends ConsumerWidget { + const _LanguagePickerSheet({required this.s, required this.ref}); + final AppStrings s; + final WidgetRef ref; + + @override + Widget build(BuildContext context, WidgetRef _) { + final currentLocale = ref.watch(localeProvider); + + final options = [ + ('tr', '🇹🇷', s.languageTurkish), + ('en', '🇬🇧', s.languageEnglish), + ('ru', '🇷🇺', s.languageRussian), + ('ar', '🇸🇦', s.languageArabic), + ('de', '🇩🇪', s.languageGerman), + ]; + + return Container( + decoration: const BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Text( + s.languageSelection, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 12), + for (final (code, flag, label) in options) + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + leading: Text(flag, style: const TextStyle(fontSize: 24)), + title: Text( + label, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + trailing: currentLocale.languageCode == code + ? const Icon(Icons.check_circle_rounded, + color: AppColors.accent) + : null, + onTap: () { + ref.read(localeProvider.notifier).setLocale(Locale(code)); + ref.read(authProvider.notifier).updateLanguage(code); + Navigator.pop(context); + }, + ), + SizedBox(height: MediaQuery.paddingOf(context).bottom + 4), + ], + ), + ); + } +} + +// ── Edit sheet ──────────────────────────────────────────────────────────────── + +class _EditTenantSheet extends StatefulWidget { + const _EditTenantSheet({ + required this.tenant, + required this.s, + required this.onSave, + }); + final Tenant tenant; + final AppStrings s; + final Future Function(String companyName, String currency) onSave; + + @override + State<_EditTenantSheet> createState() => _EditTenantSheetState(); +} + +class _EditTenantSheetState extends State<_EditTenantSheet> { + late final TextEditingController _nameController; + late String _selectedCurrency; + bool _saving = false; + + static const _currencies = [ + ('TRY', '₺', 'Türk Lirası'), + ('USD', '\$', 'US Dollar'), + ('EUR', '€', 'Euro'), + ('GBP', '£', 'British Pound'), + ('AED', 'د.إ', 'UAE Dirham'), + ]; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.tenant.companyName); + _selectedCurrency = widget.tenant.defaultCurrency; + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + Future _submit() async { + final name = _nameController.text.trim(); + if (name.isEmpty) return; + setState(() => _saving = true); + final navigator = Navigator.of(context); + final messenger = ScaffoldMessenger.of(context); + try { + await widget.onSave(name, _selectedCurrency); + navigator.pop(); + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('${widget.s.errorPrefix}: $e'))); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final s = widget.s; + return Padding( + padding: + EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Container( + decoration: const BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: BorderRadius.circular(2)), + ), + ), + const SizedBox(height: 16), + Text(s.editLabInfo, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary)), + const SizedBox(height: 16), + TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: s.labName, + hintText: s.labNameHint, + ), + textCapitalization: TextCapitalization.words, + ), + const SizedBox(height: 14), + Text(s.currency, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary)), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _selectedCurrency, + decoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.border)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.border)), + ), + items: [ + for (final (code, symbol, name) in _currencies) + DropdownMenuItem( + value: code, + child: Text('$symbol $name ($code)', + style: const TextStyle(fontSize: 14)), + ), + ], + onChanged: (v) { + if (v != null) setState(() => _selectedCurrency = v); + }, + ), + const SizedBox(height: 20), + if (_saving) + const Center( + child: CircularProgressIndicator(color: AppColors.accent)) + else + FilledButton( + onPressed: _submit, + style: FilledButton.styleFrom( + minimumSize: const Size(double.infinity, 48)), + child: Text(s.save), + ), + SizedBox(height: MediaQuery.paddingOf(context).bottom + 4), + ], + ), + ), + ); + } +} + +// ── Reusable UI pieces ──────────────────────────────────────────────────────── + +class _UserCard extends StatelessWidget { + const _UserCard({required this.profile}); + final dynamic profile; + + @override + Widget build(BuildContext context) { + final displayName = (profile?.displayName?.isNotEmpty == true) + ? profile!.displayName as String + : 'Kullanıcı'; + final initial = (profile?.displayName?.isNotEmpty == true + ? (profile!.displayName as String)[0] + : (profile?.email as String?)?[0] ?? '?') + .toUpperCase(); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + ), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(14)), + child: Center( + child: Text(initial, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.inProgress)), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(displayName, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary)), + const SizedBox(height: 2), + Text(profile?.email as String? ?? '', + style: const TextStyle( + fontSize: 13, color: AppColors.textSecondary)), + ], + ), + ), + ], + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title, this.action}); + final String title; + final Widget? action; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.accent, + letterSpacing: 0.3), + ), + ), + if (action != null) action!, + ], + ), + ); + } +} + +class _InfoCard extends StatelessWidget { + const _InfoCard({required this.children}); + final List children; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + ), + child: Column( + children: [ + for (int i = 0; i < children.length; i++) ...[ + children[i], + if (i < children.length - 1) + const Divider( + height: 1, indent: 16, endIndent: 16, color: AppColors.border), + ], + ], + ), + ); + } +} + +class _InfoTile extends StatelessWidget { + const _InfoTile( + {required this.icon, required this.label, required this.value}); + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon(icon, size: 18, color: AppColors.textSecondary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle( + fontSize: 11, color: AppColors.textMuted)), + const SizedBox(height: 2), + Text(value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary)), + ], + ), + ), + ], + ), + ); + } +} + +class _InfoTileBadge extends StatelessWidget { + const _InfoTileBadge({ + required this.icon, + required this.label, + required this.value, + required this.badgeColor, + required this.badgeBg, + }); + final IconData icon; + final String label; + final String value; + final Color badgeColor; + final Color badgeBg; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon(icon, size: 18, color: AppColors.textSecondary), + const SizedBox(width: 12), + Expanded( + child: Text(label, + style: const TextStyle( + fontSize: 11, color: AppColors.textMuted)), + ), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: badgeBg, + borderRadius: BorderRadius.circular(8), + ), + child: Text(value, + style: TextStyle( + color: badgeColor, + fontSize: 12, + fontWeight: FontWeight.w600)), + ), + ], + ), + ); + } +} + +class _NavTile extends StatelessWidget { + const _NavTile({ + required this.icon, + required this.iconColor, + required this.iconBg, + required this.title, + required this.onTap, + this.subtitle, + }); + + final IconData icon; + final Color iconColor; + final Color iconBg; + final String title; + final String? subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: iconBg, borderRadius: BorderRadius.circular(9)), + child: Icon(icon, color: iconColor, size: 18), + ), + title: Text(title, + style: const TextStyle( + fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + subtitle: subtitle != null + ? Text(subtitle!, + style: const TextStyle(color: AppColors.textSecondary)) + : null, + trailing: + const Icon(Icons.chevron_right, color: AppColors.textSecondary), + onTap: onTap, + ); + } +} + +class _SignOutCard extends StatelessWidget { + const _SignOutCard({required this.ref, required this.s}); + final WidgetRef ref; + final AppStrings s; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.cancelledBg), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + ), + child: ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(9)), + child: const Icon(Icons.logout, + color: AppColors.cancelled, size: 18), + ), + title: Text(s.signOut, + style: const TextStyle( + color: AppColors.cancelled, fontWeight: FontWeight.w600)), + onTap: () async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(s.signOutTitle), + content: Text(s.signOutConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(s.cancel)), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: AppColors.cancelled), + child: Text(s.signOut), + ), + ], + ), + ); + if (confirmed == true) { + await ref.read(authProvider.notifier).signOut(); + } + }, + ), + ); + } +} diff --git a/lib/features/shared/ai_chat_screen.dart b/lib/features/shared/ai_chat_screen.dart new file mode 100644 index 0000000..573c0c0 --- /dev/null +++ b/lib/features/shared/ai_chat_screen.dart @@ -0,0 +1,930 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/providers/auth_provider.dart'; +import '../../core/services/ai_actions.dart'; +import '../../core/services/ai_context_builder.dart'; +import '../../core/services/ai_service.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/utils/file_download_helper.dart'; +import '../../models/job_file.dart'; +import '../../models/tenant.dart'; + +class AiChatScreen extends ConsumerStatefulWidget { + const AiChatScreen({super.key}); + + @override + ConsumerState createState() => _AiChatScreenState(); +} + +class _AiChatScreenState extends ConsumerState { + final _inputController = TextEditingController(); + final _scrollController = ScrollController(); + final List<_Message> _messages = []; + + String? _systemPrompt; + bool _loadingContext = true; + bool _streaming = false; + + @override + void initState() { + super.initState(); + _loadContext(); + } + + @override + void dispose() { + _inputController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + Future _loadContext() async { + try { + final membership = ref.read(authProvider).activeTenant!; + final prompt = await AiContextBuilder.instance.build(membership); + if (mounted) setState(() { _systemPrompt = prompt; _loadingContext = false; }); + } catch (_) { + if (mounted) setState(() => _loadingContext = false); + } + } + + Future _send([String? override]) async { + final text = (override ?? _inputController.text).trim(); + if (text.isEmpty || _streaming || _systemPrompt == null) return; + _inputController.clear(); + FocusScope.of(context).unfocus(); + + final apiMessages = [ + ..._messages.map((m) => {'role': m.isUser ? 'user' : 'assistant', 'content': m.rawText}), + {'role': 'user', 'content': text}, + ]; + + setState(() { + _messages.add(_Message.user(text)); + _messages.add(_Message.assistantStreaming()); + _streaming = true; + }); + _scrollToBottom(); + + var accumulated = ''; + try { + final stream = AiService.instance.streamChat( + systemPrompt: _systemPrompt!, + messages: apiMessages, + ); + await for (final chunk in stream) { + if (!mounted) break; + accumulated += chunk; + setState(() => _messages[_messages.length - 1] = _Message.assistantStreaming(accumulated)); + _scrollToBottom(); + } + if (mounted) { + setState(() => _messages[_messages.length - 1] = _Message.assistantDone(accumulated)); + } + } catch (e) { + if (mounted) { + setState(() => _messages[_messages.length - 1] = _Message.error('Bir hata olustu, tekrar deneyin.')); + } + } finally { + if (mounted) setState(() => _streaming = false); + } + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + title: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: AppColors.accent, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.auto_awesome, size: 16, color: Colors.white), + ), + const SizedBox(width: 10), + const Text('AI Asistan'), + ], + ), + actions: [ + if (_messages.isNotEmpty) + IconButton( + onPressed: () => setState(() => _messages.clear()), + icon: const Icon(Icons.refresh_outlined, size: 20), + tooltip: 'Sohbeti temizle', + ), + ], + ), + body: Column( + children: [ + Expanded( + child: _loadingContext + ? const _LoadingContext() + : _messages.isEmpty + ? _WelcomeView(onSuggestion: _send) + : ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + itemCount: _messages.length, + itemBuilder: (_, i) => _MessageBubble( + message: _messages[i], + membership: ref.read(authProvider).activeTenant!, + ), + ), + ), + _InputBar( + controller: _inputController, + enabled: !_loadingContext && !_streaming, + streaming: _streaming, + onSend: _send, + ), + ], + ), + ); + } +} + +// ── Message model ───────────────────────────────────────────────────────────── + +enum _MsgKind { user, streaming, done, error } + +class _Message { + _Message._({required this.kind, required this.rawText}); + + factory _Message.user(String text) => + _Message._(kind: _MsgKind.user, rawText: text); + + factory _Message.assistantStreaming([String text = '']) => + _Message._(kind: _MsgKind.streaming, rawText: text); + + factory _Message.assistantDone(String text) => + _Message._(kind: _MsgKind.done, rawText: text); + + factory _Message.error(String text) => + _Message._(kind: _MsgKind.error, rawText: text); + + final _MsgKind kind; + final String rawText; + + bool get isUser => kind == _MsgKind.user; + bool get isStreaming => kind == _MsgKind.streaming; + bool get isError => kind == _MsgKind.error; +} + +// ── Welcome view ────────────────────────────────────────────────────────────── + +class _WelcomeView extends StatelessWidget { + const _WelcomeView({required this.onSuggestion}); + final void Function(String) onSuggestion; + + static const _suggestions = [ + 'Bekleyen islerimin ozeti nedir?', + 'Gecikmiş iş var mı?', + 'Bu ay kac is tamamlandi?', + 'Revizyon oranım ne durumda?', + 'Finans durumumu ozetle.', + 'Son yuklenen dosyalari goster.', + ]; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 32, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: AppColors.accent, + borderRadius: BorderRadius.circular(16), + ), + child: const Icon(Icons.auto_awesome, size: 28, color: Colors.white), + ), + const SizedBox(height: 16), + const Text( + 'Merhaba! Size nasil yardimci olabilirim?', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: AppColors.textPrimary), + ), + const SizedBox(height: 6), + const Text( + 'Isler, finans ve ekip hakkinda soru sorabilir, islem yapabilirsiniz.', + style: TextStyle(fontSize: 14, color: AppColors.textSecondary), + ), + const SizedBox(height: 28), + const Text( + 'ONERILER', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.textMuted, letterSpacing: 0.8), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: _suggestions + .map((s) => _SuggestionChip(label: s, onTap: () => onSuggestion(s))) + .toList(), + ), + ], + ), + ); + } +} + +class _SuggestionChip extends StatelessWidget { + const _SuggestionChip({required this.label, required this.onTap}); + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.border), + ), + child: Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textPrimary)), + ), + ); + } +} + +// ── Message bubble ──────────────────────────────────────────────────────────── + +class _MessageBubble extends StatelessWidget { + const _MessageBubble({required this.message, required this.membership}); + final _Message message; + final TenantMembership membership; + + @override + Widget build(BuildContext context) { + if (message.isUser) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppColors.accent, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(4), + ), + ), + child: Text( + message.rawText, + style: const TextStyle(fontSize: 14, height: 1.5, color: Colors.white), + ), + ), + ), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 28, + height: 28, + margin: const EdgeInsets.only(right: 8, top: 2), + decoration: BoxDecoration(color: AppColors.accent, borderRadius: BorderRadius.circular(8)), + child: const Icon(Icons.auto_awesome, size: 14, color: Colors.white), + ), + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(4), + topRight: Radius.circular(16), + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + border: Border.all(color: AppColors.border), + ), + child: message.isStreaming && message.rawText.isEmpty + ? const _TypingDots() + : _MessageContent(message: message, membership: membership), + ), + ), + ], + ), + ); + } +} + +// ── Message content: markdown + action buttons ──────────────────────────────── + +class _MessageContent extends StatelessWidget { + const _MessageContent({required this.message, required this.membership}); + final _Message message; + final TenantMembership membership; + + @override + Widget build(BuildContext context) { + if (message.isError) { + return Text( + message.rawText, + style: const TextStyle(fontSize: 14, height: 1.5, color: AppColors.cancelled), + ); + } + + // During streaming: show raw text without parsing actions + if (message.isStreaming) { + return _MarkdownText(message.rawText, color: AppColors.textPrimary); + } + + // Done: parse segments → text + action buttons + final segments = parseSegments(message.rawText); + if (segments.isEmpty) { + return _MarkdownText(message.rawText, color: AppColors.textPrimary); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: segments.map((seg) { + if (seg is TextSegment) { + return _MarkdownText(seg.text, color: AppColors.textPrimary); + } + if (seg is ActionSegment) { + return Padding( + padding: const EdgeInsets.only(top: 10), + child: _ActionCard(action: seg.action, membership: membership), + ); + } + return const SizedBox.shrink(); + }).toList(), + ); + } +} + +// ── Action card ─────────────────────────────────────────────────────────────── + +enum _ActionState { idle, confirming, loading, success, error, files } + +class _ActionCard extends StatefulWidget { + const _ActionCard({required this.action, required this.membership}); + final AiAction action; + final TenantMembership membership; + + @override + State<_ActionCard> createState() => _ActionCardState(); +} + +class _ActionCardState extends State<_ActionCard> { + _ActionState _state = _ActionState.idle; + String _resultMsg = ''; + List _files = []; + + AiAction get action => widget.action; + + Future _execute() async { + setState(() => _state = _ActionState.loading); + final outcome = await AiActionExecutor.execute(action, widget.membership); + if (!mounted) return; + switch (outcome) { + case ActionSuccess(:final message): + setState(() { _state = _ActionState.success; _resultMsg = message; }); + case ActionError(:final error): + setState(() { _state = _ActionState.error; _resultMsg = error; }); + case ActionFiles(:final files): + setState(() { _state = _ActionState.files; _files = files; }); + } + } + + void _onTap() { + if (action.isDangerous) { + setState(() => _state = _ActionState.confirming); + } else { + _execute(); + } + } + + @override + Widget build(BuildContext context) { + return switch (_state) { + _ActionState.idle => _idleButton(), + _ActionState.confirming => _confirmCard(), + _ActionState.loading => _loadingCard(), + _ActionState.success => _resultCard(success: true), + _ActionState.error => _resultCard(success: false), + _ActionState.files => _filesCard(), + }; + } + + Widget _idleButton() { + final isDanger = action.isDangerous; + final isFile = action.isFileAction; + final color = isDanger + ? AppColors.cancelled + : isFile + ? AppColors.inProgress + : AppColors.accent; + final bgColor = isDanger + ? AppColors.cancelledBg + : isFile + ? AppColors.inProgressBg + : AppColors.accent.withValues(alpha: 0.1); + final icon = isDanger + ? Icons.cancel_outlined + : isFile + ? Icons.folder_outlined + : action.type == 'add_member' + ? Icons.person_add_outlined + : Icons.check_circle_outline; + + return GestureDetector( + onTap: _onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 8), + Flexible( + child: Text( + action.label, + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: color), + ), + ), + ], + ), + ), + ); + } + + Widget _confirmCard() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.cancelledBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.cancelled.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Icon(Icons.warning_amber_rounded, size: 16, color: AppColors.cancelled), + const SizedBox(width: 6), + const Expanded( + child: Text( + 'Bu islem geri alinamayabilir.', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.cancelled), + ), + ), + ], + ), + const SizedBox(height: 4), + Text(action.label, style: const TextStyle(fontSize: 13, color: AppColors.textPrimary)), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => setState(() => _state = _ActionState.idle), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 8), + side: BorderSide(color: AppColors.border), + ), + child: const Text('Vazgec', style: TextStyle(fontSize: 13)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton( + onPressed: _execute, + style: FilledButton.styleFrom( + backgroundColor: AppColors.cancelled, + padding: const EdgeInsets.symmetric(vertical: 8), + ), + child: const Text('Onayla', style: TextStyle(fontSize: 13)), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _loadingCard() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.accent), + ), + const SizedBox(width: 8), + Text('Isleniyor...', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)), + ], + ), + ); + } + + Widget _resultCard({required bool success}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: success ? AppColors.successBg : AppColors.cancelledBg, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + success ? Icons.check_circle_outline : Icons.error_outline, + size: 16, + color: success ? AppColors.success : AppColors.cancelled, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + _resultMsg, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: success ? AppColors.success : AppColors.cancelled, + ), + ), + ), + ], + ), + ); + } + + Widget _filesCard() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.inProgress.withValues(alpha: 0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Icon(Icons.folder_open_outlined, size: 15, color: AppColors.inProgress), + const SizedBox(width: 6), + Text( + '${_files.length} dosya', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.inProgress), + ), + ], + ), + const SizedBox(height: 8), + ..._files.map((f) => _FileDownloadRow(file: f)), + ], + ), + ); + } +} + +// ── File download row ───────────────────────────────────────────────────────── + +class _FileDownloadRow extends StatefulWidget { + const _FileDownloadRow({required this.file}); + final JobFile file; + + @override + State<_FileDownloadRow> createState() => _FileDownloadRowState(); +} + +class _FileDownloadRowState extends State<_FileDownloadRow> { + bool _downloading = false; + + IconData get _icon => switch (widget.file.kind) { + JobFileKind.scan => Icons.view_in_ar_rounded, + JobFileKind.image => Icons.image_outlined, + JobFileKind.document => Icons.description_outlined, + }; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + Icon(_icon, size: 14, color: AppColors.inProgress), + const SizedBox(width: 6), + Expanded( + child: Text( + widget.file.name, + style: const TextStyle(fontSize: 12, color: AppColors.textPrimary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 4), + Text( + widget.file.sizeLabel, + style: const TextStyle(fontSize: 11, color: AppColors.textMuted), + ), + const SizedBox(width: 6), + _downloading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 1.5, color: AppColors.inProgress), + ) + : GestureDetector( + onTap: () async { + setState(() => _downloading = true); + await FileDownloadHelper.download(context, widget.file); + if (mounted) setState(() => _downloading = false); + }, + child: const Icon(Icons.download_outlined, size: 18, color: AppColors.inProgress), + ), + ], + ), + ); + } +} + +// ── Simple markdown renderer ────────────────────────────────────────────────── + +class _MarkdownText extends StatelessWidget { + const _MarkdownText(this.text, {required this.color}); + final String text; + final Color color; + + @override + Widget build(BuildContext context) { + if (text.isEmpty) return const SizedBox.shrink(); + final lines = text.split('\n'); + final widgets = []; + bool prevEmpty = false; + + for (final raw in lines) { + final line = raw.trimRight(); + if (line.isEmpty) { + if (!prevEmpty) widgets.add(const SizedBox(height: 6)); + prevEmpty = true; + continue; + } + prevEmpty = false; + + // Header ## or ### + if (line.startsWith('## ') || line.startsWith('### ')) { + final content = line.replaceFirst(RegExp(r'^#{2,3}\s+'), ''); + widgets.add(Padding( + padding: const EdgeInsets.only(top: 4, bottom: 2), + child: Text( + content, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: color), + ), + )); + continue; + } + + // Bullet + if (line.startsWith('- ') || line.startsWith('• ')) { + final content = line.substring(2); + widgets.add(Padding( + padding: const EdgeInsets.only(left: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('• ', style: TextStyle(color: color, height: 1.5, fontSize: 14)), + Expanded(child: _inlineText(content, color)), + ], + ), + )); + continue; + } + + widgets.add(_inlineText(line, color)); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: widgets, + ); + } + + Widget _inlineText(String text, Color baseColor) { + final spans = []; + final boldPattern = RegExp(r'\*\*(.+?)\*\*'); + int last = 0; + + for (final m in boldPattern.allMatches(text)) { + if (m.start > last) { + spans.add(TextSpan(text: text.substring(last, m.start))); + } + spans.add(TextSpan( + text: m.group(1), + style: const TextStyle(fontWeight: FontWeight.w700), + )); + last = m.end; + } + if (last < text.length) spans.add(TextSpan(text: text.substring(last))); + + return RichText( + text: TextSpan( + style: TextStyle(fontSize: 14, height: 1.5, color: baseColor), + children: spans, + ), + ); + } +} + +// ── Typing dots ─────────────────────────────────────────────────────────────── + +class _TypingDots extends StatefulWidget { + const _TypingDots(); + @override + State<_TypingDots> createState() => _TypingDotsState(); +} + +class _TypingDotsState extends State<_TypingDots> with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 900))..repeat(); + } + + @override + void dispose() { _ctrl.dispose(); super.dispose(); } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 18, + child: AnimatedBuilder( + animation: _ctrl, + builder: (_, __) => Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (i) { + final t = ((_ctrl.value * 3) - i).clamp(0.0, 1.0); + final opacity = (t < 0.5 ? t * 2 : (1 - t) * 2).clamp(0.3, 1.0); + return Container( + width: 6, height: 6, + margin: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: AppColors.accent.withValues(alpha: opacity), + shape: BoxShape.circle, + ), + ); + }), + ), + ), + ); + } +} + +// ── Input bar ───────────────────────────────────────────────────────────────── + +class _InputBar extends StatelessWidget { + const _InputBar({ + required this.controller, + required this.enabled, + required this.streaming, + required this.onSend, + }); + + final TextEditingController controller; + final bool enabled; + final bool streaming; + final VoidCallback onSend; + + @override + Widget build(BuildContext context) { + final bottom = MediaQuery.paddingOf(context).bottom; + return Container( + padding: EdgeInsets.fromLTRB(12, 8, 12, 8 + bottom), + decoration: BoxDecoration( + color: AppColors.surface, + border: Border(top: BorderSide(color: AppColors.border)), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + enabled: enabled, + maxLines: 4, + minLines: 1, + textInputAction: TextInputAction.send, + onSubmitted: (_) => onSend(), + decoration: InputDecoration( + hintText: streaming ? 'Yanit bekleniyor...' : 'Bir sey sorun...', + hintStyle: const TextStyle(color: AppColors.textMuted), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(22), + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(22), + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(22), + borderSide: const BorderSide(color: AppColors.accent, width: 1.5), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + isDense: true, + ), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: enabled ? onSend : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 40, + height: 40, + decoration: BoxDecoration( + color: enabled ? AppColors.accent : AppColors.border, + shape: BoxShape.circle, + ), + child: Center( + child: streaming + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.arrow_upward_rounded, color: Colors.white, size: 18), + ), + ), + ), + ], + ), + ); + } +} + +// ── Loading context ─────────────────────────────────────────────────────────── + +class _LoadingContext extends StatelessWidget { + const _LoadingContext(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: AppColors.accent, strokeWidth: 2), + SizedBox(height: 12), + Text('Veriler yukleniyor...', style: TextStyle(color: AppColors.textSecondary, fontSize: 13)), + ], + ), + ); + } +} diff --git a/lib/features/shared/job_files_panel.dart b/lib/features/shared/job_files_panel.dart new file mode 100644 index 0000000..ee910f1 --- /dev/null +++ b/lib/features/shared/job_files_panel.dart @@ -0,0 +1,619 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../core/api/pocketbase_client.dart'; +import '../../core/theme/app_theme.dart'; +import '../../models/job.dart'; +import '../../models/job_file.dart'; +import 'job_files_repository.dart'; + +const _maxFileSizeBytes = 50 * 1024 * 1024; // 50 MB per file +const _maxFilesPerJob = 10; + +class JobFilesPanel extends StatefulWidget { + const JobFilesPanel({ + super.key, + required this.job, + required this.filesFuture, + required this.onRefresh, + }); + + final Job job; + final Future> filesFuture; + final VoidCallback onRefresh; + + @override + State createState() => _JobFilesPanelState(); +} + +class _JobFilesPanelState extends State { + _UploadState? _upload; + List? _files; + bool _loadingFiles = false; + String? _filesError; + + @override + void initState() { + super.initState(); + _subscribeToFuture(widget.filesFuture); + } + + @override + void didUpdateWidget(JobFilesPanel oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.filesFuture != widget.filesFuture) { + _subscribeToFuture(widget.filesFuture); + } + } + + void _subscribeToFuture(Future> future) { + setState(() { _loadingFiles = true; _filesError = null; }); + future.then((files) { + if (mounted) setState(() { _files = files; _loadingFiles = false; }); + }).catchError((e) { + if (mounted) setState(() { _filesError = _friendlyError(e); _loadingFiles = false; }); + }); + } + + static String _friendlyError(Object e) { + final s = e.toString(); + // Strip full ClientException URL dumps — show only the message part + final msgMatch = RegExp(r'message: ([^,}]+)').firstMatch(s); + if (msgMatch != null) return msgMatch.group(1)!.trim(); + if (s.length > 100) return 'Sunucu hatası'; + return s; + } + + Future _pickAndUpload() async { + final result = await FilePicker.platform.pickFiles( + allowMultiple: true, + withData: true, + type: FileType.custom, + allowedExtensions: [ + 'pdf', 'jpg', 'jpeg', 'png', 'webp', + 'stl', 'obj', 'ply', 'zip', 'opus', 'mp3', 'mp4' + ], + ); + if (result == null || result.files.isEmpty || !mounted) return; + + // Client-side size validation + for (final pf in result.files) { + if (pf.size > _maxFileSizeBytes) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('${pf.name} 50 MB sınırını aşıyor (${_formatSize(pf.size)}).'), + backgroundColor: AppColors.cancelled, + )); + return; + } + } + + final existingCount = _files?.length ?? 0; + final remaining = _maxFilesPerJob - existingCount; + if (result.files.length > remaining) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Bu iş en fazla $_maxFilesPerJob dosya alabilir. Şu an $existingCount dosya var; en fazla $remaining dosya daha ekleyebilirsiniz.'), + backgroundColor: AppColors.cancelled, + )); + return; + } + + int uploadedCount = 0; + for (var i = 0; i < result.files.length; i++) { + final pf = result.files[i]; + if (pf.bytes == null) continue; + + setState(() { + _upload = _UploadState( + fileName: pf.name, + fileIndex: i + 1, + totalFiles: result.files.length, + progress: 0, + speedBytesPerSec: 0, + ); + }); + + try { + await _uploadWithProgress( + pf: pf, + onProgress: (progress, speed) { + if (mounted) { + setState(() { + _upload = _upload?.copyWith(progress: progress, speedBytesPerSec: speed); + }); + } + }, + ); + uploadedCount++; + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${pf.name} yüklenemedi: ${_friendlyError(e)}'), backgroundColor: AppColors.cancelled), + ); + } + setState(() => _upload = null); + return; + } + } + + setState(() => _upload = null); + widget.onRefresh(); + if (mounted && uploadedCount > 0) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$uploadedCount dosya yüklendi.')), + ); + } + } + + Future _uploadWithProgress({ + required PlatformFile pf, + required void Function(double progress, double speedBytesPerSec) onProgress, + }) async { + final bytes = pf.bytes!; + final pb = PocketBaseClient.instance.pb; + final baseUrl = 'https://pocket.kovaksoft.com'; + final uri = Uri.parse('$baseUrl/api/collections/job_files/records'); + + final ext = (pf.extension ?? '').toLowerCase(); + final kind = (ext == 'stl' || ext == 'obj' || ext == 'ply') + ? JobFileKind.scan + : (ext == 'pdf') ? JobFileKind.document : JobFileKind.image; + final mimeType = _mimeFromExt(ext); + final currentUserId = (pb.authStore.record?.id) ?? ''; + final token = pb.authStore.token; + + final startTime = DateTime.now().millisecondsSinceEpoch; + int sentBytes = 0; + + Stream> progressStream(List src) async* { + const chunkSize = 65536; + var offset = 0; + while (offset < src.length) { + final end = (offset + chunkSize).clamp(0, src.length); + final chunk = src.sublist(offset, end); + yield chunk; + offset = end; + sentBytes = offset; + final elapsedMs = DateTime.now().millisecondsSinceEpoch - startTime; + final speed = elapsedMs > 0 ? sentBytes / elapsedMs * 1000 : 0.0; + onProgress(sentBytes / src.length, speed); + // yield control so Flutter can rebuild + await Future.delayed(Duration.zero); + } + } + + final request = http.MultipartRequest('POST', uri) + ..headers['Authorization'] = 'Bearer $token' + ..fields['job_id'] = widget.job.id + ..fields['clinic_tenant_id'] = widget.job.clinicTenantId + ..fields['lab_tenant_id'] = widget.job.labTenantId + ..fields['uploaded_by'] = currentUserId + ..fields['kind'] = kind.value + ..fields['name'] = pf.name + ..fields['size'] = bytes.length.toString() + ..fields['mime_type'] = mimeType + ..files.add(http.MultipartFile( + 'file', + http.ByteStream(progressStream(bytes)), + bytes.length, + filename: pf.name, + )); + + final streamed = await request.send(); + final body = await streamed.stream.bytesToString(); + + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + String msg = 'HTTP ${streamed.statusCode}'; + try { + final j = jsonDecode(body) as Map; + msg = j['message'] as String? ?? msg; + } catch (_) {} + throw Exception(msg); + } + } + + Future _bulkDownload(List files) async { + if (files.isEmpty) return; + final messenger = ScaffoldMessenger.of(context); + try { + final pb = PocketBaseClient.instance.pb; + final fileToken = await pb.files.getToken(); + final dir = await getTemporaryDirectory(); + await dir.create(recursive: true); + + // Download all files in parallel + final results = await Future.wait( + files.where((f) => f.downloadUrl.isNotEmpty).map((file) async { + final uri = Uri.parse('${file.downloadUrl}?token=$fileToken'); + final response = await http.get(uri); + if (response.statusCode != 200) return null; + final path = '${dir.path}/${file.name}'; + await File(path).writeAsBytes(response.bodyBytes); + return XFile(path, mimeType: file.mimeType ?? 'application/octet-stream'); + }), + ); + + final xFiles = results.whereType().toList(); + if (xFiles.isEmpty) return; + await Share.shareXFiles( + xFiles, + subject: '${widget.job.patientCode} dosyaları', + sharePositionOrigin: const Rect.fromLTWH(100, 100, 200, 1), + ); + } catch (e) { + if (mounted) { + messenger.showSnackBar( + SnackBar(content: Text('İndirilemedi: $e'), backgroundColor: AppColors.cancelled), + ); + } + } + } + + Future _deleteFile(JobFile file) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Dosyayı Sil'), + content: Text(file.name), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('İptal')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Sil'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + // Optimistic: remove immediately from local list + setState(() => _files = _files?.where((f) => f.id != file.id).toList()); + + try { + await JobFilesRepository.instance.deleteFile(file.id); + } catch (e) { + final is404 = e.toString().contains('404') || e.toString().contains('wasn\'t found'); + if (!is404) { + // Revert only on transient errors (network, 500) — not when already deleted + setState(() => _files = [...?_files, file]); + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(is404 ? 'Dosya zaten silinmiş.' : 'Silinemedi: ${_friendlyError(e)}'), + backgroundColor: AppColors.cancelled, + ), + ); + } + } + } + + Future _downloadFile(JobFile file, Rect shareOrigin) async { + if (file.downloadUrl.isEmpty) return; + final messenger = ScaffoldMessenger.of(context); + try { + final pb = PocketBaseClient.instance.pb; + final fileToken = await pb.files.getToken(); + final uri = Uri.parse('${file.downloadUrl}?token=$fileToken'); + final response = await http.get(uri); + if (response.statusCode != 200) { + throw Exception('HTTP ${response.statusCode}'); + } + final dir = await getTemporaryDirectory(); + await dir.create(recursive: true); + final path = '${dir.path}/${file.name}'; + await File(path).writeAsBytes(response.bodyBytes); + await Share.shareXFiles( + [XFile(path, mimeType: file.mimeType ?? 'application/octet-stream')], + subject: file.name, + sharePositionOrigin: shareOrigin, + ); + } catch (e) { + if (mounted) { + messenger.showSnackBar( + SnackBar( + content: Text('İndirilemedi: $e'), + backgroundColor: AppColors.cancelled, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final uploading = _upload != null; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.attach_file_rounded, size: 18, color: AppColors.accent), + const SizedBox(width: 6), + const Expanded( + child: Text('Dosyalar', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), + ), + if (!uploading) ...[ + if ((_files?.length ?? 0) >= 2) + TextButton.icon( + onPressed: () => _bulkDownload(_files!), + icon: const Icon(Icons.download_for_offline_outlined, size: 16), + label: const Text('Hepsini İndir'), + style: TextButton.styleFrom( + foregroundColor: AppColors.accent, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + ), + TextButton.icon( + onPressed: _pickAndUpload, + icon: const Icon(Icons.upload_rounded, size: 16), + label: const Text('Yükle'), + style: TextButton.styleFrom( + foregroundColor: AppColors.accent, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + ), + ], + ], + ), + + if (_upload != null) ...[ + const SizedBox(height: 10), + _UploadProgressBar(state: _upload!), + ], + + const SizedBox(height: 8), + Builder(builder: (ctx) { + if (_loadingFiles && _files == null) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + } + if (_filesError != null && _files == null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'Dosyalar yüklenemedi: $_filesError', + style: const TextStyle(color: AppColors.cancelled, fontSize: 12), + ), + ); + } + final files = _files ?? []; + if (files.isEmpty && !uploading) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Text( + 'Henüz dosya eklenmemiş.', + style: TextStyle(color: AppColors.textMuted, fontSize: 13), + ), + ); + } + return Column( + children: files + .map((f) => _FileRow( + file: f, + onDelete: () => _deleteFile(f), + onDownload: (origin) => _downloadFile(f, origin), + )) + .toList(), + ); + }, + ), + ], + ), + ); + } + + static String _formatSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + static String _mimeFromExt(String ext) => switch (ext) { + 'jpg' || 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'webp' => 'image/webp', + 'pdf' => 'application/pdf', + 'stl' => 'model/stl', + 'zip' => 'application/zip', + 'mp3' => 'audio/mpeg', + 'mp4' => 'video/mp4', + 'opus' => 'audio/opus', + _ => 'application/octet-stream', + }; +} + +// ── Upload Progress Bar ─────────────────────────────────────────────────────── + +class _UploadProgressBar extends StatelessWidget { + const _UploadProgressBar({required this.state}); + final _UploadState state; + + @override + Widget build(BuildContext context) { + final pct = (state.progress * 100).toStringAsFixed(0); + final speed = state.speedBytesPerSec; + final speedLabel = speed >= 1024 * 1024 + ? '${(speed / 1024 / 1024).toStringAsFixed(1)} MB/s' + : '${(speed / 1024).toStringAsFixed(0)} KB/s'; + + final fileLabel = state.totalFiles > 1 + ? '${state.fileIndex}/${state.totalFiles} — ${state.fileName}' + : state.fileName; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + fileLabel, + style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Text( + '$pct% · $speedLabel', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.accent, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: state.progress, + minHeight: 6, + backgroundColor: AppColors.background, + valueColor: const AlwaysStoppedAnimation(AppColors.accent), + ), + ), + ], + ); + } +} + +// ── File Row ────────────────────────────────────────────────────────────────── + +class _FileRow extends StatefulWidget { + const _FileRow({ + required this.file, + required this.onDelete, + required this.onDownload, + }); + final JobFile file; + final VoidCallback onDelete; + final Future Function(Rect) onDownload; + + @override + State<_FileRow> createState() => _FileRowState(); +} + +class _FileRowState extends State<_FileRow> { + bool _downloading = false; + + IconData get _icon => switch (widget.file.kind) { + JobFileKind.scan => Icons.view_in_ar_rounded, + JobFileKind.image => Icons.image_outlined, + JobFileKind.document => Icons.description_outlined, + }; + + final _downloadKey = GlobalKey(); + + Future _handleDownload() async { + setState(() => _downloading = true); + final box = _downloadKey.currentContext?.findRenderObject() as RenderBox?; + final origin = box != null + ? box.localToGlobal(Offset.zero) & box.size + : const Rect.fromLTWH(100, 100, 200, 1); + try { + await widget.onDownload(origin); + } finally { + if (mounted) setState(() => _downloading = false); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + children: [ + Icon(_icon, size: 18, color: AppColors.textMuted), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.file.name, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + '${widget.file.kind.label} · ${widget.file.sizeLabel}', + style: const TextStyle(fontSize: 11, color: AppColors.textMuted), + ), + ], + ), + ), + if (_downloading) + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.accent), + ) + else + IconButton( + key: _downloadKey, + onPressed: _handleDownload, + icon: const Icon(Icons.download_outlined, size: 18, color: AppColors.accent), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'İndir', + ), + const SizedBox(width: 4), + IconButton( + onPressed: widget.onDelete, + icon: const Icon(Icons.delete_outline_rounded, size: 18, color: AppColors.cancelled), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Sil', + ), + ], + ), + ); + } +} + +// ── Upload State ────────────────────────────────────────────────────────────── + +class _UploadState { + const _UploadState({ + required this.fileName, + required this.fileIndex, + required this.totalFiles, + required this.progress, + required this.speedBytesPerSec, + }); + + final String fileName; + final int fileIndex; + final int totalFiles; + final double progress; + final double speedBytesPerSec; + + _UploadState copyWith({double? progress, double? speedBytesPerSec}) => + _UploadState( + fileName: fileName, + fileIndex: fileIndex, + totalFiles: totalFiles, + progress: progress ?? this.progress, + speedBytesPerSec: speedBytesPerSec ?? this.speedBytesPerSec, + ); +} diff --git a/lib/features/shared/job_files_repository.dart b/lib/features/shared/job_files_repository.dart new file mode 100644 index 0000000..4a99d05 --- /dev/null +++ b/lib/features/shared/job_files_repository.dart @@ -0,0 +1,59 @@ +import 'package:http/http.dart' as http; +import 'package:pocketbase/pocketbase.dart'; +import '../../core/api/pocketbase_client.dart'; +import '../../models/job_file.dart'; + +class JobFilesRepository { + JobFilesRepository._(); + static final instance = JobFilesRepository._(); + + PocketBase get _pb => PocketBaseClient.instance.pb; + static const _baseUrl = 'https://pocket.kovaksoft.com'; + + String get _currentUserId => (_pb.authStore.record?.id) ?? ''; + + Future> listForJob(String jobId) async { + final result = await _pb.collection('job_files').getList( + filter: 'job_id = "$jobId"', + perPage: 200, + ); + final files = result.items.map((r) => JobFile.fromJson(r.toJson(), _baseUrl)).toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return files; + } + + Future uploadFile({ + required String jobId, + required String clinicTenantId, + required String labTenantId, + required JobFileKind kind, + required String name, + required int size, + required List bytes, + String? mimeType, + }) async { + final multipartFile = http.MultipartFile.fromBytes( + 'file', + bytes, + filename: name, + ); + final record = await _pb.collection('job_files').create( + body: { + 'job_id': jobId, + 'clinic_tenant_id': clinicTenantId, + 'lab_tenant_id': labTenantId, + 'uploaded_by': _currentUserId, + 'kind': kind.value, + 'name': name, + 'size': size, + if (mimeType != null) 'mime_type': mimeType, + }, + files: [multipartFile], + ); + return JobFile.fromJson(record.toJson(), _baseUrl); + } + + Future deleteFile(String fileId) async { + await _pb.collection('job_files').delete(fileId); + } +} diff --git a/lib/features/shared/reports_repository.dart b/lib/features/shared/reports_repository.dart new file mode 100644 index 0000000..5e0238e --- /dev/null +++ b/lib/features/shared/reports_repository.dart @@ -0,0 +1,325 @@ +import 'package:pocketbase/pocketbase.dart'; +import '../../core/api/pocketbase_client.dart'; +import '../../models/tenant.dart'; + +// ── Value objects ───────────────────────────────────────────────────────────── + +const _monthLabels = ['Oca','Şub','Mar','Nis','May','Haz','Tem','Ağu','Eyl','Eki','Kas','Ara']; + +class MonthlyCount { + const MonthlyCount({required this.year, required this.month, required this.count}); + final int year, month, count; + String get label => _monthLabels[month - 1]; +} + +class MonthlyAmount { + const MonthlyAmount({required this.year, required this.month, required this.amount}); + final int year, month; + final double amount; + String get label => _monthLabels[month - 1]; +} + +class CounterpartStat { + const CounterpartStat({required this.name, required this.jobCount, required this.pendingRevenue, required this.paidRevenue}); + final String name; + final int jobCount; + final double pendingRevenue, paidRevenue; + double get totalRevenue => pendingRevenue + paidRevenue; +} + +class ActivityItem { + const ActivityItem({required this.jobId, this.patientCode, required this.action, required this.createdAt, this.note}); + final String jobId, action; + final String? patientCode, note; + final DateTime createdAt; + + String get actionLabel => switch (action) { + 'accepted' => 'İş kabul edildi', + 'handed_to_clinic' => 'Provaya gönderildi', + 'approved' => 'Onaylandı', + 'revision_requested' => 'Revizyon istendi', + 'delivered' => 'Teslim edildi', + 'cancelled' => 'İptal edildi', + _ => action, + }; + + bool get isNegative => action == 'revision_requested' || action == 'cancelled'; + bool get isPositive => action == 'delivered' || action == 'approved' || action == 'accepted'; +} + +// ── Aggregated metrics ──────────────────────────────────────────────────────── + +class ReportMetrics { + const ReportMetrics({ + required this.activeJobs, + required this.completedThisMonth, + required this.overdueJobs, + required this.revisionRate, + required this.avgCompletionDays, + required this.totalRevenue, + required this.pendingRevenue, + required this.currency, + required this.jobsByStatus, + required this.monthlyCounts, + required this.monthlyRevenue, + required this.byProstheticType, + required this.counterpartStats, + required this.recentActivity, + }); + + final int activeJobs; + final int completedThisMonth; + final int overdueJobs; + final double revisionRate; // 0-100 + final double avgCompletionDays; + final double totalRevenue; + final double pendingRevenue; + final String currency; + + final Map jobsByStatus; + final List monthlyCounts; + final List monthlyRevenue; + final Map byProstheticType; + final List counterpartStats; + final List recentActivity; +} + +// ── Repository ──────────────────────────────────────────────────────────────── + +class ReportsRepository { + ReportsRepository._(); + static final instance = ReportsRepository._(); + + PocketBase get _pb => PocketBaseClient.instance.pb; + + Future load(String tenantId, TenantKind kind) async { + final jobFilter = kind == TenantKind.lab + ? 'lab_tenant_id = "$tenantId"' + : 'clinic_tenant_id = "$tenantId"'; + final historyFilter = kind == TenantKind.lab + ? 'lab_tenant_id = "$tenantId"' + : 'clinic_tenant_id = "$tenantId"'; + + final results = await Future.wait([ + _pb.collection('jobs').getList( + filter: jobFilter, + perPage: 500, + expand: kind == TenantKind.lab ? 'clinic_tenant_id' : 'lab_tenant_id', + fields: 'id,status,prosthetic_type,clinic_tenant_id,lab_tenant_id,created,updated,due_date,price,currency,expand', + ).catchError((_) => ResultList()), + _pb.collection('finance_entries').getList( + filter: 'tenant_id = "$tenantId"', + perPage: 300, + fields: 'id,amount,currency,status,created,counterparty_name', + ).catchError((_) => ResultList()), + _pb.collection('job_status_history').getList( + filter: historyFilter, + perPage: 100, + expand: 'job_id', + fields: 'id,action_type,created,note,job_id,expand', + ).catchError((_) => ResultList()), + ]); + + final jobRecords = (results[0] as ResultList).items; + final financeRecords = (results[1] as ResultList).items; + final historyRecords = (results[2] as ResultList).items; + + return _aggregate(tenantId, kind, jobRecords, financeRecords, historyRecords); + } + + ReportMetrics _aggregate( + String tenantId, + TenantKind kind, + List jobRecords, + List financeRecords, + List historyRecords, + ) { + final now = DateTime.now(); + final thisMonthStart = DateTime(now.year, now.month, 1); + + // ── Parse jobs ──────────────────────────────────────────────────────────── + String _s(Map j, String k) { + final v = j[k]; + if (v == null || v == '') return ''; + return v.toString(); + } + + final jobs = jobRecords.map((r) { + final j = r.toJson(); + final exp = j['expand'] as Map?; + final cpKey = kind == TenantKind.lab ? 'clinic_tenant_id' : 'lab_tenant_id'; + final cpExp = exp?[cpKey] as Map?; + return _RawJob( + id: _s(j, 'id'), + status: _s(j, 'status'), + prostheticType: _s(j, 'prosthetic_type'), + clinicTenantId: _s(j, 'clinic_tenant_id'), + labTenantId: _s(j, 'lab_tenant_id'), + created: _parseDate(j['created']), + updated: _parseDate(j['updated']), + dueDate: j['due_date'] != null && j['due_date'] != '' ? _parseDate(j['due_date']) : null, + currency: _s(j, 'currency').isNotEmpty ? _s(j, 'currency') : 'TRY', + counterpartName: cpExp?['company_name'] as String? ?? '', + ); + }).toList(); + + // ── Parse finance ───────────────────────────────────────────────────────── + String topCurrency = 'TRY'; + final financeList = financeRecords.map((r) { + final j = r.toJson(); + final cur = (j['currency'] as String?) ?? 'TRY'; + if (cur.isNotEmpty) topCurrency = cur; + return _RawFinance( + status: (j['status'] as String?) ?? '', + amount: (j['amount'] as num?)?.toDouble() ?? 0, + created: _parseDate(j['created']), + counterpartyName: j['counterparty_name'] as String?, + ); + }).toList(); + + // ── Parse history ───────────────────────────────────────────────────────── + final activity = historyRecords.map((r) { + final j = r.toJson(); + final exp = j['expand'] as Map?; + final jobExp = exp?['job_id'] as Map?; + return ActivityItem( + jobId: (j['job_id'] as String?) ?? '', + patientCode: jobExp?['patient_code'] as String?, + action: (j['action_type'] as String?) ?? '', + createdAt: _parseDate(j['created']), + note: j['note'] as String?, + ); + }).toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + // ── KPI metrics ─────────────────────────────────────────────────────────── + final activeJobs = jobs.where((j) => j.status == 'in_progress' || j.status == 'pending').length; + final completedThisMonth = jobs.where((j) => j.status == 'delivered' && j.updated.isAfter(thisMonthStart)).length; + final overdueJobs = jobs.where((j) => + j.dueDate != null && + j.dueDate!.isBefore(now) && + (j.status == 'in_progress' || j.status == 'pending')).length; + + final revisions = activity.where((a) => a.action == 'revision_requested').length; + final revisionRate = activity.isNotEmpty ? revisions / activity.length * 100 : 0.0; + + final deliveredJobs = jobs.where((j) => j.status == 'delivered').toList(); + final avgCompletionDays = deliveredJobs.isNotEmpty + ? deliveredJobs + .fold(0, (s, j) => s + j.updated.difference(j.created).inDays) / + deliveredJobs.length + : 0.0; + + // ── Finance totals ──────────────────────────────────────────────────────── + final totalRevenue = financeList.where((f) => f.status == 'paid').fold(0, (s, f) => s + f.amount); + final pendingRevenue = financeList.where((f) => f.status == 'pending').fold(0, (s, f) => s + f.amount); + + // ── Job status distribution ─────────────────────────────────────────────── + final Map jobsByStatus = {}; + for (final j in jobs) { + jobsByStatus[j.status] = (jobsByStatus[j.status] ?? 0) + 1; + } + + // ── Monthly job counts (last 6 months) ──────────────────────────────────── + final monthKeys = List.generate(6, (i) { + final d = DateTime(now.year, now.month - 5 + i, 1); + return '${d.year}-${d.month}'; + }); + final monthMap = {for (final k in monthKeys) k: 0}; + for (final j in jobs) { + final key = '${j.created.year}-${j.created.month}'; + if (monthMap.containsKey(key)) monthMap[key] = monthMap[key]! + 1; + } + final monthlyCounts = monthKeys.map((k) { + final parts = k.split('-'); + return MonthlyCount(year: int.parse(parts[0]), month: int.parse(parts[1]), count: monthMap[k]!); + }).toList(); + + // ── Monthly revenue (last 6 months) ────────────────────────────────────── + final revMap = {for (final k in monthKeys) k: 0.0}; + for (final f in financeList) { + if (f.status == 'paid') { + final key = '${f.created.year}-${f.created.month}'; + if (revMap.containsKey(key)) revMap[key] = revMap[key]! + f.amount; + } + } + final monthlyRevenue = monthKeys.map((k) { + final parts = k.split('-'); + return MonthlyAmount(year: int.parse(parts[0]), month: int.parse(parts[1]), amount: revMap[k]!); + }).toList(); + + // ── By prosthetic type ──────────────────────────────────────────────────── + final Map byType = {}; + for (final j in jobs) { + if (j.prostheticType.isNotEmpty) { + byType[j.prostheticType] = (byType[j.prostheticType] ?? 0) + 1; + } + } + + // ── By counterpart ──────────────────────────────────────────────────────── + final Map cpCount = {}; + final Map cpPending = {}, cpPaid = {}; + for (final j in jobs) { + final name = j.counterpartName.isNotEmpty ? j.counterpartName : '—'; + cpCount[name] = (cpCount[name] ?? 0) + 1; + } + for (final f in financeList) { + final name = f.counterpartyName ?? '—'; + if (f.status == 'pending') cpPending[name] = (cpPending[name] ?? 0) + f.amount; + if (f.status == 'paid') cpPaid[name] = (cpPaid[name] ?? 0) + f.amount; + } + final counterparts = cpCount.entries + .map((e) => CounterpartStat( + name: e.key, + jobCount: e.value, + pendingRevenue: cpPending[e.key] ?? 0, + paidRevenue: cpPaid[e.key] ?? 0, + )) + .toList() + ..sort((a, b) => b.jobCount.compareTo(a.jobCount)); + + return ReportMetrics( + activeJobs: activeJobs, + completedThisMonth: completedThisMonth, + overdueJobs: overdueJobs, + revisionRate: revisionRate, + avgCompletionDays: avgCompletionDays, + totalRevenue: totalRevenue, + pendingRevenue: pendingRevenue, + currency: topCurrency, + jobsByStatus: jobsByStatus, + monthlyCounts: monthlyCounts, + monthlyRevenue: monthlyRevenue, + byProstheticType: byType, + counterpartStats: counterparts.take(5).toList(), + recentActivity: activity.take(30).toList(), + ); + } + + static DateTime _parseDate(dynamic v) { + if (v == null || v == '') return DateTime(2000); + return DateTime.tryParse(v.toString()) ?? DateTime(2000); + } +} + +// ── Internal raw models ─────────────────────────────────────────────────────── + +class _RawJob { + const _RawJob({ + required this.id, required this.status, required this.prostheticType, + required this.clinicTenantId, required this.labTenantId, + required this.created, required this.updated, + required this.currency, required this.counterpartName, this.dueDate, + }); + final String id, status, prostheticType, clinicTenantId, labTenantId, currency, counterpartName; + final DateTime created, updated; + final DateTime? dueDate; +} + +class _RawFinance { + const _RawFinance({required this.status, required this.amount, required this.created, this.counterpartyName}); + final String status; + final double amount; + final DateTime created; + final String? counterpartyName; +} diff --git a/lib/features/shared/reports_screen.dart b/lib/features/shared/reports_screen.dart new file mode 100644 index 0000000..6393aac --- /dev/null +++ b/lib/features/shared/reports_screen.dart @@ -0,0 +1,690 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../core/providers/auth_provider.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/gradient_app_bar.dart'; +import '../../models/job.dart'; +import '../../models/tenant.dart'; +import 'reports_repository.dart'; + +class ReportsScreen extends ConsumerStatefulWidget { + const ReportsScreen({super.key}); + + @override + ConsumerState createState() => _ReportsScreenState(); +} + +class _ReportsScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + late Future _future; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 4, vsync: this); + _tabController.addListener(() { if (mounted) setState(() {}); }); + _load(); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + void _load() { + final auth = ref.read(authProvider); + final tenantId = auth.activeTenant!.tenant.id; + final kind = auth.activeTenant!.tenant.kind; + setState(() { + _future = ReportsRepository.instance.load(tenantId, kind); + }); + } + + @override + Widget build(BuildContext context) { + final kind = ref.watch(authProvider).activeTenant?.tenant.kind ?? TenantKind.lab; + final counterpartLabel = kind == TenantKind.lab ? 'Klinikler' : 'Laboratuvarlar'; + + return Scaffold( + backgroundColor: AppColors.background, + appBar: GradientAppBar( + title: 'Raporlar', + category: 'YÖNETİCİ', + actions: [ + IconButton( + icon: const Icon(Icons.refresh_rounded), + onPressed: _load, + tooltip: 'Yenile', + ), + ], + ), + body: FutureBuilder( + future: _future, + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator(color: AppColors.accent)); + } + if (snap.hasError) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline_rounded, color: AppColors.cancelled, size: 48), + const SizedBox(height: 12), + Text('Veriler yüklenemedi', style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Tekrar Dene'), + ), + ], + ), + ); + } + final m = snap.data!; + return Column( + children: [ + // Tab bar + Container( + color: AppColors.surface, + child: TabBar( + controller: _tabController, + labelColor: AppColors.accent, + unselectedLabelColor: AppColors.textSecondary, + indicatorColor: AppColors.accent, + indicatorSize: TabBarIndicatorSize.tab, + labelStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + tabs: [ + const Tab(text: 'Özet'), + const Tab(text: 'Finans'), + const Tab(text: 'Aktivite'), + Tab(text: counterpartLabel), + ], + ), + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _SummaryTab(metrics: m), + _FinanceTab(metrics: m), + _ActivityTab(metrics: m), + _CounterpartTab(metrics: m, label: counterpartLabel), + ], + ), + ), + ], + ); + }, + ), + ); + } +} + +// ── Shared layout helpers ───────────────────────────────────────────────────── + +class _TabBody extends StatelessWidget { + const _TabBody({required this.children}); + final List children; + + @override + Widget build(BuildContext context) => ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + children: children, + ); +} + +class _Card extends StatelessWidget { + const _Card({required this.child, this.padding = const EdgeInsets.all(16)}); + final Widget child; + final EdgeInsets padding; + + @override + Widget build(BuildContext context) => Container( + margin: const EdgeInsets.only(bottom: 12), + padding: padding, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 2))], + ), + child: child, + ); +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader(this.title, {this.subtitle}); + final String title; + final String? subtitle; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(bottom: 10, top: 4), + child: Row( + children: [ + Expanded( + child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + ), + if (subtitle != null) + Text(subtitle!, style: const TextStyle(fontSize: 12, color: AppColors.textMuted)), + ], + ), + ); +} + +// ── KPI Chips ───────────────────────────────────────────────────────────────── + +class _KpiRow extends StatelessWidget { + const _KpiRow({required this.metrics}); + final ReportMetrics metrics; + + @override + Widget build(BuildContext context) { + final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0); + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + _Kpi(label: 'Aktif İşler', value: '${metrics.activeJobs}', icon: Icons.work_outline_rounded, color: AppColors.inProgress), + const SizedBox(width: 10), + _Kpi(label: 'Bu Ay Tamamlandı', value: '${metrics.completedThisMonth}', icon: Icons.check_circle_outline_rounded, color: AppColors.success), + const SizedBox(width: 10), + _Kpi(label: 'Bekleyen Gelir', value: fmt.format(metrics.pendingRevenue), icon: Icons.hourglass_empty_rounded, color: AppColors.pending), + const SizedBox(width: 10), + _Kpi(label: 'Ort. Süre', value: '${metrics.avgCompletionDays.toStringAsFixed(1)} gün', icon: Icons.timer_outlined, color: AppColors.accent), + const SizedBox(width: 10), + _Kpi(label: 'Revizyon Oranı', value: '%${metrics.revisionRate.toStringAsFixed(0)}', icon: Icons.loop_rounded, color: metrics.revisionRate > 20 ? AppColors.cancelled : AppColors.textSecondary), + if (metrics.overdueJobs > 0) ...[ + const SizedBox(width: 10), + _Kpi(label: 'Gecikmiş', value: '${metrics.overdueJobs}', icon: Icons.schedule_rounded, color: AppColors.cancelled), + ], + ], + ), + ); + } +} + +class _Kpi extends StatelessWidget { + const _Kpi({required this.label, required this.value, required this.icon, required this.color}); + final String label, value; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.border), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 6)], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 4), + Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)), + ], + ), + const SizedBox(height: 6), + Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: color)), + ], + ), + ); +} + +// ── Özet Tab ────────────────────────────────────────────────────────────────── + +class _SummaryTab extends StatelessWidget { + const _SummaryTab({required this.metrics}); + final ReportMetrics metrics; + + @override + Widget build(BuildContext context) { + return _TabBody(children: [ + _KpiRow(metrics: metrics), + const _SectionHeader('İş Durumu Dağılımı'), + _Card( + child: Column( + children: _statusOrder.where((s) => metrics.jobsByStatus.containsKey(s)).map((s) { + final count = metrics.jobsByStatus[s] ?? 0; + final total = metrics.jobsByStatus.values.fold(0, (a, b) => a + b); + return _HBarRow( + label: _statusLabel(s), + value: count, + max: total, + color: _statusColor(s), + ); + }).toList(), + ), + ), + const _SectionHeader('Son 6 Aylık İş Trendi'), + _Card(child: _VBarChart(data: metrics.monthlyCounts, color: AppColors.accent)), + ]); + } + + static const _statusOrder = ['in_progress', 'pending', 'sent', 'delivered', 'cancelled']; + + static String _statusLabel(String s) => switch (s) { + 'pending' => 'Bekliyor', + 'in_progress' => 'İşlemde', + 'sent' => 'Gönderildi', + 'delivered' => 'Teslim', + 'cancelled' => 'İptal', + _ => s, + }; + + static Color _statusColor(String s) => switch (s) { + 'pending' => AppColors.pending, + 'in_progress' => AppColors.inProgress, + 'sent' => AppColors.accent, + 'delivered' => AppColors.success, + 'cancelled' => AppColors.cancelled, + _ => AppColors.textMuted, + }; +} + +// ── Finans Tab ──────────────────────────────────────────────────────────────── + +class _FinanceTab extends StatelessWidget { + const _FinanceTab({required this.metrics}); + final ReportMetrics metrics; + + @override + Widget build(BuildContext context) { + final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0); + final total = metrics.totalRevenue + metrics.pendingRevenue; + return _TabBody(children: [ + const _SectionHeader('Gelir Özeti'), + Row( + children: [ + Expanded( + child: _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Container(width: 8, height: 8, decoration: BoxDecoration(color: AppColors.success, shape: BoxShape.circle)), + const SizedBox(width: 6), + const Text('Tahsil Edildi', style: TextStyle(fontSize: 12, color: AppColors.textMuted)), + ]), + const SizedBox(height: 6), + Text(fmt.format(metrics.totalRevenue), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.success)), + ], + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Container(width: 8, height: 8, decoration: BoxDecoration(color: AppColors.pending, shape: BoxShape.circle)), + const SizedBox(width: 6), + const Text('Bekleyen', style: TextStyle(fontSize: 12, color: AppColors.textMuted)), + ]), + const SizedBox(height: 6), + Text(fmt.format(metrics.pendingRevenue), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.pending)), + ], + ), + ), + ), + ], + ), + if (total > 0) _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Tahsilat Oranı', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: total > 0 ? metrics.totalRevenue / total : 0, + minHeight: 12, + backgroundColor: AppColors.pendingBg, + valueColor: const AlwaysStoppedAnimation(AppColors.success), + ), + ), + const SizedBox(height: 6), + Text('${(metrics.totalRevenue / total * 100).toStringAsFixed(0)}% tahsil edildi', + style: const TextStyle(fontSize: 12, color: AppColors.textMuted)), + ], + ), + ), + const SizedBox(height: 4), + const _SectionHeader('Aylık Gelir Trendi'), + _Card(child: _VBarChart( + data: metrics.monthlyRevenue.map((m) => MonthlyCount(year: m.year, month: m.month, count: m.amount.round())).toList(), + color: AppColors.success, + formatValue: (v) => NumberFormat.compactCurrency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0).format(v), + )), + ]); + } +} + +// ── Aktivite Tab ────────────────────────────────────────────────────────────── + +class _ActivityTab extends StatelessWidget { + const _ActivityTab({required this.metrics}); + final ReportMetrics metrics; + + @override + Widget build(BuildContext context) { + final items = metrics.recentActivity; + if (items.isEmpty) { + return const Center( + child: Text('Henüz aktivite kaydı yok.', style: TextStyle(color: AppColors.textMuted)), + ); + } + return _TabBody(children: [ + const _SectionHeader('Son İşlemler'), + _Card( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + children: items.asMap().entries.map((entry) { + final i = entry.key; + final item = entry.value; + return _ActivityRow(item: item, isLast: i == items.length - 1); + }).toList(), + ), + ), + ]); + } +} + +class _ActivityRow extends StatelessWidget { + const _ActivityRow({required this.item, required this.isLast}); + final ActivityItem item; + final bool isLast; + + @override + Widget build(BuildContext context) { + final color = item.isNegative ? AppColors.cancelled : item.isPositive ? AppColors.success : AppColors.accent; + final icon = switch (item.action) { + 'accepted' => Icons.check_circle_outline_rounded, + 'handed_to_clinic' => Icons.send_rounded, + 'approved' => Icons.thumb_up_outlined, + 'revision_requested' => Icons.loop_rounded, + 'delivered' => Icons.local_shipping_outlined, + 'cancelled' => Icons.cancel_outlined, + _ => Icons.history_rounded, + }; + final df = DateFormat('dd.MM.yy HH:mm'); + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Timeline line + dot + SizedBox( + width: 28, + child: Column( + children: [ + Container( + width: 24, height: 24, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 13, color: color), + ), + if (!isLast) + Expanded(child: Container(width: 1.5, color: AppColors.border)), + ], + ), + ), + const SizedBox(width: 10), + Expanded( + child: Padding( + padding: EdgeInsets.only(bottom: isLast ? 0 : 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.actionLabel, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + if (item.patientCode != null && item.patientCode!.isNotEmpty) + Text(item.patientCode!, style: const TextStyle(fontSize: 11, color: AppColors.accent)), + if (item.note != null && item.note!.isNotEmpty) + Text(item.note!, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)), + const SizedBox(height: 2), + Text(df.format(item.createdAt), style: const TextStyle(fontSize: 10, color: AppColors.textMuted)), + ], + ), + ), + ), + ], + ), + ); + } +} + +// ── Counterpart Tab ─────────────────────────────────────────────────────────── + +class _CounterpartTab extends StatelessWidget { + const _CounterpartTab({required this.metrics, required this.label}); + final ReportMetrics metrics; + final String label; + + @override + Widget build(BuildContext context) { + final stats = metrics.counterpartStats; + if (stats.isEmpty) { + return Center( + child: Text('$label henüz yok.', style: const TextStyle(color: AppColors.textMuted)), + ); + } + final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0); + final maxJobs = stats.fold(0, (m, s) => s.jobCount > m ? s.jobCount : m); + + return _TabBody(children: [ + const _SectionHeader('Protez Türü Dağılımı'), + _Card( + child: Column( + children: _buildTypeRows(metrics.byProstheticType), + ), + ), + _SectionHeader('En Aktif $label'), + _Card( + child: Column( + children: stats.asMap().entries.map((entry) { + final i = entry.key; + final s = entry.value; + return Padding( + padding: EdgeInsets.only(bottom: i < stats.length - 1 ? 12 : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 22, height: 22, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + shape: BoxShape.circle, + ), + child: Center( + child: Text('${i + 1}', + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.inProgress)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text(s.name, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + maxLines: 1, overflow: TextOverflow.ellipsis), + ), + Text('${s.jobCount} iş', + style: const TextStyle(fontSize: 12, color: AppColors.textSecondary, fontWeight: FontWeight.w500)), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: maxJobs > 0 ? s.jobCount / maxJobs : 0, + minHeight: 6, + backgroundColor: AppColors.surfaceVariant, + valueColor: const AlwaysStoppedAnimation(AppColors.accent), + ), + ), + if (s.totalRevenue > 0) Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '${fmt.format(s.paidRevenue)} tahsil · ${fmt.format(s.pendingRevenue)} bekliyor', + style: const TextStyle(fontSize: 11, color: AppColors.textMuted), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ]); + } + + List _buildTypeRows(Map byType) { + if (byType.isEmpty) return [const Text('Veri yok', style: TextStyle(color: AppColors.textMuted))]; + final sorted = byType.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); + final max = sorted.first.value; + return sorted.map((e) => _HBarRow( + label: _typeLabel(e.key), + value: e.value, + max: max, + color: AppColors.accent, + )).toList(); + } + + static String _typeLabel(String s) => switch (s) { + 'metal_porselen' => 'Metal Porselen', + 'zirkonyum' => 'Zirkonyum', + 'implant_ustu_zirkonyum'=> 'İmplant Üstü', + 'gecici' => 'Geçici', + 'e_max' => 'E-Max', + 'tam_protez' => 'Tam Protez', + 'parsiyel' => 'Parsiyel', + _ => 'Diğer', + }; +} + +// ── Chart widgets ───────────────────────────────────────────────────────────── + +class _HBarRow extends StatelessWidget { + const _HBarRow({required this.label, required this.value, required this.max, required this.color}); + final String label; + final int value, max; + final Color color; + + @override + Widget build(BuildContext context) { + final fraction = max > 0 ? value / max : 0.0; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + children: [ + SizedBox( + width: 100, + child: Text(label, + style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), + maxLines: 1, overflow: TextOverflow.ellipsis), + ), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Stack( + children: [ + Container(height: 22, color: AppColors.surfaceVariant), + FractionallySizedBox( + widthFactor: fraction, + child: Container( + height: 22, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 28, + child: Text('$value', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.textPrimary), + textAlign: TextAlign.right), + ), + ], + ), + ); + } +} + +class _VBarChart extends StatelessWidget { + const _VBarChart({required this.data, required this.color, this.formatValue}); + final List data; + final Color color; + final String Function(int)? formatValue; + + @override + Widget build(BuildContext context) { + if (data.isEmpty) return const SizedBox.shrink(); + final maxVal = data.fold(0, (m, e) => e.count > m ? e.count : m); + final fmt = formatValue ?? (v) => '$v'; + + return SizedBox( + height: 140, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: data.map((d) { + final fraction = maxVal > 0 ? d.count / maxVal : 0.0; + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (d.count > 0) + Text(fmt(d.count), + style: const TextStyle(fontSize: 9, color: AppColors.textMuted), + textAlign: TextAlign.center), + const SizedBox(height: 2), + AnimatedContainer( + duration: const Duration(milliseconds: 600), + curve: Curves.easeOut, + height: (fraction * 90).clamp(2, 90), + decoration: BoxDecoration( + color: color, + borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), + ), + ), + const SizedBox(height: 4), + Text(d.label, + style: const TextStyle(fontSize: 10, color: AppColors.textMuted), + textAlign: TextAlign.center), + ], + ), + ), + ); + }).toList(), + ), + ); + } +} diff --git a/lib/features/shared/tenant_team_repository.dart b/lib/features/shared/tenant_team_repository.dart new file mode 100644 index 0000000..77d84cc --- /dev/null +++ b/lib/features/shared/tenant_team_repository.dart @@ -0,0 +1,82 @@ +import 'package:pocketbase/pocketbase.dart'; +import '../../core/api/pocketbase_client.dart'; +import '../../models/tenant.dart'; +import '../../models/user_profile.dart'; + +class TeamMember { + const TeamMember({ + required this.memberId, + required this.user, + required this.role, + required this.joinedAt, + }); + final String memberId; + final UserProfile user; + final TenantRole role; + final DateTime joinedAt; +} + +class TenantTeamRepository { + TenantTeamRepository._(); + static final instance = TenantTeamRepository._(); + + PocketBase get _pb => PocketBaseClient.instance.pb; + + Future> listMembers(String tenantId) async { + final result = await _pb.collection('tenant_members').getList( + filter: 'tenant_id = "$tenantId"', + expand: 'user_id', + perPage: 200, + ); + return (result.items.map((r) { + final j = r.toJson(); + final userExp = (j['expand'] as Map?)?['user_id'] as Map?; + return TeamMember( + memberId: j['id'] as String, + user: UserProfile.fromJson(userExp ?? {'id': j['user_id'], 'email': ''}), + role: TenantMembership.parseRole(j['role'] as String), + joinedAt: DateTime.parse(j['created'] as String), + ); + }).toList()..sort((a, b) => a.joinedAt.compareTo(b.joinedAt))); + } + + Future addMember({ + required String tenantId, + required String email, + required String password, + required String firstName, + required String lastName, + required TenantRole role, + }) async { + final userRecord = await _pb.collection('users').create(body: { + 'email': email.trim().toLowerCase(), + 'password': password, + 'passwordConfirm': password, + 'first_name': firstName.trim(), + 'last_name': lastName.trim(), + 'emailVisibility': true, + }); + final memberRecord = await _pb.collection('tenant_members').create(body: { + 'tenant_id': tenantId, + 'user_id': userRecord.id, + 'role': role.value, + }); + final j = memberRecord.toJson(); + return TeamMember( + memberId: j['id'] as String, + user: UserProfile.fromJson(userRecord.toJson()), + role: role, + joinedAt: DateTime.parse(j['created'] as String), + ); + } + + Future changeMemberRole(String memberId, TenantRole newRole) async { + await _pb.collection('tenant_members').update(memberId, body: { + 'role': newRole.value, + }); + } + + Future removeMember(String memberId) async { + await _pb.collection('tenant_members').delete(memberId); + } +} diff --git a/lib/features/shared/tenant_team_screen.dart b/lib/features/shared/tenant_team_screen.dart new file mode 100644 index 0000000..6470f10 --- /dev/null +++ b/lib/features/shared/tenant_team_screen.dart @@ -0,0 +1,742 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/providers/auth_provider.dart'; +import '../../core/theme/app_theme.dart'; +import '../../models/tenant.dart'; +import 'tenant_team_repository.dart'; + +class TenantTeamScreen extends ConsumerStatefulWidget { + const TenantTeamScreen({super.key}); + + @override + ConsumerState createState() => _TenantTeamScreenState(); +} + +class _TenantTeamScreenState extends ConsumerState { + List _members = []; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + final members = await TenantTeamRepository.instance.listMembers(tenantId); + if (mounted) { + setState(() { + _members = members; + _loading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + bool get _canManage => + ref.read(authProvider).activeTenant?.canManageUsers ?? false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Ekip'), + actions: [ + if (_canManage) + TextButton.icon( + onPressed: () => _showAddMemberSheet(context), + icon: const Icon(Icons.person_add_outlined, size: 18), + label: const Text('Üye Ekle'), + ), + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? _ErrorView(error: _error!, onRetry: _load) + : RefreshIndicator( + onRefresh: _load, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _SectionHeader( + title: 'Üyeler', + count: _members.length, + ), + const SizedBox(height: 8), + _MembersList( + members: _members, + canManage: _canManage, + currentUserId: + ref.read(authProvider).profile?.id ?? '', + onRoleChange: _changeRole, + onRemove: _removeMember, + ), + const SizedBox(height: 24), + ], + ), + ), + ); + } + + Future _showAddMemberSheet(BuildContext context) async { + final tenantId = ref.read(authProvider).activeTenant!.tenant.id; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppColors.background, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => _AddMemberSheet( + onAdd: (firstName, lastName, email, password, role) async { + await TenantTeamRepository.instance.addMember( + tenantId: tenantId, + email: email, + password: password, + firstName: firstName, + lastName: lastName, + role: role, + ); + await _load(); + }, + ), + ); + } + + Future _changeRole(TeamMember member, TenantRole newRole) async { + try { + await TenantTeamRepository.instance.changeMemberRole( + member.memberId, newRole); + await _load(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Hata: $e'))); + } + } + } + + Future _removeMember(TeamMember member) async { + final name = member.user.displayName.isNotEmpty + ? member.user.displayName + : member.user.email; + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Üyeyi Çıkar'), + content: Text('$name adlı üyeyi ekipten çıkarmak istiyor musunuz?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Vazgeç')), + FilledButton( + onPressed: () => Navigator.pop(context, true), + style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled), + child: const Text('Çıkar'), + ), + ], + ), + ); + if (confirmed != true) return; + try { + await TenantTeamRepository.instance.removeMember(member.memberId); + await _load(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Hata: $e'))); + } + } + } +} + +// ── Section header ───────────────────────────────────────────────────────── + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title, required this.count}); + final String title; + final int count; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text( + title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + letterSpacing: 0.5, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '$count', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.inProgress, + ), + ), + ), + ], + ); + } +} + +// ── Members list ─────────────────────────────────────────────────────────── + +class _MembersList extends StatelessWidget { + const _MembersList({ + required this.members, + required this.canManage, + required this.currentUserId, + required this.onRoleChange, + required this.onRemove, + }); + + final List members; + final bool canManage; + final String currentUserId; + final Future Function(TeamMember, TenantRole) onRoleChange; + final Future Function(TeamMember) onRemove; + + @override + Widget build(BuildContext context) { + if (members.isEmpty) { + return const _EmptyCard(message: 'Henüz üye yok.'); + } + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + ), + child: Column( + children: members.asMap().entries.map((entry) { + final i = entry.key; + final m = entry.value; + final isLast = i == members.length - 1; + return _MemberTile( + member: m, + isSelf: m.user.id == currentUserId, + canManage: canManage && m.role != TenantRole.owner, + showDivider: !isLast, + onRoleChange: (role) => onRoleChange(m, role), + onRemove: () => onRemove(m), + ); + }).toList(), + ), + ); + } +} + +class _MemberTile extends StatelessWidget { + const _MemberTile({ + required this.member, + required this.isSelf, + required this.canManage, + required this.showDivider, + required this.onRoleChange, + required this.onRemove, + }); + + final TeamMember member; + final bool isSelf; + final bool canManage; + final bool showDivider; + final void Function(TenantRole) onRoleChange; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final name = member.user.displayName.isNotEmpty + ? member.user.displayName + : member.user.email; + final initials = name.trim().isNotEmpty + ? name.trim()[0].toUpperCase() + : '?'; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.inProgressBg, + borderRadius: BorderRadius.circular(20), + ), + child: Center( + child: Text( + initials, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.inProgress, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + name, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + if (isSelf) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: AppColors.successBg, + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + 'Sen', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.success, + ), + ), + ), + ], + ], + ), + Text( + member.user.email, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + if (canManage) ...[ + _RoleChip( + role: member.role, + onChanged: onRoleChange, + ), + const SizedBox(width: 4), + IconButton( + onPressed: onRemove, + icon: const Icon(Icons.remove_circle_outline, + color: AppColors.cancelled, size: 20), + tooltip: 'Çıkar', + constraints: const BoxConstraints(), + padding: const EdgeInsets.all(6), + ), + ] else + _RoleBadge(role: member.role), + ], + ), + ), + if (showDivider) + const Divider(height: 1, indent: 68, color: AppColors.border), + ], + ); + } +} + +class _RoleChip extends StatelessWidget { + const _RoleChip({required this.role, required this.onChanged}); + final TenantRole role; + final void Function(TenantRole) onChanged; + + static const _selectableRoles = [ + TenantRole.admin, + TenantRole.technician, + TenantRole.delivery, + TenantRole.finance, + TenantRole.doctor, + TenantRole.member, + ]; + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + initialValue: role, + onSelected: onChanged, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + itemBuilder: (_) => _selectableRoles + .map((r) => PopupMenuItem( + value: r, + child: Text(r.label), + )) + .toList(), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: _roleBg(role), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + role.label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _roleColor(role), + ), + ), + const SizedBox(width: 4), + Icon(Icons.arrow_drop_down, size: 16, color: _roleColor(role)), + ], + ), + ), + ); + } +} + +class _RoleBadge extends StatelessWidget { + const _RoleBadge({required this.role}); + final TenantRole role; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: _roleBg(role), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + role.label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _roleColor(role), + ), + ), + ); + } +} + +Color _roleBg(TenantRole r) => switch (r) { + TenantRole.owner => AppColors.inProgressBg, + TenantRole.admin => AppColors.inProgressBg, + TenantRole.doctor => AppColors.successBg, + _ => AppColors.surface, + }; + +Color _roleColor(TenantRole r) => switch (r) { + TenantRole.owner => AppColors.inProgress, + TenantRole.admin => AppColors.inProgress, + TenantRole.doctor => AppColors.success, + _ => AppColors.textSecondary, + }; + +// ── Add member sheet ──────────────────────────────────────────────────────── + +class _AddMemberSheet extends StatefulWidget { + const _AddMemberSheet({required this.onAdd}); + final Future Function( + String firstName, + String lastName, + String email, + String password, + TenantRole role, + ) onAdd; + + @override + State<_AddMemberSheet> createState() => _AddMemberSheetState(); +} + +class _AddMemberSheetState extends State<_AddMemberSheet> { + final _formKey = GlobalKey(); + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + TenantRole _selectedRole = TenantRole.member; + bool _saving = false; + bool _obscurePassword = true; + + static const _selectableRoles = [ + TenantRole.admin, + TenantRole.technician, + TenantRole.delivery, + TenantRole.finance, + TenantRole.doctor, + TenantRole.member, + ]; + + @override + void dispose() { + _firstNameController.dispose(); + _lastNameController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + try { + await widget.onAdd( + _firstNameController.text.trim(), + _lastNameController.text.trim(), + _emailController.text.trim(), + _passwordController.text, + _selectedRole, + ); + if (mounted) Navigator.pop(context); + } catch (e) { + if (mounted) { + final msg = _friendlyError(e); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(msg))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + static String _friendlyError(Object e) { + final s = e.toString(); + if (s.contains('email') && s.contains('unique')) { + return 'Bu e-posta adresi zaten kayıtlı.'; + } + final msgMatch = RegExp(r'message: ([^,}]+)').firstMatch(s); + if (msgMatch != null) return msgMatch.group(1)!.trim(); + if (s.length > 120) return 'Sunucu hatası'; + return s; + } + + @override + Widget build(BuildContext context) { + final bottom = MediaQuery.of(context).viewInsets.bottom; + return Padding( + padding: EdgeInsets.fromLTRB(24, 24, 24, 24 + bottom), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'Üye Ekle', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const Spacer(), + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _firstNameController, + textCapitalization: TextCapitalization.words, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'İsim', + prefixIcon: Icon(Icons.person_outline), + ), + validator: (val) { + if (val == null || val.trim().isEmpty) return 'Zorunlu'; + return null; + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _lastNameController, + textCapitalization: TextCapitalization.words, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Soyisim', + ), + validator: (val) { + if (val == null || val.trim().isEmpty) return 'Zorunlu'; + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'E-posta', + hintText: 'ornek@email.com', + prefixIcon: Icon(Icons.email_outlined), + ), + validator: (val) { + if (val == null || val.trim().isEmpty) return 'E-posta zorunludur'; + if (!val.contains('@')) return 'Geçerli bir e-posta girin'; + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: 'Şifre', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon(_obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined), + onPressed: () => + setState(() => _obscurePassword = !_obscurePassword), + ), + ), + validator: (val) { + if (val == null || val.isEmpty) return 'Şifre zorunludur'; + if (val.length < 8) return 'En az 8 karakter olmalı'; + return null; + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _selectedRole, + decoration: const InputDecoration( + labelText: 'Rol', + prefixIcon: Icon(Icons.badge_outlined), + ), + items: _selectableRoles + .map((r) => DropdownMenuItem( + value: r, + child: Text(r.label), + )) + .toList(), + onChanged: (v) { + if (v != null) setState(() => _selectedRole = v); + }, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _saving ? null : _submit, + icon: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.person_add_outlined, size: 18), + label: const Text('Üye Ekle'), + ), + ), + ], + ), + ), + ); + } +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +class _EmptyCard extends StatelessWidget { + const _EmptyCard({required this.message}); + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + ), + child: Center( + child: Text( + message, + style: const TextStyle(color: AppColors.textSecondary), + ), + ), + ); + } +} + +class _ErrorView extends StatelessWidget { + const _ErrorView({required this.error, required this.onRetry}); + final String error; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, color: AppColors.cancelled, size: 40), + const SizedBox(height: 12), + Text(error, + style: const TextStyle(color: AppColors.textSecondary), + textAlign: TextAlign.center), + const SizedBox(height: 12), + TextButton(onPressed: onRetry, child: const Text('Tekrar Dene')), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..0922b72 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,131 @@ +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'core/api/pocketbase_client.dart'; +import 'core/providers/locale_provider.dart'; +import 'core/router/router_provider.dart'; +import 'core/services/notification_service.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await PocketBaseClient.init(); + await NotificationService.init(); + + final initialLocale = await LocaleNotifier.load(); + + runApp( + ProviderScope( + overrides: [ + localeProvider.overrideWith( + (ref) => LocaleNotifier(initialLocale), + ), + ], + child: const DlsApp(), + ), + ); +} + +class DlsApp extends ConsumerWidget { + const DlsApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(routerProvider); + final locale = ref.watch(localeProvider); + NotificationService.setRouter(router); + return ShadcnApp.router( + title: 'DLS', + debugShowCheckedModeBanner: false, + locale: locale, + supportedLocales: supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + theme: const ThemeData( + colorScheme: _dlsLight, + radius: 0.5, + ), + darkTheme: const ThemeData.dark( + colorScheme: _dlsDark, + radius: 0.5, + ), + routerConfig: router, + ); + } +} + +// ── DLS Light color scheme ──────────────────────────────────────────────────── + +const _dlsLight = ColorScheme( + brightness: Brightness.light, + background: Color(0xFFF1F5F9), + foreground: Color(0xFF0F172A), + card: Color(0xFFFFFFFF), + cardForeground: Color(0xFF0F172A), + popover: Color(0xFFFFFFFF), + popoverForeground: Color(0xFF0F172A), + primary: Color(0xFF1E3A5F), + primaryForeground: Color(0xFFFFFFFF), + secondary: Color(0xFFE2E8F0), + secondaryForeground: Color(0xFF0F172A), + muted: Color(0xFFE2E8F0), + mutedForeground: Color(0xFF64748B), + accent: Color(0xFFF8FAFC), + accentForeground: Color(0xFF0F172A), + destructive: Color(0xFFDC2626), + border: Color(0xFFE2E8F0), + input: Color(0xFFE2E8F0), + ring: Color(0xFF0369A1), + chart1: Color(0xFF1E3A5F), + chart2: Color(0xFF059669), + chart3: Color(0xFFF59E0B), + chart4: Color(0xFF0369A1), + chart5: Color(0xFFDC2626), + sidebar: Color(0xFFFFFFFF), + sidebarForeground: Color(0xFF0F172A), + sidebarPrimary: Color(0xFF1E3A5F), + sidebarPrimaryForeground: Color(0xFFFFFFFF), + sidebarAccent: Color(0xFFF1F5F9), + sidebarAccentForeground: Color(0xFF0F172A), + sidebarBorder: Color(0xFFE2E8F0), + sidebarRing: Color(0xFF0369A1), +); + +// ── DLS Dark color scheme ───────────────────────────────────────────────────── + +const _dlsDark = ColorScheme( + brightness: Brightness.dark, + background: Color(0xFF0F172A), + foreground: Color(0xFFF1F5F9), + card: Color(0xFF1E293B), + cardForeground: Color(0xFFF1F5F9), + popover: Color(0xFF1E293B), + popoverForeground: Color(0xFFF1F5F9), + primary: Color(0xFF93C5FD), + primaryForeground: Color(0xFF1E3A5F), + secondary: Color(0xFF273344), + secondaryForeground: Color(0xFFF1F5F9), + muted: Color(0xFF273344), + mutedForeground: Color(0xFF94A3B8), + accent: Color(0xFF273344), + accentForeground: Color(0xFFF1F5F9), + destructive: Color(0xFFFCA5A5), + border: Color(0xFF334155), + input: Color(0xFF334155), + ring: Color(0xFF7DD3FC), + chart1: Color(0xFF93C5FD), + chart2: Color(0xFF6EE7B7), + chart3: Color(0xFFFCD34D), + chart4: Color(0xFF7DD3FC), + chart5: Color(0xFFFCA5A5), + sidebar: Color(0xFF1E293B), + sidebarForeground: Color(0xFFF1F5F9), + sidebarPrimary: Color(0xFF93C5FD), + sidebarPrimaryForeground: Color(0xFF1E3A5F), + sidebarAccent: Color(0xFF273344), + sidebarAccentForeground: Color(0xFFF1F5F9), + sidebarBorder: Color(0xFF334155), + sidebarRing: Color(0xFF7DD3FC), +); diff --git a/lib/models/clinic_discount.dart b/lib/models/clinic_discount.dart new file mode 100644 index 0000000..14bc9f3 --- /dev/null +++ b/lib/models/clinic_discount.dart @@ -0,0 +1,68 @@ +import 'job.dart'; + +enum DiscountType { percentage, fixed } + +extension DiscountTypeX on DiscountType { + String get value => name; + String get label => this == DiscountType.percentage ? 'Yüzde (%)' : 'Sabit Tutar (TL)'; +} + +class ClinicDiscount { + const ClinicDiscount({ + required this.id, + required this.labTenantId, + this.clinicTenantId, + this.clinicName, + this.prostheticType, + required this.discountType, + required this.discountValue, + this.minQuantity = 0, + required this.isActive, + this.notes, + }); + + final String id; + final String labTenantId; + final String? clinicTenantId; // null = tüm klinikler + final String? clinicName; + final String? prostheticType; // null = tüm ürün tipleri + final DiscountType discountType; + final double discountValue; + final int minQuantity; // 0 = minimum yok + final bool isActive; + final String? notes; + + bool get appliesToAll => clinicTenantId == null || clinicTenantId!.isEmpty; + bool get appliesToAllTypes => prostheticType == null || prostheticType!.isEmpty; + + String get displayValue => discountType == DiscountType.percentage + ? '%${discountValue.toStringAsFixed(discountValue % 1 == 0 ? 0 : 1)}' + : '${discountValue.toStringAsFixed(2)} TL'; + + String get prostheticLabel { + if (appliesToAllTypes) return 'Tüm Türler'; + return ProstheticType.values + .firstWhere((e) => e.value == prostheticType, + orElse: () => ProstheticType.diger) + .label; + } + + factory ClinicDiscount.fromJson(Map j) { + final expand = j['expand'] as Map?; + final clinicExp = expand?['clinic_tenant_id'] as Map?; + final dt = j['discount_type'] as String? ?? 'percentage'; + final pt = j['prosthetic_type'] as String?; + return ClinicDiscount( + id: j['id'] as String, + labTenantId: j['lab_tenant_id'] as String, + clinicTenantId: j['clinic_tenant_id'] as String?, + clinicName: clinicExp?['company_name'] as String?, + prostheticType: (pt == null || pt.isEmpty) ? null : pt, + discountType: dt == 'fixed' ? DiscountType.fixed : DiscountType.percentage, + discountValue: (j['discount_value'] as num?)?.toDouble() ?? 0, + minQuantity: (j['min_quantity'] as num?)?.toInt() ?? 0, + isActive: j['is_active'] as bool? ?? true, + notes: j['notes'] as String?, + ); + } +} diff --git a/lib/models/connection.dart b/lib/models/connection.dart new file mode 100644 index 0000000..7b119ff --- /dev/null +++ b/lib/models/connection.dart @@ -0,0 +1,53 @@ +enum ConnectionStatus { pending, approved, rejected } + +extension ConnectionStatusX on ConnectionStatus { + String get value => name; + String get label { + switch (this) { + case ConnectionStatus.pending: + return 'Bekliyor'; + case ConnectionStatus.approved: + return 'Onaylı'; + case ConnectionStatus.rejected: + return 'Reddedildi'; + } + } +} + +class Connection { + const Connection({ + required this.id, + required this.clinicTenantId, + required this.labTenantId, + required this.status, + this.clinicName, + this.labName, + this.dateCreated, + }); + + final String id; + final String clinicTenantId; + final String labTenantId; + final ConnectionStatus status; + final String? clinicName; + final String? labName; + final String? dateCreated; + + factory Connection.fromJson(Map j) { + final expand = j['expand'] as Map?; + final clinicExp = expand?['clinic_tenant_id'] as Map?; + final labExp = expand?['lab_tenant_id'] as Map?; + return Connection( + id: j['id'] as String, + clinicTenantId: j['clinic_tenant_id'] as String, + labTenantId: j['lab_tenant_id'] as String, + status: ConnectionStatus.values.firstWhere( + (e) => e.value == j['status'], + orElse: () => ConnectionStatus.pending, + ), + clinicName: clinicExp?['company_name'] as String?, + labName: labExp?['company_name'] as String?, + dateCreated: j['created'] as String?, + ); + } +} diff --git a/lib/models/finance_entry.dart b/lib/models/finance_entry.dart new file mode 100644 index 0000000..5b79e4e --- /dev/null +++ b/lib/models/finance_entry.dart @@ -0,0 +1,62 @@ +enum FinanceType { receivable, payable } + +extension FinanceTypeX on FinanceType { + String get value => name; + String get label => this == FinanceType.receivable ? 'Alacak' : 'Borç'; +} + +enum FinanceStatus { pending, paid } + +extension FinanceStatusX on FinanceStatus { + String get value => name; + String get label => this == FinanceStatus.pending ? 'Bekliyor' : 'Ödendi'; +} + +class FinanceEntry { + const FinanceEntry({ + required this.id, + required this.tenantId, + required this.jobId, + required this.type, + required this.amount, + required this.currency, + required this.status, + this.paidAt, + this.counterpartyName, + this.patientCode, + this.dateCreated, + }); + + final String id; + final String tenantId; + final String jobId; + final FinanceType type; + final double amount; + final String currency; + final FinanceStatus status; + final String? paidAt; + final String? counterpartyName; + final String? patientCode; + final String? dateCreated; + + factory FinanceEntry.fromJson(Map j) { + final expand = j['expand'] as Map?; + final jobExp = expand?['job_id'] as Map?; + String? _str(dynamic v) { final s = v as String?; return (s == null || s.isEmpty) ? null : s; } + return FinanceEntry( + id: j['id'] as String, + tenantId: j['tenant_id'] as String, + jobId: j['job_id'] as String, + type: FinanceType.values.firstWhere((e) => e.value == j['type'], + orElse: () => FinanceType.receivable), + amount: (j['amount'] as num).toDouble(), + currency: j['currency'] as String? ?? 'TRY', + status: FinanceStatus.values.firstWhere((e) => e.value == j['status'], + orElse: () => FinanceStatus.pending), + paidAt: _str(j['paid_at']), + counterpartyName: _str(j['counterparty_name']), + patientCode: jobExp?['patient_code'] as String?, + dateCreated: j['created'] as String?, + ); + } +} diff --git a/lib/models/job.dart b/lib/models/job.dart new file mode 100644 index 0000000..670c331 --- /dev/null +++ b/lib/models/job.dart @@ -0,0 +1,312 @@ +enum JobStatus { pending, inProgress, sent, delivered, cancelled } + +enum JobStep { + olcu, // legacy fallback + altYapiProva, // sabit seramik/metal — alt yapı (coping) + ustYapiProva, // sabit seramik — bisküvi prova + mumProva, // hareketli protez — mum prova + dislerProva, // hareketli protez — dişler prova + dayanakProva, // implant — dayanak prova + kronProva, // implant — kron prova + cilaBitim, // son cila / bitim (her şablonda son adım) +} + +enum JobLocation { atClinic, atLab } + +enum ProstheticType { + metalPorselen, + zirkonyum, + implantUstuZirkonyum, + gecici, + eMax, + tamProtez, + parsiyel, + diger, +} + +// ── Status ──────────────────────────────────────────────────────────────────── + +extension JobStatusExt on JobStatus { + String get label => switch (this) { + JobStatus.pending => 'Bekliyor', + JobStatus.inProgress => 'İşlemde', + JobStatus.sent => 'Gönderildi', + JobStatus.delivered => 'Teslim Alındı', + JobStatus.cancelled => 'İptal', + }; + String get value => switch (this) { + JobStatus.pending => 'pending', + JobStatus.inProgress => 'in_progress', + JobStatus.sent => 'sent', + JobStatus.delivered => 'delivered', + JobStatus.cancelled => 'cancelled', + }; +} + +// ── Step ────────────────────────────────────────────────────────────────────── + +extension JobStepExt on JobStep { + String get label => switch (this) { + JobStep.olcu => 'Ölçü', + JobStep.altYapiProva => 'Alt Yapı Prova', + JobStep.ustYapiProva => 'Üst Yapı Prova', + JobStep.mumProva => 'Mum Prova', + JobStep.dislerProva => 'Dişler Prova', + JobStep.dayanakProva => 'Dayanak Prova', + JobStep.kronProva => 'Kron Prova', + JobStep.cilaBitim => 'Cila / Bitim', + }; + + /// One-liner shown under the step on the stepper + String get description => switch (this) { + JobStep.olcu => 'İlk ölçü alındı', + JobStep.altYapiProva => 'Metal/zirkonyum coping klinik onayı', + JobStep.ustYapiProva => 'Bisküvi pişirimi sonrası klinik onayı', + JobStep.mumProva => 'Mum prova klinik onayı', + JobStep.dislerProva => 'Diş dizimi klinik onayı', + JobStep.dayanakProva => 'Dayanak klinik onayı', + JobStep.kronProva => 'Kron klinik onayı', + JobStep.cilaBitim => 'Son cila ve teslim hazırlığı', + }; + + String get value => switch (this) { + JobStep.olcu => 'olcu', + JobStep.altYapiProva => 'alt_yapi_prova', + JobStep.ustYapiProva => 'ust_yapi_prova', + JobStep.mumProva => 'mum_prova', + JobStep.dislerProva => 'disler_prova', + JobStep.dayanakProva => 'dayanak_prova', + JobStep.kronProva => 'kron_prova', + JobStep.cilaBitim => 'cila_bitim', + }; +} + +// ── Prosthetic type ─────────────────────────────────────────────────────────── + +extension ProstheticTypeExt on ProstheticType { + String get label => switch (this) { + ProstheticType.metalPorselen => 'Metal Porselen', + ProstheticType.zirkonyum => 'Zirkonyum', + ProstheticType.implantUstuZirkonyum => 'İmplant Üstü Zirkonyum', + ProstheticType.gecici => 'Geçici', + ProstheticType.eMax => 'E-Max', + ProstheticType.tamProtez => 'Tam Protez', + ProstheticType.parsiyel => 'Parsiyel Protez', + ProstheticType.diger => 'Diğer', + }; + String get value => switch (this) { + ProstheticType.metalPorselen => 'metal_porselen', + ProstheticType.zirkonyum => 'zirkonyum', + ProstheticType.implantUstuZirkonyum => 'implant_ustu_zirkonyum', + ProstheticType.gecici => 'gecici', + ProstheticType.eMax => 'e_max', + ProstheticType.tamProtez => 'tam_protez', + ProstheticType.parsiyel => 'parsiyel', + ProstheticType.diger => 'diger', + }; +} + +// ── Step template ───────────────────────────────────────────────────────────── + +/// Returns the ordered step list for a given prosthetic type + prova flag. +List jobStepTemplate(ProstheticType type, bool provaRequired) { + if (!provaRequired) return const [JobStep.cilaBitim]; + return switch (type) { + // Sabit seramik: alt yapı coping + bisküvi prova + cila + ProstheticType.metalPorselen || + ProstheticType.zirkonyum || + ProstheticType.eMax => + const [JobStep.altYapiProva, JobStep.ustYapiProva, JobStep.cilaBitim], + // İmplant: dayanak + kron prova + cila + ProstheticType.implantUstuZirkonyum => + const [JobStep.dayanakProva, JobStep.kronProva, JobStep.cilaBitim], + // Hareketli protez: mum + dişler prova + cila + ProstheticType.tamProtez || + ProstheticType.parsiyel => + const [JobStep.mumProva, JobStep.dislerProva, JobStep.cilaBitim], + // Geçici: sadece cila (prova gereksiz) + ProstheticType.gecici => + const [JobStep.cilaBitim], + // Diğer: tek ara prova + cila + _ => + const [JobStep.altYapiProva, JobStep.cilaBitim], + }; +} + +// ── Job ─────────────────────────────────────────────────────────────────────── + +class Job { + const Job({ + required this.id, + required this.clinicTenantId, + required this.labTenantId, + required this.patientCode, + required this.prostheticType, + required this.memberCount, + required this.status, + required this.dateCreated, + this.patientId, + this.prostheticId, + this.teeth = const [], + this.color, + this.description, + this.price, + this.currency, + this.currentStep, + this.location = JobLocation.atClinic, + this.dueDate, + this.clinicName, + this.labName, + this.attachments = const [], + this.provaRequired = true, + }); + + final String id; + final String clinicTenantId; + final String labTenantId; + final String? patientId; + final String patientCode; + final String? prostheticId; + final ProstheticType prostheticType; + final int memberCount; + final List teeth; + final String? color; + final String? description; + final double? price; + final String? currency; + final JobStatus status; + final JobStep? currentStep; + final JobLocation location; + final DateTime? dueDate; + final DateTime dateCreated; + final List attachments; + final bool provaRequired; + + // Denormalized from relation joins — list views only + final String? clinicName; + final String? labName; + + // ── copyWith ────────────────────────────────────────────────────────────── + + Job copyWith({ + JobStatus? status, + JobStep? currentStep, + JobLocation? location, + String? clinicName, + String? labName, + bool clearCurrentStep = false, + }) => + Job( + id: id, + clinicTenantId: clinicTenantId, + labTenantId: labTenantId, + patientId: patientId, + patientCode: patientCode, + prostheticId: prostheticId, + prostheticType: prostheticType, + memberCount: memberCount, + teeth: teeth, + color: color, + description: description, + price: price, + currency: currency, + status: status ?? this.status, + currentStep: clearCurrentStep ? null : (currentStep ?? this.currentStep), + location: location ?? this.location, + dueDate: dueDate, + dateCreated: dateCreated, + attachments: attachments, + provaRequired: provaRequired, + clinicName: clinicName ?? this.clinicName, + labName: labName ?? this.labName, + ); + + // ── Step helpers ────────────────────────────────────────────────────────── + + List get stepTemplate => jobStepTemplate(prostheticType, provaRequired); + + bool get isLastStep => + currentStep != null && currentStep == stepTemplate.last; + + /// The next step after currentStep in this job's template, or null if done. + JobStep? get nextStep { + if (currentStep == null) return stepTemplate.firstOrNull; + final idx = stepTemplate.indexOf(currentStep!); + if (idx < 0 || idx >= stepTemplate.length - 1) return null; + return stepTemplate[idx + 1]; + } + + factory Job.fromJson(Map j) { + final expand = j['expand'] as Map?; + final clinicExp = expand?['clinic_tenant_id'] as Map?; + final labExp = expand?['lab_tenant_id'] as Map?; + String? str(dynamic v) { + final s = v as String?; + return (s == null || s.isEmpty) ? null : s; + } + + return Job( + id: j['id'] as String, + clinicTenantId: j['clinic_tenant_id'] as String, + labTenantId: j['lab_tenant_id'] as String, + patientId: str(j['patient_id']), + patientCode: j['patient_code'] as String, + prostheticId: str(j['prosthetic_id']), + prostheticType: _parseProstheticType(j['prosthetic_type'] as String), + memberCount: (j['member_count'] as num).toInt(), + teeth: j['teeth'] is List + ? (j['teeth'] as List).map((e) => e.toString()).toList() + : [], + color: str(j['color']), + description: str(j['description']), + price: (j['price'] as num?)?.toDouble(), + currency: str(j['currency']), + status: _parseStatus(j['status'] as String), + currentStep: str(j['current_step']) != null + ? _parseStep(j['current_step'] as String) + : null, + location: + j['location'] == 'at_lab' ? JobLocation.atLab : JobLocation.atClinic, + dueDate: str(j['due_date']) != null + ? DateTime.parse(j['due_date'] as String) + : null, + dateCreated: DateTime.parse(j['created'] as String), + clinicName: clinicExp?['company_name'] as String?, + labName: labExp?['company_name'] as String?, + attachments: j['attachments'] is List + ? (j['attachments'] as List).map((e) => e.toString()).toList() + : [], + provaRequired: (j['prova_required'] as bool?) ?? true, + ); + } + + static JobStatus _parseStatus(String s) => switch (s) { + 'in_progress' => JobStatus.inProgress, + 'sent' => JobStatus.sent, + 'delivered' => JobStatus.delivered, + 'cancelled' => JobStatus.cancelled, + _ => JobStatus.pending, + }; + + static JobStep _parseStep(String s) => switch (s) { + 'alt_yapi_prova' => JobStep.altYapiProva, + 'ust_yapi_prova' => JobStep.ustYapiProva, + 'mum_prova' => JobStep.mumProva, + 'disler_prova' => JobStep.dislerProva, + 'dayanak_prova' => JobStep.dayanakProva, + 'kron_prova' => JobStep.kronProva, + 'cila_bitim' => JobStep.cilaBitim, + _ => JobStep.olcu, + }; + + static ProstheticType _parseProstheticType(String s) => switch (s) { + 'zirkonyum' => ProstheticType.zirkonyum, + 'implant_ustu_zirkonyum'=> ProstheticType.implantUstuZirkonyum, + 'gecici' => ProstheticType.gecici, + 'e_max' => ProstheticType.eMax, + 'tam_protez' => ProstheticType.tamProtez, + 'parsiyel' => ProstheticType.parsiyel, + 'diger' => ProstheticType.diger, + _ => ProstheticType.metalPorselen, + }; +} diff --git a/lib/models/job_file.dart b/lib/models/job_file.dart new file mode 100644 index 0000000..82e3ae7 --- /dev/null +++ b/lib/models/job_file.dart @@ -0,0 +1,84 @@ +enum JobFileKind { scan, image, document } + +extension JobFileKindExt on JobFileKind { + String get label => switch (this) { + JobFileKind.scan => 'Tarama', + JobFileKind.image => 'Görsel', + JobFileKind.document => 'Belge', + }; + String get value => switch (this) { + JobFileKind.scan => 'scan', + JobFileKind.image => 'image', + JobFileKind.document => 'document', + }; + + static JobFileKind fromValue(String s) => switch (s) { + 'image' => JobFileKind.image, + 'document' => JobFileKind.document, + _ => JobFileKind.scan, + }; +} + +class JobFile { + const JobFile({ + required this.id, + required this.jobId, + required this.clinicTenantId, + required this.labTenantId, + required this.uploadedBy, + required this.kind, + required this.fileName, + required this.name, + required this.size, + required this.createdAt, + required this.downloadUrl, + this.mimeType, + }); + + final String id; + final String jobId; + final String clinicTenantId; + final String labTenantId; + final String uploadedBy; + final JobFileKind kind; + final String fileName; + final String name; + final int size; + final String? mimeType; + final DateTime createdAt; + final String downloadUrl; + + String get sizeLabel { + if (size < 1024) return '$size B'; + if (size < 1024 * 1024) return '${(size / 1024).toStringAsFixed(1)} KB'; + return '${(size / (1024 * 1024)).toStringAsFixed(2)} MB'; + } + + factory JobFile.fromJson(Map j, String baseUrl) { + String str(String key, [String fallback = '']) => + (j[key] as String?) ?? fallback; + final id = str('id'); + final collectionId = str('collectionId', 'job_files'); + final fileName = str('file'); + final url = fileName.isNotEmpty + ? '$baseUrl/api/files/$collectionId/$id/$fileName' + : ''; + final createdRaw = str('created'); + return JobFile( + id: id, + jobId: str('job_id'), + clinicTenantId: str('clinic_tenant_id'), + labTenantId: str('lab_tenant_id'), + uploadedBy: str('uploaded_by'), + kind: JobFileKindExt.fromValue(str('kind')), + fileName: fileName, + name: str('name'), + size: (j['size'] as num?)?.toInt() ?? 0, + mimeType: j['mime_type'] as String?, + createdAt: createdRaw.isNotEmpty + ? DateTime.tryParse(createdRaw) ?? DateTime(2000) + : DateTime(2000), + downloadUrl: url, + ); + } +} diff --git a/lib/models/patient.dart b/lib/models/patient.dart new file mode 100644 index 0000000..67aa81b --- /dev/null +++ b/lib/models/patient.dart @@ -0,0 +1,49 @@ +class Patient { + const Patient({ + required this.id, + required this.tenantId, + required this.patientCode, + this.firstName, + this.lastName, + this.birthDate, + this.phone, + this.notes, + }); + + final String id; + final String tenantId; + final String patientCode; + final String? firstName; + final String? lastName; + final String? birthDate; + final String? phone; + final String? notes; + + String get displayName { + final parts = [firstName, lastName].where((s) => s != null && s.isNotEmpty); + return parts.isEmpty ? patientCode : parts.join(' '); + } + + factory Patient.fromJson(Map j) => Patient( + id: j['id'] as String, + tenantId: j['tenant_id'] is Map + ? (j['tenant_id'] as Map)['id'] as String + : j['tenant_id'] as String, + patientCode: j['patient_code'] as String, + firstName: j['first_name'] as String?, + lastName: j['last_name'] as String?, + birthDate: j['birth_date'] as String?, + phone: j['phone'] as String?, + notes: j['notes'] as String?, + ); + + Map toJson() => { + 'tenant_id': tenantId, + 'patient_code': patientCode, + if (firstName != null) 'first_name': firstName, + if (lastName != null) 'last_name': lastName, + if (birthDate != null) 'birth_date': birthDate, + if (phone != null) 'phone': phone, + if (notes != null) 'notes': notes, + }; +} diff --git a/lib/models/prosthetic_product.dart b/lib/models/prosthetic_product.dart new file mode 100644 index 0000000..9e3abc6 --- /dev/null +++ b/lib/models/prosthetic_product.dart @@ -0,0 +1,45 @@ +class ProstheticProduct { + const ProstheticProduct({ + required this.id, + required this.labTenantId, + required this.name, + required this.prostheticType, + this.unitPrice, + this.currency, + this.isActive = true, + this.description, + }); + + final String id; + final String labTenantId; + final String name; + final String prostheticType; + final double? unitPrice; + final String? currency; + final bool isActive; + final String? description; + + factory ProstheticProduct.fromJson(Map j) { + String? _str(dynamic v) { final s = v as String?; return (s == null || s.isEmpty) ? null : s; } + return ProstheticProduct( + id: j['id'] as String, + labTenantId: j['lab_tenant_id'] as String, + name: j['name'] as String, + prostheticType: j['prosthetic_type'] as String, + unitPrice: (j['unit_price'] as num?)?.toDouble(), + currency: j['currency'] as String? ?? 'TRY', + isActive: j['is_active'] as bool? ?? true, + description: _str(j['description']), + ); + } + + Map toJson() => { + 'lab_tenant_id': labTenantId, + 'name': name, + 'prosthetic_type': prostheticType, + if (unitPrice != null) 'unit_price': unitPrice, + 'currency': currency ?? 'TRY', + 'is_active': isActive, + if (description != null) 'description': description, + }; +} diff --git a/lib/models/tenant.dart b/lib/models/tenant.dart new file mode 100644 index 0000000..863bc44 --- /dev/null +++ b/lib/models/tenant.dart @@ -0,0 +1,145 @@ +enum TenantKind { lab, clinic } + +enum TenantRole { + owner, + admin, + technician, // lab: işler + ürünler + delivery, // lab: işler + finance, // lab+clinic: finans + doctor, // clinic: işler + hastalar + member, // legacy — full access + ; + + String get value => name; + + String get label => switch (this) { + TenantRole.owner => 'Sahibi', + TenantRole.admin => 'Yönetici', + TenantRole.technician => 'Teknisyen', + TenantRole.delivery => 'Teslimat Elemanı', + TenantRole.finance => 'Finans Elemanı', + TenantRole.doctor => 'Hekim', + TenantRole.member => 'Üye', + }; +} + +enum TenantPlan { starter, pro, enterprise } + +class Tenant { + const Tenant({ + required this.id, + required this.kind, + required this.memberNumber, + required this.companyName, + this.logo, + this.defaultCurrency = 'TRY', + this.status = 'active', + this.plan, + this.maxMembers, + }); + + final String id; + final TenantKind kind; + final String memberNumber; + final String companyName; + final String? logo; + final String defaultCurrency; + final String status; + final TenantPlan? plan; + final int? maxMembers; + + bool get isLab => kind == TenantKind.lab; + bool get isClinic => kind == TenantKind.clinic; + + factory Tenant.fromJson(Map j) => Tenant( + id: j['id'] as String, + kind: j['kind'] == 'lab' ? TenantKind.lab : TenantKind.clinic, + memberNumber: (j['member_number'] as String?) ?? '', + companyName: j['company_name'] as String, + logo: j['logo'] as String?, + defaultCurrency: (j['default_currency'] as String?) ?? 'TRY', + status: (j['status'] as String?) ?? 'active', + plan: _parsePlan(j['plan'] as String?), + maxMembers: (j['max_members'] as num?)?.toInt(), + ); + + static TenantPlan? _parsePlan(String? p) => switch (p) { + 'starter' => TenantPlan.starter, + 'pro' => TenantPlan.pro, + 'enterprise' => TenantPlan.enterprise, + _ => null, + }; +} + +class TenantMembership { + const TenantMembership({ + required this.id, + required this.tenant, + required this.role, + }); + + final String id; + final Tenant tenant; + final TenantRole role; + + // ── Access helpers ──────────────────────────────────────────────────────── + bool get isOwner => role == TenantRole.owner; + bool get isAdmin => role == TenantRole.admin || role == TenantRole.owner; + bool get canManageUsers => role == TenantRole.owner || role == TenantRole.admin; + bool get canManageJobs => role != TenantRole.finance; + bool get canManageFinance => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.finance || role == TenantRole.member; + bool get canManageProducts => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.technician || role == TenantRole.member; + bool get canViewPatients => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.doctor || role == TenantRole.member; + bool get canManageConnections => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.member; + + // ── Fine-grained job actions ────────────────────────────────────────────── + /// Can create new jobs (clinic side: owner/admin/doctor/member; not delivery/finance) + bool get canCreateJobs => role != TenantRole.delivery && role != TenantRole.finance; + + /// Can confirm physical delivery (delivery role + supervisors) + bool get canDeliverJobs => role != TenantRole.finance; + + /// Can cancel or fully manage job lifecycle (not delivery-only or finance) + bool get canCancelJobs => role == TenantRole.owner || role == TenantRole.admin || role == TenantRole.member || role == TenantRole.doctor; + + /// Primary focus is delivery — restrict to delivery-relevant UI + bool get isDeliveryOnly => role == TenantRole.delivery; + + // ── Nav visibility ──────────────────────────────────────────────────────── + bool get showDashboard => true; + bool get showJobs => canManageJobs; + bool get showProducts => tenant.isLab && canManageProducts; + bool get showPatients => tenant.isClinic && canViewPatients; + bool get showConnections => canManageConnections; + bool get showFinance => canManageFinance; + + factory TenantMembership.fromJson(Map j) { + final expand = j['expand'] as Map?; + final tenantData = expand?['tenant_id'] as Map?; + return TenantMembership( + id: j['id'] as String, + tenant: Tenant.fromJson(tenantData!), + role: parseRole(j['role'] as String), + ); + } + + static TenantRole parseRole(String r) => switch (r) { + 'owner' => TenantRole.owner, + 'admin' => TenantRole.admin, + 'technician' => TenantRole.technician, + 'delivery' => TenantRole.delivery, + 'finance' => TenantRole.finance, + 'doctor' => TenantRole.doctor, + _ => TenantRole.member, + }; + + String get roleLabel => switch (role) { + TenantRole.owner => 'Sahibi', + TenantRole.admin => 'Yönetici', + TenantRole.technician => 'Teknisyen', + TenantRole.delivery => 'Teslimat Elemanı', + TenantRole.finance => 'Finans Elemanı', + TenantRole.doctor => 'Hekim', + TenantRole.member => 'Üye', + }; +} diff --git a/lib/models/tenant_invite.dart b/lib/models/tenant_invite.dart new file mode 100644 index 0000000..3c02533 --- /dev/null +++ b/lib/models/tenant_invite.dart @@ -0,0 +1,37 @@ +import 'tenant.dart'; + +class TenantInvite { + const TenantInvite({ + required this.id, + required this.tenantId, + required this.email, + required this.jobRole, + required this.token, + required this.expiresAt, + required this.status, + required this.invitedById, + }); + + final String id; + final String tenantId; + final String email; + final TenantRole jobRole; + final String token; + final DateTime expiresAt; + final String status; // pending | accepted | expired + final String invitedById; + + bool get isPending => status == 'pending'; + bool get isExpired => status == 'expired' || expiresAt.isBefore(DateTime.now()); + + factory TenantInvite.fromJson(Map j) => TenantInvite( + id: j['id'] as String, + tenantId: j['tenant_id'] as String, + email: j['email'] as String, + jobRole: TenantMembership.parseRole(j['job_role'] as String), + token: j['token'] as String, + expiresAt: DateTime.parse(j['expires_at'] as String), + status: j['status'] as String, + invitedById: j['invited_by'] as String, + ); +} diff --git a/lib/models/user_profile.dart b/lib/models/user_profile.dart new file mode 100644 index 0000000..beec4c2 --- /dev/null +++ b/lib/models/user_profile.dart @@ -0,0 +1,31 @@ +class UserProfile { + const UserProfile({ + required this.id, + required this.email, + this.firstName, + this.lastName, + this.preferredLanguage, + }); + + final String id; + final String email; + final String? firstName; + final String? lastName; + final String? preferredLanguage; + + String get displayName => + [firstName, lastName].where((s) => s != null && s.isNotEmpty).join(' '); + + factory UserProfile.fromJson(Map j) => UserProfile( + id: j['id'] as String, + email: j['email'] as String, + firstName: _str(j['first_name']), + lastName: _str(j['last_name']), + preferredLanguage: _str(j['preferred_language']), + ); + + static String? _str(dynamic v) { + final s = v as String?; + return (s == null || s.isEmpty) ? null : s; + } +} 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..2752c54 --- /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 "lab_app") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.kovaksoft.lab_app") + +# 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..f6f23bf --- /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) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_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..df8d2f7 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +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..3c66f1d --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "lab_app"); + 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, "lab_app"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#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..a400395 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,18 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import file_picker +import share_plus +import shared_preferences_foundation +import sqflite_darwin + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..f591201 --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,42 @@ +PODS: + - file_picker (0.0.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - share_plus (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) + +EXTERNAL SOURCES: + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos + FlutterMacOS: + :path: Flutter/ephemeral + share_plus: + :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin + +SPEC CHECKSUMS: + file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..958b6ee --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,812 @@ +// !$*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 */; }; + D86BA1358569CDEEC563AEE8 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3757D3A5D288E2CE07281282 /* Pods_Runner.framework */; }; + FB0DB25F8D85A21F9038C00C /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B45D24BEC14499565196D8D1 /* Pods_RunnerTests.framework */; }; +/* 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 */ + 193F1620662F80E606E49FE9 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 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 /* lab_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = lab_app.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 = ""; }; + 3757D3A5D288E2CE07281282 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 46182606C9DD22A9B984EF6C /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 67CE13E5BD24E015FD9F63F2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 7C746E1484667EC1724EFD80 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 86931C0E7B6C1DF763FDACE4 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 8D934720EB9A13518055507D /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + B45D24BEC14499565196D8D1 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FB0DB25F8D85A21F9038C00C /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D86BA1358569CDEEC563AEE8 /* Pods_Runner.framework in Frameworks */, + ); + 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 */, + 723D63EA1F50C8248AB65096 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* lab_app.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 = ""; + }; + 723D63EA1F50C8248AB65096 /* Pods */ = { + isa = PBXGroup; + children = ( + 67CE13E5BD24E015FD9F63F2 /* Pods-Runner.debug.xcconfig */, + 8D934720EB9A13518055507D /* Pods-Runner.release.xcconfig */, + 193F1620662F80E606E49FE9 /* Pods-Runner.profile.xcconfig */, + 86931C0E7B6C1DF763FDACE4 /* Pods-RunnerTests.debug.xcconfig */, + 7C746E1484667EC1724EFD80 /* Pods-RunnerTests.release.xcconfig */, + 46182606C9DD22A9B984EF6C /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 3757D3A5D288E2CE07281282 /* Pods_Runner.framework */, + B45D24BEC14499565196D8D1 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 2030560B746BE6458B92025E /* [CP] Check Pods Manifest.lock */, + 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 = ( + 03FAA69D7458CEF6E05E5905 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 3883C84F0A7CA8A2EA960026 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* lab_app.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 */ + 03FAA69D7458CEF6E05E5905 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 2030560B746BE6458B92025E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 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"; + }; + 3883C84F0A7CA8A2EA960026 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* 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; + baseConfigurationReference = 86931C0E7B6C1DF763FDACE4 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 11.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.kovakyazilim.labapp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/lab_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/lab_app"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7C746E1484667EC1724EFD80 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 11.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.kovakyazilim.labapp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/lab_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/lab_app"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 46182606C9DD22A9B984EF6C /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 11.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.kovakyazilim.labapp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/lab_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/lab_app"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + 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", + ); + MACOSX_DEPLOYMENT_TARGET = 11.0; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + MACOSX_DEPLOYMENT_TARGET = 11.0; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + 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", + ); + MACOSX_DEPLOYMENT_TARGET = 11.0; + 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", + ); + MACOSX_DEPLOYMENT_TARGET = 11.0; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + MACOSX_DEPLOYMENT_TARGET = 11.0; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + MACOSX_DEPLOYMENT_TARGET = 11.0; + 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..e6f511d --- /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..21a3cc1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + 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..3b8f85c 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..df3e47d 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..3ce116a 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..6ee7dd0 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..8b8e8e8 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..259f60c 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..0a519d7 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..6c689af --- /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 = DLS + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.kovaksoft.labApp + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.kovaksoft. 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..cff5a4b --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + + 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..a650a57 --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,29 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + + // Center window with a sensible default size + let screenSize = NSScreen.main?.frame.size ?? CGSize(width: 1440, height: 900) + let windowWidth: CGFloat = min(1280, screenSize.width * 0.85) + let windowHeight: CGFloat = min(820, screenSize.height * 0.85) + let origin = CGPoint( + x: (screenSize.width - windowWidth) / 2, + y: (screenSize.height - windowHeight) / 2 + ) + let windowFrame = NSRect(origin: origin, size: CGSize(width: windowWidth, height: windowHeight)) + + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + // Keep sidebar always visible — desktop layout kicks in at 720px + self.minSize = CGSize(width: 880, height: 560) + self.titlebarAppearsTransparent = true + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..afcec2c --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + aps-environment + production + + 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/maestro/flows/00_login_clinic.yaml b/maestro/flows/00_login_clinic.yaml new file mode 100644 index 0000000..87bc258 --- /dev/null +++ b/maestro/flows/00_login_clinic.yaml @@ -0,0 +1,11 @@ +appId: com.kovaksoft.labApp +--- +- launchApp: + clearState: true +- assertVisible: "Tekrar hoş geldiniz" +- tapOn: "E-posta adresi" +- inputText: "egecankomur@icloud.com" +- tapOn: "Şifre" +- inputText: "Oyuncu21" +- tapOn: "Giriş Yap" +- assertVisible: "Bugünkü Durum" diff --git a/maestro/flows/00_login_lab.yaml b/maestro/flows/00_login_lab.yaml new file mode 100644 index 0000000..10dc834 --- /dev/null +++ b/maestro/flows/00_login_lab.yaml @@ -0,0 +1,11 @@ +appId: com.kovaksoft.labApp +--- +- launchApp: + clearState: true +- assertVisible: "Tekrar hoş geldiniz" +- tapOn: "E-posta adresi" +- inputText: "egecankomur@gmail.com" +- tapOn: "Şifre" +- inputText: "Oyuncu21" +- tapOn: "Giriş Yap" +- assertVisible: "Bugünkü Durum" diff --git a/maestro/flows/00_logout.yaml b/maestro/flows/00_logout.yaml new file mode 100644 index 0000000..1da7760 --- /dev/null +++ b/maestro/flows/00_logout.yaml @@ -0,0 +1,14 @@ +appId: com.kovaksoft.labApp +--- +# "Ayarlar" tab — koordinat bazlı (NavBar text Maestro XCTest'e görünmüyor) +- tapOn: + point: "90%,94%" +- scrollUntilVisible: + element: + text: "Çıkış Yap" + direction: DOWN +- tapOn: "Çıkış Yap" +- tapOn: + text: "Çıkış Yap" + index: 1 +- assertVisible: "Tekrar hoş geldiniz" diff --git a/maestro/flows/01_lab_send_prova.yaml b/maestro/flows/01_lab_send_prova.yaml new file mode 100644 index 0000000..406ca1c --- /dev/null +++ b/maestro/flows/01_lab_send_prova.yaml @@ -0,0 +1,28 @@ +appId: com.kovaksoft.labApp +--- +# ADIM 1: Lab → ust_yapi_prova adımını klinik provaya gönder +- runFlow: "00_login_lab.yaml" + +# Job card dashboard'da görünüyor — direkt tıkla +- tapOn: "PR-20260607-5YPP" +- assertVisible: "İş Detayı" + +# "Prova için Kliniğe Gönder" butonunu bul ve tıkla (detay ekranındaki) +- scrollUntilVisible: + element: + text: "Prova için Kliniğe Gönder" + direction: DOWN +- tapOn: "Prova için Kliniğe Gönder" + +# Sheet açıldı — başlık görünüyor +- assertVisible: "Üst Yapı Prova için Kliniğe Gönder" +- tapOn: "Not (isteğe bağlı)" +- inputText: "Bisküvi prova için gönderildi" + +# Sheet'teki submit butonu — title ile aynı metin, index:1 kullan +- tapOn: + text: "Üst Yapı Prova için Kliniğe Gönder" + index: 1 + +# Başarı — sheet kapandı, "Prova için Kliniğe Gönder" butonu artık yok (location=at_clinic) +- assertNotVisible: "Prova için Kliniğe Gönder" diff --git a/maestro/flows/02_clinic_approve_prova.yaml b/maestro/flows/02_clinic_approve_prova.yaml new file mode 100644 index 0000000..4ec40f8 --- /dev/null +++ b/maestro/flows/02_clinic_approve_prova.yaml @@ -0,0 +1,20 @@ +appId: com.kovaksoft.labApp +--- +# ADIM 2: Klinik → ust_yapi_prova onayı ver +# Başlangıç durumu: at_clinic, in_progress + +- runFlow: "00_login_clinic.yaml" + +# Job "Son İşler" dashboard'unda görünür — direkt tıkla +- tapOn: "PR-20260607-5YPP" +- assertVisible: "İş Detayı" + +# Prova onay butonu görünmeli +- scrollUntilVisible: + element: + text: "Onayla" + direction: DOWN +- tapOn: "Onayla" + +# Başarı — prova onaylandı, "Onayla" butonu artık yok +- assertNotVisible: "Onayla" diff --git a/maestro/flows/03_lab_send_final.yaml b/maestro/flows/03_lab_send_final.yaml new file mode 100644 index 0000000..6372810 --- /dev/null +++ b/maestro/flows/03_lab_send_final.yaml @@ -0,0 +1,30 @@ +appId: com.kovaksoft.labApp +--- +# ADIM 3: Lab → cila_bitim adımı, son gönderim +# Başlangıç durumu: cila_bitim, at_lab, in_progress + +- runFlow: "00_login_lab.yaml" + +# Job dashboard "Devam Eden İşler"'de görünür +- tapOn: "PR-20260607-5YPP" +- assertVisible: "İş Detayı" + +# Son Prova - Teslime Gönder butonu görünmeli +- scrollUntilVisible: + element: + text: "Son Prova - Teslime Gönder" + direction: DOWN +- tapOn: "Son Prova - Teslime Gönder" + +# Sheet: son gönderim notu +- assertVisible: "Son Prova · Teslime Gönder" +- tapOn: "Not (isteğe bağlı)" +- inputText: "Son işlem tamamlandı, teslim için gönderiliyor" + +# Sheet'teki submit butonu (başlık ile aynı metin, index:1) +- tapOn: + text: "Son Prova · Teslime Gönder" + index: 1 + +# İş artık "Gönderildi" — gönder butonu artık yok +- assertNotVisible: "Son Prova - Teslime Gönder" diff --git a/maestro/flows/04_clinic_deliver.yaml b/maestro/flows/04_clinic_deliver.yaml new file mode 100644 index 0000000..9825eea --- /dev/null +++ b/maestro/flows/04_clinic_deliver.yaml @@ -0,0 +1,30 @@ +appId: com.kovaksoft.labApp +--- +# ADIM 4: Klinik → "Teslim Aldım" işareti +# Başlangıç durumu: status=sent, at_clinic + +- runFlow: "00_login_clinic.yaml" + +# Job "Son İşler"'de görünür — direkt tıkla +- tapOn: "PR-20260607-5YPP" +- assertVisible: "İş Detayı" + +# Teslim Al butonu +- scrollUntilVisible: + element: + text: "Teslim Aldım" + direction: DOWN +- tapOn: "Teslim Aldım" + +# Dialog +- assertVisible: "Teslim Alındı" +- tapOn: "Teslimat notu (isteğe bağlı)" +- inputText: "Teslim alındı, hasta bilgilendirildi" + +# Dialog'daki "Teslim Alındı" butonuna bas (index:1 — başlık ile aynı) +- tapOn: + text: "Teslim Alındı" + index: 1 + +# Başarı — "Teslim Aldım" butonu artık yok +- assertNotVisible: "Teslim Aldım" diff --git a/maestro/flows/05_new_job_no_black_screen.yaml b/maestro/flows/05_new_job_no_black_screen.yaml new file mode 100644 index 0000000..5e47a19 --- /dev/null +++ b/maestro/flows/05_new_job_no_black_screen.yaml @@ -0,0 +1,38 @@ +appId: com.kovaksoft.labApp +--- +# ADIM 5: Klinik → Yeni iş oluştur, siyah ekran olmamalı + +- runFlow: "00_login_clinic.yaml" + +# "İşler" tab — koordinat bazlı +- tapOn: + point: "30%,94%" + +# Yeni iş butonu (FAB veya AppBar action) +- tapOn: + description: "Yeni İş" +- assertVisible: "Yeni İş" + +# Lab seç +- scrollUntilVisible: + element: + text: "IO Lab" + direction: DOWN +- tapOn: "IO Lab" + +# Protez türü seç +- tapOn: "Protez Türü" +- tapOn: "Zirkonyum" + +# Diş seç — "Üst Çene" kısa yolu +- tapOn: "Üst Çene" + +# Kaydet +- scrollUntilVisible: + element: + text: "Kaydet" + direction: DOWN +- tapOn: "Kaydet" + +# Siyah ekran olmamalı — iş detay ekranı açılmalı +- assertVisible: "İş Detayı" diff --git a/maestro/login_flow.yaml b/maestro/login_flow.yaml new file mode 100644 index 0000000..38f75db --- /dev/null +++ b/maestro/login_flow.yaml @@ -0,0 +1,10 @@ +appId: com.kovaksoft.labApp +--- +- launchApp +- assertVisible: "Hoş geldiniz" +- tapOn: "E-posta" +- inputText: "test@test.com" +- tapOn: "Şifre" +- inputText: "test123" +- hideKeyboard +- tapOn: "Giriş Yap" diff --git a/pb_hooks/jobs_notifications.pb.js b/pb_hooks/jobs_notifications.pb.js new file mode 100644 index 0000000..931e1fd --- /dev/null +++ b/pb_hooks/jobs_notifications.pb.js @@ -0,0 +1,129 @@ +/// @ts-check + +onRecordAfterCreateSuccess((e) => { + const APP_ID = "524cb6d8-2640-4f85-bb24-c9c762233de7"; + const API_KEY = "os_v2_app_kjglnwbgibhylozezhdweiz546iv5ns5h2hujfvrsao64p7qkqk6lwqthgwirm6naxdy37tmd5nivppfqmxrnqreoewpgqkcnawjjpy"; + try { + const rec = e.record; + if (rec.getString("status") !== "pending") return; + const labTenantId = rec.getString("lab_tenant_id"); + const code = rec.getString("patient_code") || "Is"; + const type = rec.getString("prosthetic_type") || ""; + const label = type ? code + " - " + type : code; + const jobId = rec.getString("id"); + let userIds = []; + try { + const members = $app.findRecordsByFilter("tenant_members", 'tenant_id = "' + labTenantId + '"', "", 100, 0); + userIds = members.map(function(r) { return r.getString("user_id"); }).filter(Boolean); + } catch (_) {} + if (!userIds.length) return; + const res = $http.send({ + url: "https://onesignal.com/api/v1/notifications", + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": "Basic " + API_KEY }, + body: JSON.stringify({ app_id: APP_ID, include_external_user_ids: userIds, channel_for_external_user_ids: "push", + headings: { en: "Yeni Is Talebi", tr: "Yeni Is Talebi" }, + contents: { en: label + " icin yeni bir is talebi geldi.", tr: label + " icin yeni bir is talebi geldi." }, + data: { job_id: jobId, tenant_type: "lab" } }), + timeout: 10, + }); + console.log("[notif-create] status=" + res.statusCode + " users=" + userIds.length); + } catch (err) { + console.log("[notif-create] error: " + String(err)); + } +}, "jobs"); + +onRecordAfterUpdateSuccess((e) => { + const APP_ID = "524cb6d8-2640-4f85-bb24-c9c762233de7"; + const API_KEY = "os_v2_app_kjglnwbgibhylozezhdweiz546iv5ns5h2hujfvrsao64p7qkqk6lwqthgwirm6naxdy37tmd5nivppfqmxrnqreoewpgqkcnawjjpy"; + try { + const rec = e.record; + const newStatus = rec.getString("status"); + const newLocation = rec.getString("location"); + const clinicTenantId = rec.getString("clinic_tenant_id"); + const labTenantId = rec.getString("lab_tenant_id"); + const code = rec.getString("patient_code") || "Is"; + const type = rec.getString("prosthetic_type") || ""; + const label = type ? code + " - " + type : code; + const jobId = rec.getString("id"); + let oldStatus = ""; + let oldLocation = ""; + try { + const orig = rec.original(); + if (orig) { + oldStatus = orig.getString("status"); + oldLocation = orig.getString("location"); + } + } catch (_) {} + + if (oldStatus && oldStatus === newStatus && oldLocation === newLocation) return; + + let targetTenantId = ""; + let title = ""; + let body = ""; + let tenantType = "clinic"; + + if (oldStatus === "pending" && newStatus === "in_progress" && newLocation === "at_lab") { + targetTenantId = clinicTenantId; title = "Is Kabul Edildi"; + body = label + " laboratuvar tarafindan kabul edildi."; tenantType = "clinic"; + } else if (newStatus === "in_progress" && newLocation === "at_clinic" && oldLocation !== "at_clinic") { + targetTenantId = clinicTenantId; title = "Prova Onayi Bekleniyor"; + body = label + " prova icin klinik onayini bekliyor."; tenantType = "clinic"; + } else if (oldLocation === "at_clinic" && newLocation === "at_lab" && newStatus === "in_progress") { + targetTenantId = labTenantId; title = "Klinik Geri Bildirimi"; + body = label + " icin klinikten geri bildirim geldi."; tenantType = "lab"; + } else if (newStatus === "sent" && oldStatus !== "sent") { + targetTenantId = clinicTenantId; title = "Teslimat Hazir"; + body = label + " teslim icin hazir."; tenantType = "clinic"; + } else if (newStatus === "delivered" && oldStatus !== "delivered") { + targetTenantId = labTenantId; title = "Is Teslim Alindi"; + body = label + " klinik tarafindan teslim alindi."; tenantType = "lab"; + } else if (newStatus === "cancelled" && oldStatus !== "cancelled") { + let allIds = []; + try { + const cm = $app.findRecordsByFilter("tenant_members", 'tenant_id = "' + clinicTenantId + '"', "", 100, 0); + const lm = $app.findRecordsByFilter("tenant_members", 'tenant_id = "' + labTenantId + '"', "", 100, 0); + const raw = cm.concat(lm).map(function(r) { return r.getString("user_id"); }).filter(Boolean); + allIds = raw.filter(function(id, idx) { return raw.indexOf(id) === idx; }); + } catch (_) {} + if (!allIds.length) return; + const res = $http.send({ + url: "https://onesignal.com/api/v1/notifications", + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": "Basic " + API_KEY }, + body: JSON.stringify({ app_id: APP_ID, include_external_user_ids: allIds, channel_for_external_user_ids: "push", + headings: { en: "Is Iptal Edildi", tr: "Is Iptal Edildi" }, + contents: { en: label + " iptal edildi.", tr: label + " iptal edildi." }, + data: { job_id: jobId, tenant_type: "clinic" } }), + timeout: 10, + }); + console.log("[notif-update] cancelled status=" + res.statusCode + " users=" + allIds.length); + return; + } else { + return; + } + + let userIds = []; + try { + const members = $app.findRecordsByFilter("tenant_members", 'tenant_id = "' + targetTenantId + '"', "", 100, 0); + userIds = members.map(function(r) { return r.getString("user_id"); }).filter(Boolean); + } catch (_) {} + + if (!userIds.length) return; + + const res = $http.send({ + url: "https://onesignal.com/api/v1/notifications", + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": "Basic " + API_KEY }, + body: JSON.stringify({ app_id: APP_ID, include_external_user_ids: userIds, channel_for_external_user_ids: "push", + headings: { en: title, tr: title }, + contents: { en: body, tr: body }, + data: { job_id: jobId, tenant_type: tenantType } }), + timeout: 10, + }); + console.log("[notif-update] " + title + " status=" + res.statusCode + " users=" + userIds.length); + + } catch (err) { + console.log("[notif-update] error: " + String(err)); + } +}, "jobs"); diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..4b0d620 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1311 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c + url: "https://pub.dev" + source: hosted + version: "7.6.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce + url: "https://pub.dev" + source: hosted + version: "0.13.4" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + url: "https://pub.dev" + source: hosted + version: "9.1.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + country_flags: + dependency: transitive + description: + name: country_flags + sha256: f022d18337f3861f1f4e319b936cb53920de9259f38cb09e169eace9942e2b79 + url: "https://pub.dev" + source: hosted + version: "4.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be" + url: "https://pub.dev" + source: hosted + version: "0.7.5" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2" + url: "https://pub.dev" + source: hosted + version: "1.0.0+7.7.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + data_widget: + dependency: transitive + description: + name: data_widget + sha256: "4947aae3c50635496d56f94ad18de98e19015c5ebf01abee0f39a2c098c7021a" + url: "https://pub.dev" + source: hosted + version: "0.0.3" + drift: + dependency: transitive + description: + name: drift + sha256: "540cf382a3bfa99b76e51514db5b0ebcd81ce3679b7c1c9cb9478ff3735e47a1" + url: "https://pub.dev" + source: hosted + version: "2.28.2" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: "68c138e884527d2bd61df2ade276c3a144df84d1adeb0ab8f3196b5afe021bd4" + url: "https://pub.dev" + source: hosted + version: "2.28.0" + email_validator: + dependency: transitive + description: + name: email_validator + sha256: b19aa5d92fdd76fbc65112060c94d45ba855105a28bb6e462de7ff03b12fa1fb + url: "https://pub.dev" + source: hosted + version: "3.0.0" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + expressions: + dependency: transitive + description: + name: expressions + sha256: f3b0e99563a9a1bde1138e728eb722f292cc7d2aec55d28136c49b1a370306c5 + url: "https://pub.dev" + source: hosted + version: "0.2.5+3" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5" + url: "https://pub.dev" + source: hosted + version: "4.5.2" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.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" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c" + url: "https://pub.dev" + source: hosted + version: "2.5.8" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + gap: + dependency: transitive + description: + name: gap + sha256: f19387d4e32f849394758b91377f9153a1b41d79513ef7668c088c77dbc6955d + url: "https://pub.dev" + source: hosted + version: "3.0.1" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + jovial_misc: + dependency: transitive + description: + name: jovial_misc + sha256: "065b5240badae6b13472efdea28fffe8baf914a7831361469a95c6456d9b8dc8" + url: "https://pub.dev" + source: hosted + version: "0.10.0" + jovial_svg: + dependency: transitive + description: + name: jovial_svg + sha256: "99e9c3afcf7371ae38083ad52de23677d6d751f46150c3c6ae842e009e84d9f0" + url: "https://pub.dev" + source: hosted + version: "1.1.29" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c + url: "https://pub.dev" + source: hosted + version: "6.9.5" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + onesignal_flutter: + dependency: "direct main" + description: + name: onesignal_flutter + sha256: "74521a8bfbe9253a5fdfdd52981e8bb4f6da2fbbb92792d4ff368917ac395716" + url: "https://pub.dev" + source: hosted + version: "5.5.8" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + phonecodes: + dependency: transitive + description: + name: phonecodes + sha256: d963c19d35914cd83620e64125689a0c09047e25046639f2a124142ccf5868bb + url: "https://pub.dev" + source: hosted + version: "0.0.4" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pocketbase: + dependency: "direct main" + description: + name: pocketbase + sha256: f70353ea9c583fabe4165880e054504c5036c428f59c0fc588d64bb19218df05 + url: "https://pub.dev" + source: hosted + version: "0.22.0" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "837a6dc33f490706c7f4632c516bcd10804ee4d9ccc8046124ca56388715fdf3" + url: "https://pub.dev" + source: hosted + version: "0.5.9" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "120d3310f687f43e7011bb213b90a436f1bbc300f0e4b251a72c39bccb017a4f" + url: "https://pub.dev" + source: hosted + version: "2.6.4" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shadcn_flutter: + dependency: "direct main" + description: + name: shadcn_flutter + sha256: "1fd4f798c39d6308dc8f7e94d9e870b5db39fbf417ea95c423c7555ce8227a1c" + url: "https://pub.dev" + source: hosted + version: "0.0.47" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + url: "https://pub.dev" + source: hosted + version: "10.1.4" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + url: "https://pub.dev" + source: hosted + version: "5.0.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + skeletonizer: + dependency: transitive + description: + name: skeletonizer + sha256: "9f38f9b47ec3cf2235a6a4f154a88a95432bc55ba98b3e2eb6ced5c1974bc122" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca + url: "https://pub.dev" + source: hosted + version: "1.3.7" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" + url: "https://pub.dev" + source: hosted + version: "2.4.2+1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465" + url: "https://pub.dev" + source: hosted + version: "2.5.8" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" + url: "https://pub.dev" + source: hosted + version: "2.9.4" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: "57090342af1ce32bb499aa641f4ecdd2d6231b9403cea537ac059e803cc20d67" + url: "https://pub.dev" + source: hosted + version: "0.41.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + 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: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "7ee12e6dffe0fc8e755179d6d91b3b34f5924223fc104d85572ef9180d73d172" + url: "https://pub.dev" + source: hosted + version: "1.2.5" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..9130310 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,60 @@ +name: dls_app +description: DLS — Dental Lab System (Flutter + PocketBase) +publish_to: none +version: 1.0.0+1 + +environment: + sdk: ">=3.3.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + + # Backend + pocketbase: ^0.22.0 + + # State management + flutter_riverpod: ^2.5.1 + riverpod_annotation: ^2.3.5 + + # Navigation + go_router: ^14.2.7 + + + # File handling + file_picker: ^8.1.2 + path_provider: ^2.1.3 + http: ^1.2.0 + shared_preferences: ^2.3.2 + share_plus: ^10.1.4 + onesignal_flutter: ^5.2.5 + + # UI + flutter_svg: ^2.0.10+1 + cached_network_image: ^3.3.1 + shimmer: ^3.0.0 + intl: ^0.20.2 + google_fonts: ^6.2.1 + flutter_animate: ^4.5.0 + + # Utilities + freezed_annotation: ^2.4.1 + json_annotation: ^4.9.0 + equatable: ^2.0.5 + uuid: ^4.4.0 + shadcn_flutter: ^0.0.47 + +dev_dependencies: + flutter_test: + sdk: flutter + build_runner: ^2.4.11 + freezed: ^2.5.2 + json_serializable: ^6.8.0 + riverpod_generator: ^2.4.0 + drift_dev: ^2.18.0 + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..af23248 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,7 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('placeholder', (tester) async { + expect(true, isTrue); + }); +} 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..b491443 --- /dev/null +++ b/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + lab_app + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..d3f9c18 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "lab_app", + "short_name": "lab_app", + "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..a8996aa --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(lab_app 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 "lab_app") + +# 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..c3384ec --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,17 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} 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..4b61cfd --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + share_plus + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +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..2dae856 --- /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.kovaksoft" "\0" + VALUE "FileDescription", "lab_app" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "lab_app" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.kovaksoft. All rights reserved." "\0" + VALUE "OriginalFilename", "lab_app.exe" "\0" + VALUE "ProductName", "lab_app" "\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..b101a1d --- /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"lab_app", 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_