diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml new file mode 100644 index 00000000000..c2635a4f5c1 --- /dev/null +++ b/.github/workflows/mobile.yml @@ -0,0 +1,136 @@ +name: Mobile App (Flutter) + +on: + push: + branches: [feat/flutter-mobile-app] + paths: ['mobile/**'] + pull_request: + branches: [develop] + paths: ['mobile/**'] + workflow_dispatch: + inputs: + upload_testflight: + description: 'Upload to TestFlight' + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test & Analyze + runs-on: ubuntu-latest + defaults: + run: + working-directory: mobile + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + - run: flutter pub get + - run: flutter analyze --no-fatal-infos + - run: flutter test + + build-ios: + name: Build iOS + needs: test + runs-on: macos-latest + if: | + github.event_name == 'workflow_dispatch' || + startsWith(github.ref, 'refs/tags/mobile-v') + defaults: + run: + working-directory: mobile + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + + - run: flutter pub get + - run: cd ios && pod install + + # Uses same secret names as immich-fork for reuse. + - name: Create API Key + env: + API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }} + run: | + mkdir -p ~/.appstoreconnect/private_keys + echo "$API_KEY_CONTENT" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8 + + - name: Import Certificate + env: + IOS_CERTIFICATE_P12: ${{ secrets.IOS_DISTRIBUTION_CERT_P12 }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DISTRIBUTION_CERT_PASSWORD }} + run: | + echo "$IOS_CERTIFICATE_P12" | base64 --decode > /tmp/certificate.p12 + + security create-keychain -p "$IOS_CERTIFICATE_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$IOS_CERTIFICATE_PASSWORD" build.keychain + security set-keychain-settings -t 3600 -u build.keychain + + security import /tmp/certificate.p12 -k build.keychain \ + -P "$IOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple: \ + -s -k "$IOS_CERTIFICATE_PASSWORD" build.keychain + + security find-identity -v -p codesigning build.keychain + + - name: Build IPA + run: flutter build ipa --release --export-options-plist=ios/ExportOptions.plist + + - name: Upload IPA artifact + uses: actions/upload-artifact@v4 + with: + name: huly-mobile-ios + path: mobile/build/ios/ipa/*.ipa + + - name: Upload to TestFlight + if: | + startsWith(github.ref, 'refs/tags/mobile-v') || + github.event.inputs.upload_testflight == 'true' + env: + API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} + run: | + xcrun altool --upload-app \ + --type ios \ + --file build/ios/ipa/*.ipa \ + --apiKey "$API_KEY_ID" \ + --apiIssuer "$API_KEY_ISSUER_ID" + + - name: Cleanup keychain + if: always() + run: security delete-keychain build.keychain 2>/dev/null || true + + build-android: + name: Build Android + needs: test + runs-on: ubuntu-latest + if: | + github.event_name == 'workflow_dispatch' || + startsWith(github.ref, 'refs/tags/mobile-v') + defaults: + run: + working-directory: mobile + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + - uses: subosito/flutter-action@v2 + with: + channel: stable + - run: flutter pub get + - run: flutter build appbundle --release + - name: Upload AAB + uses: actions/upload-artifact@v4 + with: + name: huly-mobile-android + path: mobile/build/app/outputs/bundle/release/*.aab diff --git a/.gitignore b/.gitignore index b68af9de6bb..db5fc3f098a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .heft/ lib/ +!mobile/lib/ _api-extractor-temp/ temp/ .idea diff --git a/.ideas/mobile-app-plan.md b/.ideas/mobile-app-plan.md new file mode 100644 index 00000000000..75cec275cf2 --- /dev/null +++ b/.ideas/mobile-app-plan.md @@ -0,0 +1,166 @@ +# Huly Mobile App (Flutter) — Status & Continuation Plan + +**Branch:** `feat/flutter-mobile-app` +**Fork PR:** https://github.com/ledoent/platform/pull/2 +**Repo location:** `mobile/` directory in huly-platform monorepo +**Target:** Self-hosted Huly deployment (ledoweb.com) + +--- + +## What's Done (11 commits on branch) + +### Phase 1 — Auth + Issue Tracking +- [x] OTP, password, OAuth (Google/GitHub/OIDC) via `huly://` custom scheme +- [x] 2FA/TOTP verification (verify2fa RPC, TfaScreen, tfaPending state) +- [x] Issue list/detail/create/edit (TxUpdateDoc) +- [x] Status management (fetch, display, edit via dropdown) +- [x] Client-side search/filter on issue list +- [x] Auto-refresh (45s timer, lifecycle-aware) +- [x] Server-side `mobileRedirect` in authProviders + +### Phase 2 — Polish + Navigation +- [x] Member/assignee name resolution (contact:class:Person) +- [x] Activity feed + comment posting on issue detail +- [x] File attachments (image_picker → blob upload → attachment doc) +- [x] Bottom nav bar (Issues / Chat / Settings) +- [x] Settings screen (server info, workspace, biometric toggle, sign out) + +### Phase 3 — Chunter Chat +- [x] Channel + DirectMessage list with DM name resolution +- [x] Message thread with send, shared MessageBubble widget +- [x] 10s polling (lifecycle-aware) + +### Phase 4 — Real-Time +- [x] WebSocket client (Huly protocol: hello handshake, ping/pong, Tx streaming) +- [x] dataVersionProvider — auto-refreshes providers on Tx events +- [x] Polling kept as fallback +- [x] FCM push notifications (register token as PushSubscription, foreground display) + +### Phase 5 — Advanced +- [x] Biometric auth (Face ID / fingerprint, lock screen, settings toggle) + +### Infrastructure +- [x] GitHub Actions CI (`.github/workflows/mobile.yml`) + - Test on push/PR, build iOS+Android on tags/manual + - Uses same secrets as `dnplkndll/immich` repo +- [x] App Store Connect: bundle ID `com.ledoweb.hulyMobile` registered +- [x] Capabilities enabled: Push Notifications, App Groups, Associated Domains + +### Code Quality +- [x] Shared widgets extracted (MessageBubble, PriorityChip) +- [x] Shared utilities (escapeHtml, stripHtml, formatTimestamp) +- [x] No duplication across screens +- [x] `flutter build ios --no-codesign` passes +- [x] `flutter test` — 16/16 tests pass + +--- + +## Blocked / In Progress + +### TestFlight Upload (blocked) +- IPA built locally (21.8MB) at `mobile/build/ios/ipa/` +- Upload failed: app `com.ledoweb.hulyMobile` needs to be **created in App Store Connect** +- Bundle ID is registered, capabilities enabled +- **TODO:** Go to https://appstoreconnect.apple.com/apps → New App → iOS + - Name: `Huly Mobile` + - Bundle ID: `com.ledoweb.hulyMobile` (select from dropdown) + - SKU: `com.ledoweb.hulyMobile` + - Primary Language: English (U.S.) +- Then upload: `xcrun altool --upload-app --type ios --file mobile/build/ios/ipa/huly_mobile.ipa --apiKey H37UAAFAVA --apiIssuer 7f122bfe-f415-4590-9229-35a8e80a1826` + +### CI Secrets (done) +All 5 secrets set on `ledoent/platform`: +- `APP_STORE_CONNECT_API_KEY_ID` — H37UAAFAVA +- `APP_STORE_CONNECT_API_KEY_ISSUER_ID` — 7f122bfe-f415-4590-9229-35a8e80a1826 +- `APP_STORE_CONNECT_API_KEY_P8` — from ~/.appstoreconnect/private_keys/ +- `IOS_DISTRIBUTION_CERT_P12` — from ~/.apple-certs/immich_distribution.p12 +- `IOS_DISTRIBUTION_CERT_PASSWORD` — empty string + +--- + +## TODO — Remaining Work + +### Immediate (unblock TestFlight) +- [ ] Create app in App Store Connect (manual — API key lacks CREATE permission) +- [ ] Upload IPA to TestFlight (command above, or `git tag mobile-v0.3.0` to trigger CI) +- [ ] Add custom Huly app icon (currently Flutter default placeholder) +- [ ] Add custom launch screen / splash (currently default) + +### Android (untested) +- [ ] Test Android build on device or emulator +- [ ] Chrome Custom Tabs for OAuth (vs ASWebAuthenticationSession) +- [ ] Verify Android share intent works +- [ ] Verify Android Keystore via flutter_secure_storage +- [ ] Add google-services.json for FCM on Android + +### Firebase Setup (for push notifications) +- [ ] Create Firebase project for Huly Mobile +- [ ] Add GoogleService-Info.plist to `mobile/ios/Runner/` +- [ ] Add google-services.json to `mobile/android/app/` +- [ ] Test push notification delivery end-to-end +- [ ] Verify FCM token registration as PushSubscription in workspace + +### WebSocket Hardening +- [ ] Test WebSocket against live Huly instance +- [ ] Verify Tx events are received and providers refresh +- [ ] Test reconnection after network loss +- [ ] Add connection status indicator in UI (connected/disconnected) +- [ ] Consider adding snappy compression for bandwidth (optional) + +### Chat Improvements +- [ ] Thread replies (tap message → reply in thread) +- [ ] Unread indicators on channel list (DocNotifyContext queries) +- [ ] Message reactions +- [ ] Rich markup rendering (`flutter_widget_from_html` for TipTap HTML) +- [ ] Typing indicators + +### Issue Improvements +- [ ] Issue deletion +- [ ] Assignee picker (select from workspace members) +- [ ] Sub-tasks / related issues +- [ ] Due dates +- [ ] Labels/tags + +### UX Polish +- [ ] Workspace switcher (currently requires logout/re-login) +- [ ] Notification badge on bottom nav for unread chat +- [ ] Dark/light theme toggle (currently dark only) +- [ ] Error states and empty states with illustrations +- [ ] Loading skeletons instead of spinners + +### Future Modules +- [ ] Documents (read-only markdown rendering) +- [ ] Time tracking (log entries against issues) +- [ ] HR module (leave requests, PTO) +- [ ] Kanban board view for issues + +--- + +## Key Files Reference + +| File | Purpose | +|------|---------| +| `mobile/lib/app.dart` | Router, bottom nav shell, realtime init | +| `mobile/lib/core/api/rest_client.dart` | REST API client (find-all, tx, blob) | +| `mobile/lib/core/api/websocket_client.dart` | WebSocket protocol implementation | +| `mobile/lib/core/api/realtime_provider.dart` | WebSocket + push + dataVersion providers | +| `mobile/lib/features/auth/auth_provider.dart` | Auth state machine, all login methods | +| `mobile/lib/features/issues/issue_provider.dart` | Issue, status, member, attachment providers | +| `mobile/lib/features/chat/chat_provider.dart` | Channel + message providers | +| `mobile/lib/services/push_notification_service.dart` | FCM init, foreground display, subscription | +| `mobile/pubspec.yaml` | Dependencies | +| `.github/workflows/mobile.yml` | CI: test, build iOS/Android, upload TestFlight | +| `.ideas/mobile-app-plan.md` | This file | + +## Apple Developer Details + +| Item | Value | +|------|-------| +| Bundle ID | `com.ledoweb.hulyMobile` | +| Team ID | `6ZJTLNKLQR` | +| ASC Key ID | `H37UAAFAVA` | +| ASC Issuer ID | `7f122bfe-f415-4590-9229-35a8e80a1826` | +| API Key | `~/.appstoreconnect/private_keys/AuthKey_H37UAAFAVA.p8` | +| Dist Cert | `~/.apple-certs/immich_distribution.p12` | +| ASC Bundle Internal ID | `9F22F9BGXB` | +| Capabilities | Push Notifications, App Groups, Associated Domains, In-App Purchase | diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 00000000000..3820a95c65c --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-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 diff --git a/mobile/.metadata b/mobile/.metadata new file mode 100644 index 00000000000..e4b4e8bf9de --- /dev/null +++ b/mobile/.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: "2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + - platform: android + create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + - platform: ios + create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + - platform: linux + create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + - platform: macos + create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + - platform: web + create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + - platform: windows + create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + + # 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/mobile/README.md b/mobile/README.md new file mode 100644 index 00000000000..07075e81821 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,53 @@ +# Huly Mobile + +Flutter mobile client for Huly Platform. + +## Features + +- **Authentication**: Full flow including Server URL setup, Email/Password, OTP, 2FA, and OAuth (Google). +- **Biometric Security**: Optional biometric lock for app access. +- **Issues**: Browse projects, filter and search issues, create and edit issues with attachments. +- **Chat**: Real-time messaging in channels and direct messages via WebSockets. +- **Push Notifications**: Firebase-powered notifications for mentions and messages. + +## Tech Stack + +- **Framework**: Flutter +- **State Management**: [Riverpod](https://riverpod.dev) +- **Navigation**: [GoRouter](https://pub.dev/packages/go_router) +- **API**: [Dio](https://pub.dev/packages/dio) for REST, `web_socket_channel` for Real-time. +- **Persistence**: `flutter_secure_storage` for credentials and tokens. + +## Getting Started + +1. **Install Dependencies**: + ```bash + flutter pub get + ``` +2. **Generate Models**: + ```bash + flutter pub run build_runner build --delete-conflicting-outputs + ``` +3. **Run the App**: + ```bash + flutter run + ``` + +## Testing + +The project uses `flutter_test` and `mocktail` for testing. + +- **Unit Tests**: Test providers and API clients. +- **Widget Tests**: Test screen behavior and UI components. + +Run all tests: +```bash +flutter test +``` + +## Architecture + +The project follows a feature-based folder structure under `lib/features/`. +- `core/`: Shared models, API clients, theme, and widgets. +- `features/`: Isolated feature modules (auth, chat, issues, etc.). +- `services/`: Global application services (Push, Share Handler). diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml new file mode 100644 index 00000000000..5f11304e1eb --- /dev/null +++ b/mobile/analysis_options.yaml @@ -0,0 +1,32 @@ +# 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 + +analyzer: + errors: + invalid_annotation_target: ignore + +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: + unnecessary_underscores: false + use_null_aware_elements: false + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore new file mode 100644 index 00000000000..be3943c96d8 --- /dev/null +++ b/mobile/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/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts new file mode 100644 index 00000000000..32b42d0387d --- /dev/null +++ b/mobile/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.ledoweb.huly_mobile" + 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.ledoweb.huly_mobile" + // 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/mobile/android/app/src/debug/AndroidManifest.xml b/mobile/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000000..399f6981d5d --- /dev/null +++ b/mobile/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..05890156062 --- /dev/null +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/kotlin/com/ledoweb/huly_mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/ledoweb/huly_mobile/MainActivity.kt new file mode 100644 index 00000000000..65d59aada5b --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/ledoweb/huly_mobile/MainActivity.kt @@ -0,0 +1,5 @@ +package com.ledoweb.huly_mobile + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000000..f74085f3f6a --- /dev/null +++ b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/launch_background.xml b/mobile/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000000..304732f8842 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000000..db77bb4b7b0 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000000..17987b79bb8 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000000..09d4391482b Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000000..d5f1c8d34e7 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000000..4d6372eebdb Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/values-night/styles.xml b/mobile/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000000..06952be745f --- /dev/null +++ b/mobile/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000000..cb1ef88056e --- /dev/null +++ b/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/mobile/android/app/src/profile/AndroidManifest.xml b/mobile/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000000..399f6981d5d --- /dev/null +++ b/mobile/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile/android/build.gradle.kts b/mobile/android/build.gradle.kts new file mode 100644 index 00000000000..dbee657bb5b --- /dev/null +++ b/mobile/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/mobile/android/gradle.properties b/mobile/android/gradle.properties new file mode 100644 index 00000000000..fbee1d8cdaf --- /dev/null +++ b/mobile/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/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000000..e4ef43fb98d --- /dev/null +++ b/mobile/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/mobile/android/settings.gradle.kts b/mobile/android/settings.gradle.kts new file mode 100644 index 00000000000..ca7fe065c16 --- /dev/null +++ b/mobile/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/mobile/assets/.gitkeep b/mobile/assets/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore new file mode 100644 index 00000000000..7a7f9873ad7 --- /dev/null +++ b/mobile/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/mobile/ios/ExportOptions.plist b/mobile/ios/ExportOptions.plist new file mode 100644 index 00000000000..337e80f5466 --- /dev/null +++ b/mobile/ios/ExportOptions.plist @@ -0,0 +1,12 @@ + + + + + method + app-store-connect + teamID + 6ZJTLNKLQR + signingStyle + automatic + + diff --git a/mobile/ios/Flutter/AppFrameworkInfo.plist b/mobile/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000000..391a902b2be --- /dev/null +++ b/mobile/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000000..ec97fc6f302 --- /dev/null +++ b/mobile/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/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000000..c4855bfe200 --- /dev/null +++ b/mobile/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/mobile/ios/Podfile b/mobile/ios/Podfile new file mode 100644 index 00000000000..620e46eba60 --- /dev/null +++ b/mobile/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock new file mode 100644 index 00000000000..27eb883bec2 --- /dev/null +++ b/mobile/ios/Podfile.lock @@ -0,0 +1,153 @@ +PODS: + - Firebase/CoreOnly (11.15.0): + - FirebaseCore (~> 11.15.0) + - Firebase/Messaging (11.15.0): + - Firebase/CoreOnly + - FirebaseMessaging (~> 11.15.0) + - firebase_core (3.15.2): + - Firebase/CoreOnly (= 11.15.0) + - Flutter + - firebase_messaging (15.2.10): + - Firebase/Messaging (= 11.15.0) + - firebase_core + - Flutter + - FirebaseCore (11.15.0): + - FirebaseCoreInternal (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreInternal (11.15.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (11.15.0): + - FirebaseCore (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - PromisesObjC (~> 2.4) + - FirebaseMessaging (11.15.0): + - FirebaseCore (~> 11.15.0) + - FirebaseInstallations (~> 11.0) + - GoogleDataTransport (~> 10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Reachability (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - nanopb (~> 3.30910.0) + - Flutter (1.0.0) + - flutter_local_notifications (0.0.1): + - Flutter + - flutter_secure_storage (6.0.0): + - Flutter + - flutter_web_auth_2 (3.0.0): + - Flutter + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleUtilities/AppDelegateSwizzler (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Network (8.1.0): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (8.1.0)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - image_picker_ios (0.0.1): + - Flutter + - local_auth_darwin (0.0.1): + - Flutter + - FlutterMacOS + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) + - PromisesObjC (2.4.0) + - receive_sharing_intent (1.8.1): + - Flutter + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - firebase_core (from `.symlinks/plugins/firebase_core/ios`) + - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) + - Flutter (from `Flutter`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) + - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +SPEC REPOS: + trunk: + - Firebase + - FirebaseCore + - FirebaseCoreInternal + - FirebaseInstallations + - FirebaseMessaging + - GoogleDataTransport + - GoogleUtilities + - nanopb + - PromisesObjC + +EXTERNAL SOURCES: + firebase_core: + :path: ".symlinks/plugins/firebase_core/ios" + firebase_messaging: + :path: ".symlinks/plugins/firebase_messaging/ios" + Flutter: + :path: Flutter + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" + flutter_secure_storage: + :path: ".symlinks/plugins/flutter_secure_storage/ios" + flutter_web_auth_2: + :path: ".symlinks/plugins/flutter_web_auth_2/ios" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" + local_auth_darwin: + :path: ".symlinks/plugins/local_auth_darwin/darwin" + receive_sharing_intent: + :path: ".symlinks/plugins/receive_sharing_intent/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e + firebase_core: 995454a784ff288be5689b796deb9e9fa3601818 + firebase_messaging: f4a41dd102ac18b840eba3f39d67e77922d3f707 + FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e + FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4 + FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843 + FirebaseMessaging: 3b26e2cee503815e01c3701236b020aa9b576f09 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_local_notifications: 395056b3175ba4f08480a7c5de30cd36d69827e4 + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 + local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.16.2 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..da9a06a41f9 --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,753 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 02EBF737BD9C07C5C58BA836 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CF52B0027AA726F26D21C4F1 /* Pods_Runner.framework */; }; + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 904ECCE8EFBB4A20358F7044 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2828119BC1A739D5FD85D90 /* Pods_RunnerTests.framework */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 16932E4CE02F51CB1B63A4CC /* 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 = ""; }; + 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 = ""; }; + 4244867BA7BA79A78B01556A /* 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 = ""; }; + 4BAEF3BB85678CA724713894 /* 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 = ""; }; + 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 = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C4060AE74F6E64C448804E30 /* 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 = ""; }; + C576CD172C0B9E1FAEE79D85 /* 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 = ""; }; + C851D6BF1E9718C0E65BA186 /* 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 = ""; }; + CF52B0027AA726F26D21C4F1 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + E2828119BC1A739D5FD85D90 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 38922CAFE86D830AD65A4D0C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 904ECCE8EFBB4A20358F7044 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 02EBF737BD9C07C5C58BA836 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 3EC5A08E9832251486928AAA /* Pods */ = { + isa = PBXGroup; + children = ( + C4060AE74F6E64C448804E30 /* Pods-Runner.debug.xcconfig */, + C851D6BF1E9718C0E65BA186 /* Pods-Runner.release.xcconfig */, + 16932E4CE02F51CB1B63A4CC /* Pods-Runner.profile.xcconfig */, + 4BAEF3BB85678CA724713894 /* Pods-RunnerTests.debug.xcconfig */, + C576CD172C0B9E1FAEE79D85 /* Pods-RunnerTests.release.xcconfig */, + 4244867BA7BA79A78B01556A /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + 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 */, + 3EC5A08E9832251486928AAA /* Pods */, + D1235411F8F59737ECC135B3 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + D1235411F8F59737ECC135B3 /* Frameworks */ = { + isa = PBXGroup; + children = ( + CF52B0027AA726F26D21C4F1 /* Pods_Runner.framework */, + E2828119BC1A739D5FD85D90 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 581B2BE45F488A0629C4755D /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 38922CAFE86D830AD65A4D0C /* 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 = ( + 0E8F992EDA1181CD3F00A0BD /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 5CC9519BF948157B1836CDD3 /* [CP] Embed Pods Frameworks */, + 131BBF5164922CF7E53EBB6D /* [CP] Copy Pods Resources */, + ); + 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 */ + 0E8F992EDA1181CD3F00A0BD /* [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; + }; + 131BBF5164922CF7E53EBB6D /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 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"; + }; + 581B2BE45F488A0629C4755D /* [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; + }; + 5CC9519BF948157B1836CDD3 /* [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 */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 6ZJTLNKLQR; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.ledoweb.hulyMobile; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4BAEF3BB85678CA724713894 /* 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.ledoweb.hulyMobile.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 = C576CD172C0B9E1FAEE79D85 /* 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.ledoweb.hulyMobile.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 = 4244867BA7BA79A78B01556A /* 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.ledoweb.hulyMobile.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 6ZJTLNKLQR; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.ledoweb.hulyMobile; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 6ZJTLNKLQR; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.ledoweb.hulyMobile; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000000..919434a6254 --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000000..18d981003d6 --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000000..f9b0d7c5ea1 --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000000..e3773d42e24 --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata b/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000000..21a3cc14c74 --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000000..18d981003d6 --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000000..f9b0d7c5ea1 --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000000..b011acb55fc --- /dev/null +++ b/mobile/ios/Runner/AppDelegate.swift @@ -0,0 +1,55 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + + private let appGroupId = "group.com.ledoweb.huly" + + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + let result = super.application(application, didFinishLaunchingWithOptions: launchOptions) + setupShareChannel() + return result + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } + + private func setupShareChannel() { + guard let controller = window?.rootViewController as? FlutterViewController else { + return + } + + let channel = FlutterMethodChannel( + name: "com.ledoweb.huly/share", + binaryMessenger: controller.binaryMessenger + ) + + channel.setMethodCallHandler { [weak self] (call, result) in + guard let self = self else { + result(FlutterMethodNotImplemented) + return + } + + switch call.method { + case "getPendingShare": + let userDefaults = UserDefaults(suiteName: self.appGroupId) + let data = userDefaults?.string(forKey: "pendingShare") + result(data) + + case "clearPendingShare": + let userDefaults = UserDefaults(suiteName: self.appGroupId) + userDefaults?.removeObject(forKey: "pendingShare") + userDefaults?.synchronize() + result(nil) + + default: + result(FlutterMethodNotImplemented) + } + } + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000000..d36b1fab2d9 --- /dev/null +++ b/mobile/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/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000000..dc9ada4725e Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000000..7353c41ecf9 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000000..797d452e458 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000000..6ed2d933e11 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000000..4cd7b0099ca Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000000..fe730945a01 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000000..321773cd857 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000000..797d452e458 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000000..502f463a9bc Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000000..0ec30343922 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000000..0ec30343922 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000000..e9f5fea27c7 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000000..84ac32ae7d9 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000000..8953cba0906 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000000..0467bf12aa4 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000000..0bedcf2fd46 --- /dev/null +++ b/mobile/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/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 00000000000..9da19eacad3 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 00000000000..9da19eacad3 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 00000000000..9da19eacad3 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000000..89c2725b70f --- /dev/null +++ b/mobile/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/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard b/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000000..f2e259c7c93 --- /dev/null +++ b/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/Runner/Base.lproj/Main.storyboard b/mobile/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000000..f3c28516fb3 --- /dev/null +++ b/mobile/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist new file mode 100644 index 00000000000..fc7eacc184e --- /dev/null +++ b/mobile/ios/Runner/Info.plist @@ -0,0 +1,83 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Huly Mobile + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + huly_mobile + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.ledoweb.huly + CFBundleURLSchemes + + huly + + + + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/mobile/ios/Runner/Runner-Bridging-Header.h b/mobile/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000000..308a2a560b4 --- /dev/null +++ b/mobile/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/mobile/ios/Runner/SceneDelegate.swift b/mobile/ios/Runner/SceneDelegate.swift new file mode 100644 index 00000000000..b9ce8ea2b2a --- /dev/null +++ b/mobile/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000000..86a7c3b1b61 --- /dev/null +++ b/mobile/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/mobile/ios/ShareExtension/Info.plist b/mobile/ios/ShareExtension/Info.plist new file mode 100644 index 00000000000..2986a2d69fb --- /dev/null +++ b/mobile/ios/ShareExtension/Info.plist @@ -0,0 +1,41 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Huly + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + ShareExtension.ShareViewController + + + diff --git a/mobile/ios/ShareExtension/ShareViewController.swift b/mobile/ios/ShareExtension/ShareViewController.swift new file mode 100644 index 00000000000..67685c7a16b --- /dev/null +++ b/mobile/ios/ShareExtension/ShareViewController.swift @@ -0,0 +1,106 @@ +import UIKit +import Social +import MobileCoreServices +import UniformTypeIdentifiers + +class ShareViewController: SLComposeServiceViewController { + + private let appGroupId = "group.com.ledoweb.huly" + + override func isContentValid() -> Bool { + return true + } + + override func didSelectPost() { + guard let extensionItems = extensionContext?.inputItems as? [NSExtensionItem] else { + extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) + return + } + + var sharedText: String? + var sharedURL: String? + let group = DispatchGroup() + + for item in extensionItems { + guard let attachments = item.attachments else { continue } + + for attachment in attachments { + if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) { + group.enter() + attachment.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { data, _ in + if let url = data as? URL { + sharedURL = url.absoluteString + } + group.leave() + } + } + + if attachment.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { + group.enter() + attachment.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { data, _ in + if let text = data as? String { + sharedText = text + } + group.leave() + } + } + } + } + + group.notify(queue: .main) { [weak self] in + self?.saveAndOpenApp(text: sharedText, url: sharedURL) + } + } + + private func saveAndOpenApp(text: String?, url: String?) { + guard let userDefaults = UserDefaults(suiteName: appGroupId) else { + extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) + return + } + + let shareData: [String: Any?] = [ + "text": text, + "url": url, + "timestamp": Date().timeIntervalSince1970, + ] + + if let jsonData = try? JSONSerialization.data(withJSONObject: shareData.compactMapValues { $0 }), + let jsonString = String(data: jsonData, encoding: .utf8) { + userDefaults.set(jsonString, forKey: "pendingShare") + userDefaults.synchronize() + } + + // Open the main app via URL scheme. + if let appURL = URL(string: "huly://share") { + openURL(appURL) + } + + extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) + } + + // Open URL from extension context (uses responder chain). + @objc private func openURL(_ url: URL) { + var responder: UIResponder? = self + while responder != nil { + if let application = responder as? UIApplication { + application.open(url, options: [:], completionHandler: nil) + return + } + responder = responder?.next + } + // Fallback: use selector-based approach. + let selector = sel_registerName("openURL:") + var nextResponder: UIResponder? = self + while let r = nextResponder { + if r.responds(to: selector) { + r.perform(selector, with: url) + return + } + nextResponder = r.next + } + } + + override func configurationItems() -> [Any]! { + return [] + } +} diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart new file mode 100644 index 00000000000..51a82a1a969 --- /dev/null +++ b/mobile/lib/app.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'core/theme/huly_theme.dart'; +import 'features/auth/auth_provider.dart'; +import 'features/auth/lock_screen.dart'; +import 'features/auth/login_screen.dart'; +import 'features/auth/server_url_screen.dart'; +import 'features/auth/tfa_screen.dart'; +import 'features/auth/workspace_screen.dart'; +import 'core/api/realtime_provider.dart'; +import 'features/chat/channel_list_screen.dart'; +import 'features/chat/message_thread_screen.dart'; +import 'features/create_issue/create_issue_screen.dart'; +import 'features/issues/issue_detail_screen.dart'; +import 'features/issues/issue_list_screen.dart'; +import 'features/settings/settings_screen.dart'; + +final _routerProvider = Provider((ref) { + final auth = ref.watch(authProvider); + + return GoRouter( + initialLocation: '/', + redirect: (context, state) { + final path = state.uri.path; + + // No server URL set yet → server URL screen. + if (auth.serverUrl == null || auth.accountsUrl == null) { + return path == '/server' ? null : '/server'; + } + + // Not logged in → login screen. + if (auth.status == AuthStatus.unauthenticated) { + return path == '/login' ? null : '/login'; + } + + // OTP pending → stay on login (OTP entry shown there). + if (auth.status == AuthStatus.otpPending) { + return path == '/login' ? null : '/login'; + } + + // 2FA pending → TFA screen. + if (auth.status == AuthStatus.tfaPending) { + return path == '/tfa' ? null : '/tfa'; + } + + // Logged in but no workspace → workspace selection. + if (auth.status == AuthStatus.loggedIn) { + return path == '/workspace' ? null : '/workspace'; + } + + // Locked → biometric unlock screen. + if (auth.status == AuthStatus.locked) { + return path == '/locked' ? null : '/locked'; + } + + // Workspace selected — allow any route; redirect root to /issues. + if (path == '/server' || + path == '/login' || + path == '/tfa' || + path == '/workspace' || + path == '/locked') { + return '/issues'; + } + if (path == '/') return '/issues'; + + return null; + }, + routes: [ + GoRoute(path: '/server', builder: (_, __) => const ServerUrlScreen()), + GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), + GoRoute(path: '/tfa', builder: (_, __) => const TfaScreen()), + GoRoute(path: '/workspace', builder: (_, __) => const WorkspaceScreen()), + GoRoute(path: '/locked', builder: (_, __) => const LockScreen()), + ShellRoute( + builder: (context, state, child) => _MainShell(child: child), + routes: [ + GoRoute( + path: '/issues', builder: (_, __) => const IssueListScreen()), + GoRoute( + path: '/chat', builder: (_, __) => const ChannelListScreen()), + GoRoute( + path: '/settings', builder: (_, __) => const SettingsScreen()), + ], + ), + GoRoute( + path: '/chat/:id', + builder: (_, state) => MessageThreadScreen( + channelId: state.pathParameters['id']!, + channelName: state.uri.queryParameters['name'], + ), + ), + GoRoute( + path: '/issue/:id', + builder: (_, state) => + IssueDetailScreen(issueId: state.pathParameters['id']!), + ), + GoRoute( + path: '/create', + builder: (_, state) { + final extra = state.extra as Map?; + return CreateIssueScreen( + initialTitle: extra?['title'], + initialDescription: extra?['description'], + ); + }, + ), + ], + ); +}); + +class _MainShell extends ConsumerStatefulWidget { + final Widget child; + const _MainShell({required this.child}); + + @override + ConsumerState<_MainShell> createState() => _MainShellState(); +} + +class _MainShellState extends ConsumerState<_MainShell> { + bool _realtimeStarted = false; + + @override + Widget build(BuildContext context) { + // Start WebSocket listener once. + if (!_realtimeStarted) { + _realtimeStarted = true; + startRealtimeListener(ref); + } + + final location = GoRouterState.of(context).uri.path; + int index = 0; + if (location.startsWith('/chat')) { + index = 1; + } else if (location.startsWith('/settings')) { + index = 2; + } + + return Scaffold( + body: widget.child, + bottomNavigationBar: NavigationBar( + backgroundColor: HulyColors.header, + indicatorColor: HulyColors.primaryButton.withValues(alpha: 0.2), + selectedIndex: index, + onDestinationSelected: (i) { + switch (i) { + case 0: + context.go('/issues'); + case 1: + context.go('/chat'); + case 2: + context.go('/settings'); + } + }, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.task_outlined), + selectedIcon: Icon(Icons.task), + label: 'Issues', + ), + NavigationDestination( + icon: Icon(Icons.chat_bubble_outline), + selectedIcon: Icon(Icons.chat_bubble), + label: 'Chat', + ), + NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Settings', + ), + ], + ), + ); + } +} + +class HulyApp extends ConsumerWidget { + const HulyApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(_routerProvider); + + return MaterialApp.router( + title: 'Huly', + debugShowCheckedModeBanner: false, + theme: hulyDarkTheme, + routerConfig: router, + ); + } +} diff --git a/mobile/lib/core/api/account_client.dart b/mobile/lib/core/api/account_client.dart new file mode 100644 index 00000000000..6967db825a6 --- /dev/null +++ b/mobile/lib/core/api/account_client.dart @@ -0,0 +1,146 @@ +import 'package:dio/dio.dart'; +import '../models/login_info.dart'; + +/// JSON-RPC client for the Huly accounts endpoint. +class AccountClient { + final Dio _dio; + final String accountsUrl; + String? _token; + + AccountClient({required this.accountsUrl, Dio? dio}) + : _dio = dio ?? Dio(); + + void setToken(String token) { + _token = token; + } + + /// Low-level JSON-RPC call to the accounts endpoint. + Future _rpc(String method, List params) async { + final headers = { + 'Content-Type': 'application/json', + }; + if (_token != null) { + headers['Authorization'] = 'Bearer $_token'; + } + + final response = await _dio.post( + accountsUrl, + data: { + 'method': method, + 'params': params, + }, + options: Options(headers: headers), + ); + + final body = response.data; + if (body is Map && body.containsKey('error')) { + throw AccountRpcError( + code: body['error']['code'] ?? -1, + message: body['error']['message'] ?? 'Unknown error', + ); + } + return body['result']; + } + + /// Fetch the accounts URL from the instance's config.json. + static Future getAccountsUrl(String serverUrl, {Dio? dio}) async { + final client = dio ?? Dio(); + final url = serverUrl.endsWith('/') + ? '${serverUrl}config.json' + : '$serverUrl/config.json'; + final response = await client.get(url); + final config = response.data; + if (config is Map && config.containsKey('ACCOUNTS_URL')) { + return config['ACCOUNTS_URL'] as String; + } + throw Exception('ACCOUNTS_URL not found in config.json'); + } + + /// Login with email and password. + Future login(String email, String password) async { + final result = await _rpc('login', [email, password, false]); + final loginInfo = LoginInfo.fromJson(result as Map); + _token = loginInfo.token; + return loginInfo; + } + + /// Request an OTP code sent to the given email. + Future loginOtp(String email) async { + final result = await _rpc('loginOtp', [email]); + return OtpInfo.fromJson(result as Map); + } + + /// Validate an OTP code and get a login token. + Future validateOtp(String email, String code) async { + final result = await _rpc('validateOtp', [email, code]); + final loginInfo = LoginInfo.fromJson(result as Map); + _token = loginInfo.token; + return loginInfo; + } + + /// Verify a 2FA/TOTP code using the partial token from login. + /// Returns a full LoginInfo with a valid token. + Future verify2fa(String partialToken, String code) async { + final savedToken = _token; + _token = partialToken; + try { + final result = await _rpc('verify2fa', [ + partialToken, + {'code': code}, + ]); + final loginInfo = LoginInfo.fromJson(result as Map); + _token = loginInfo.token; + return loginInfo; + } catch (e) { + _token = savedToken; + rethrow; + } + } + + /// Get available OAuth providers (Google, GitHub, etc.) + /// This is a REST GET endpoint, not an RPC call. + Future> getProviders() async { + try { + final url = accountsUrl.endsWith('/') + ? '${accountsUrl}providers' + : '$accountsUrl/providers'; + final response = await _dio.get(url); + final data = response.data; + if (data is List) { + return data + .map((e) => ProviderInfo( + id: (e is Map ? e['name'] : e).toString(), + name: (e is Map ? e['name'] : e).toString(), + )) + .toList(); + } + } catch (_) { + // Instance may not have providers configured. + } + return []; + } + + /// Get workspaces for the logged-in user. + Future> getUserWorkspaces() async { + final result = await _rpc('getUserWorkspaces', []); + return (result as List) + .map((e) => WorkspaceInfo.fromJson(e as Map)) + .toList(); + } + + /// Select a workspace and get connection info. + Future selectWorkspace(String workspaceUrl) async { + final result = await _rpc('selectWorkspace', [workspaceUrl, 'external']); + return WorkspaceLoginInfo.fromJson(result as Map); + } +} + +class AccountRpcError implements Exception { + final int code; + final String message; + + AccountRpcError({required this.code, required this.message}); + + @override + String toString() => 'AccountRpcError($code): $message'; +} diff --git a/mobile/lib/core/api/realtime_provider.dart b/mobile/lib/core/api/realtime_provider.dart new file mode 100644 index 00000000000..a8ef1adf678 --- /dev/null +++ b/mobile/lib/core/api/realtime_provider.dart @@ -0,0 +1,67 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../features/auth/auth_provider.dart'; +import '../../services/push_notification_service.dart'; +import 'websocket_client.dart'; + +/// Provides a connected WebSocket client when a workspace is selected. +final wsClientProvider = Provider((ref) { + final auth = ref.watch(authProvider); + final ws = auth.workspaceLogin; + if (ws == null || auth.status != AuthStatus.workspaceSelected) return null; + + final client = HulyWebSocketClient( + endpoint: ws.endpoint, + token: ws.token, + ); + + client.connect().then((_) {}).catchError((_) {}); + ref.onDispose(() => client.close()); + + return client; +}); + +/// Increments whenever a Tx event is received via WebSocket. +/// Providers can watch this to auto-refresh when data changes. +final dataVersionProvider = StateProvider((ref) => 0); + +/// Push notification service singleton. +final pushServiceProvider = Provider((ref) { + return PushNotificationService(); +}); + +/// Starts listening for Tx events and bumps dataVersionProvider. +/// Call this once from your root widget. +void startRealtimeListener(dynamic ref) { + final HulyWebSocketClient? client; + final StateController notifier; + + if (ref is WidgetRef) { + client = ref.read(wsClientProvider); + notifier = ref.read(dataVersionProvider.notifier); + } else { + return; + } + + if (client == null) return; + + client.txStream.listen((_) { + notifier.state++; + }); + + // Register push notifications with workspace. + _initPush(ref); +} + +void _initPush(dynamic ref) async { + if (ref is! WidgetRef) return; + try { + final push = ref.read(pushServiceProvider); + await push.initialize(); + final restClient = ref.read(restClientProvider); + if (restClient != null) { + await push.registerWithWorkspace(restClient); + } + } catch (_) { + // Firebase not configured — push disabled. + } +} diff --git a/mobile/lib/core/api/rest_client.dart b/mobile/lib/core/api/rest_client.dart new file mode 100644 index 00000000000..3ccb9d644f6 --- /dev/null +++ b/mobile/lib/core/api/rest_client.dart @@ -0,0 +1,118 @@ +import 'dart:convert'; +import 'package:dio/dio.dart'; + +/// REST client for the Huly transactor API (/api/v1/*). +class HulyRestClient { + final Dio _dio; + final String baseUrl; + final String workspaceId; + final String token; + + HulyRestClient({ + required String endpoint, + required this.workspaceId, + required this.token, + Dio? dio, + }) : baseUrl = endpoint + .replaceFirst('ws://', 'http://') + .replaceFirst('wss://', 'https://'), + _dio = dio ?? Dio() { + _dio.options.headers['Authorization'] = 'Bearer $token'; + _dio.options.headers['Content-Type'] = 'application/json'; + } + + String _apiPath(String path) => '$baseUrl/api/v1/$path/$workspaceId'; + + /// Query documents by class with optional query and options. + Future>> findAll( + String className, { + Map? query, + Map? options, + }) async { + final params = { + 'class': className, + }; + if (query != null) { + params['query'] = jsonEncode(query); + } + if (options != null) { + params['options'] = jsonEncode(options); + } + + final response = await _dio.get( + _apiPath('find-all'), + queryParameters: params, + ); + + _checkRateLimit(response); + + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + /// Submit a transaction. + Future> tx(Map txBody) async { + final response = await _dio.post( + _apiPath('tx'), + data: txBody, + ); + _checkRateLimit(response); + return response.data is Map + ? response.data as Map + : {}; + } + + /// Upload a blob (file) and return the blob name/ID. + Future uploadBlob({ + required String name, + required String contentType, + required int size, + required List bytes, + }) async { + final response = await _dio.put( + '$baseUrl/api/v1/blob', + data: Stream.fromIterable([bytes]), + queryParameters: { + 'name': name, + 'contentType': contentType, + 'size': size.toString(), + }, + options: Options( + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': contentType, + 'Content-Length': size, + }, + ), + ); + _checkRateLimit(response); + } + + /// Get current account info. + Future> getAccount() async { + final response = await _dio.get(_apiPath('account')); + _checkRateLimit(response); + return response.data as Map; + } + + void _checkRateLimit(Response response) { + if (response.statusCode == 429) { + final retryAfter = response.headers.value('Retry-After'); + throw RateLimitException( + retryAfterSeconds: int.tryParse(retryAfter ?? '') ?? 60, + ); + } + } +} + +class RateLimitException implements Exception { + final int retryAfterSeconds; + RateLimitException({required this.retryAfterSeconds}); + + @override + String toString() => 'Rate limited. Retry after $retryAfterSeconds seconds.'; +} + diff --git a/mobile/lib/core/api/websocket_client.dart b/mobile/lib/core/api/websocket_client.dart new file mode 100644 index 00000000000..ae3fc9394e5 --- /dev/null +++ b/mobile/lib/core/api/websocket_client.dart @@ -0,0 +1,198 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// Real-time WebSocket client for the Huly transactor. +/// +/// Implements the Huly protocol: HelloRequest/HelloResponse handshake, +/// ping/pong keep-alive, and Tx event streaming. +class HulyWebSocketClient { + final String endpoint; + final String token; + final String? sessionId; + + WebSocketChannel? _channel; + Timer? _pingTimer; + int _requestId = 0; + final _pendingRequests = >{}; + final _txController = StreamController>.broadcast(); + bool _connected = false; + bool _disposed = false; + Timer? _reconnectTimer; + int _reconnectAttempts = 0; + + /// Stream of incoming Tx arrays (real-time document changes). + Stream> get txStream => _txController.stream; + + bool get isConnected => _connected; + + HulyWebSocketClient({ + required this.endpoint, + required this.token, + this.sessionId, + }); + + /// Connect to the WebSocket server and complete the handshake. + Future> connect() async { + final wsUrl = endpoint + .replaceFirst('http://', 'ws://') + .replaceFirst('https://', 'wss://'); + final uri = Uri.parse('$wsUrl/$token') + .replace(queryParameters: {if (sessionId != null) 'sessionId': sessionId!}); + + _channel = WebSocketChannel.connect(uri); + await _channel!.ready; + + // Send HelloRequest. + _send({ + 'id': -1, + 'method': 'hello', + 'params': [], + 'binary': false, + 'compression': false, + }); + + // Wait for HelloResponse. + final helloCompleter = Completer>(); + late StreamSubscription sub; + sub = _channel!.stream.listen((data) { + final msg = _decode(data); + if (msg is Map && msg['id'] == -1) { + sub.cancel(); + _connected = true; + _reconnectAttempts = 0; + _startListening(); + _startPingTimer(); + helloCompleter.complete(msg); + } + }, onError: (e) { + if (!helloCompleter.isCompleted) { + helloCompleter.completeError(e); + } + }); + + return helloCompleter.future; + } + + void _startListening() { + _channel!.stream.listen( + (data) => _handleMessage(_decode(data)), + onDone: () { + _connected = false; + _pingTimer?.cancel(); + _failPendingRequests('Connection closed'); + _scheduleReconnect(); + }, + onError: (e) { + _connected = false; + _pingTimer?.cancel(); + _failPendingRequests('Connection error: $e'); + _scheduleReconnect(); + }, + ); + } + + void _handleMessage(dynamic msg) { + if (msg is! Map) return; + + // Ping from server. + if (msg['result'] == 'ping' && !msg.containsKey('id')) { + _send({'method': 'ping', 'params': []}); + return; + } + + // Response to a request. + final id = msg['id']; + if (id != null && id is int && _pendingRequests.containsKey(id)) { + final completer = _pendingRequests.remove(id)!; + if (msg.containsKey('error')) { + completer.completeError( + Exception(msg['error']?['message'] ?? 'Unknown error')); + } else { + completer.complete(msg['result']); + } + return; + } + + // Unsolicited Tx broadcast (no id). + if (!msg.containsKey('id') && msg.containsKey('result')) { + final result = msg['result']; + if (result is List) { + _txController.add(result); + } else if (result is Map) { + _txController.add([result]); + } + } + } + + void _startPingTimer() { + _pingTimer?.cancel(); + _pingTimer = Timer.periodic(const Duration(seconds: 10), (_) { + if (_connected) { + _send({'method': 'ping', 'params': []}); + } + }); + } + + /// Send a request and wait for the response. + Future request(String method, List params) { + if (!_connected) { + return Future.error(StateError('WebSocket not connected')); + } + final id = ++_requestId; + final completer = Completer(); + _pendingRequests[id] = completer; + _send({ + 'id': id, + 'method': method, + 'params': params, + 'time': DateTime.now().millisecondsSinceEpoch, + }); + return completer.future; + } + + void _send(Map data) { + _channel?.sink.add(jsonEncode(data)); + } + + dynamic _decode(dynamic data) { + if (data is String) { + if (data == 'ping') { + return {'result': 'ping'}; + } + return jsonDecode(data); + } + return data; + } + + void _failPendingRequests(String reason) { + for (final completer in _pendingRequests.values) { + if (!completer.isCompleted) { + completer.completeError(Exception(reason)); + } + } + _pendingRequests.clear(); + } + + void _scheduleReconnect() { + if (_disposed) return; + _reconnectTimer?.cancel(); + final delay = Duration(seconds: (_reconnectAttempts++).clamp(0, 5)); + _reconnectTimer = Timer(delay, () { + if (!_disposed) { + connect().then((_) {}).catchError((_) {}); + } + }); + } + + /// Disconnect and clean up. + Future close() async { + _disposed = true; + _pingTimer?.cancel(); + _reconnectTimer?.cancel(); + _connected = false; + _failPendingRequests('Client closed'); + await _channel?.sink.close(); + await _txController.close(); + } +} diff --git a/mobile/lib/core/models/activity.dart b/mobile/lib/core/models/activity.dart new file mode 100644 index 00000000000..fd2ec92b63e --- /dev/null +++ b/mobile/lib/core/models/activity.dart @@ -0,0 +1,22 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'activity.freezed.dart'; +part 'activity.g.dart'; + +/// Represents a chunter:class:ChatMessage attached to a document. +@freezed +class ChatMessage with _$ChatMessage { + const factory ChatMessage({ + @JsonKey(name: '_id') required String id, + @JsonKey(name: '_class') required String className, + required String attachedTo, + required String message, + String? createdBy, + String? modifiedBy, + int? createdOn, + int? modifiedOn, + }) = _ChatMessage; + + factory ChatMessage.fromJson(Map json) => + _$ChatMessageFromJson(json); +} diff --git a/mobile/lib/core/models/activity.freezed.dart b/mobile/lib/core/models/activity.freezed.dart new file mode 100644 index 00000000000..d5f04d2769a --- /dev/null +++ b/mobile/lib/core/models/activity.freezed.dart @@ -0,0 +1,341 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'activity.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +ChatMessage _$ChatMessageFromJson(Map json) { + return _ChatMessage.fromJson(json); +} + +/// @nodoc +mixin _$ChatMessage { + @JsonKey(name: '_id') + String get id => throw _privateConstructorUsedError; + @JsonKey(name: '_class') + String get className => throw _privateConstructorUsedError; + String get attachedTo => throw _privateConstructorUsedError; + String get message => throw _privateConstructorUsedError; + String? get createdBy => throw _privateConstructorUsedError; + String? get modifiedBy => throw _privateConstructorUsedError; + int? get createdOn => throw _privateConstructorUsedError; + int? get modifiedOn => throw _privateConstructorUsedError; + + /// Serializes this ChatMessage to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of ChatMessage + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ChatMessageCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ChatMessageCopyWith<$Res> { + factory $ChatMessageCopyWith( + ChatMessage value, + $Res Function(ChatMessage) then, + ) = _$ChatMessageCopyWithImpl<$Res, ChatMessage>; + @useResult + $Res call({ + @JsonKey(name: '_id') String id, + @JsonKey(name: '_class') String className, + String attachedTo, + String message, + String? createdBy, + String? modifiedBy, + int? createdOn, + int? modifiedOn, + }); +} + +/// @nodoc +class _$ChatMessageCopyWithImpl<$Res, $Val extends ChatMessage> + implements $ChatMessageCopyWith<$Res> { + _$ChatMessageCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ChatMessage + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? className = null, + Object? attachedTo = null, + Object? message = null, + Object? createdBy = freezed, + Object? modifiedBy = freezed, + Object? createdOn = freezed, + Object? modifiedOn = freezed, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + className: null == className + ? _value.className + : className // ignore: cast_nullable_to_non_nullable + as String, + attachedTo: null == attachedTo + ? _value.attachedTo + : attachedTo // ignore: cast_nullable_to_non_nullable + as String, + message: null == message + ? _value.message + : message // ignore: cast_nullable_to_non_nullable + as String, + createdBy: freezed == createdBy + ? _value.createdBy + : createdBy // ignore: cast_nullable_to_non_nullable + as String?, + modifiedBy: freezed == modifiedBy + ? _value.modifiedBy + : modifiedBy // ignore: cast_nullable_to_non_nullable + as String?, + createdOn: freezed == createdOn + ? _value.createdOn + : createdOn // ignore: cast_nullable_to_non_nullable + as int?, + modifiedOn: freezed == modifiedOn + ? _value.modifiedOn + : modifiedOn // ignore: cast_nullable_to_non_nullable + as int?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$ChatMessageImplCopyWith<$Res> + implements $ChatMessageCopyWith<$Res> { + factory _$$ChatMessageImplCopyWith( + _$ChatMessageImpl value, + $Res Function(_$ChatMessageImpl) then, + ) = __$$ChatMessageImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + @JsonKey(name: '_id') String id, + @JsonKey(name: '_class') String className, + String attachedTo, + String message, + String? createdBy, + String? modifiedBy, + int? createdOn, + int? modifiedOn, + }); +} + +/// @nodoc +class __$$ChatMessageImplCopyWithImpl<$Res> + extends _$ChatMessageCopyWithImpl<$Res, _$ChatMessageImpl> + implements _$$ChatMessageImplCopyWith<$Res> { + __$$ChatMessageImplCopyWithImpl( + _$ChatMessageImpl _value, + $Res Function(_$ChatMessageImpl) _then, + ) : super(_value, _then); + + /// Create a copy of ChatMessage + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? className = null, + Object? attachedTo = null, + Object? message = null, + Object? createdBy = freezed, + Object? modifiedBy = freezed, + Object? createdOn = freezed, + Object? modifiedOn = freezed, + }) { + return _then( + _$ChatMessageImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + className: null == className + ? _value.className + : className // ignore: cast_nullable_to_non_nullable + as String, + attachedTo: null == attachedTo + ? _value.attachedTo + : attachedTo // ignore: cast_nullable_to_non_nullable + as String, + message: null == message + ? _value.message + : message // ignore: cast_nullable_to_non_nullable + as String, + createdBy: freezed == createdBy + ? _value.createdBy + : createdBy // ignore: cast_nullable_to_non_nullable + as String?, + modifiedBy: freezed == modifiedBy + ? _value.modifiedBy + : modifiedBy // ignore: cast_nullable_to_non_nullable + as String?, + createdOn: freezed == createdOn + ? _value.createdOn + : createdOn // ignore: cast_nullable_to_non_nullable + as int?, + modifiedOn: freezed == modifiedOn + ? _value.modifiedOn + : modifiedOn // ignore: cast_nullable_to_non_nullable + as int?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$ChatMessageImpl implements _ChatMessage { + const _$ChatMessageImpl({ + @JsonKey(name: '_id') required this.id, + @JsonKey(name: '_class') required this.className, + required this.attachedTo, + required this.message, + this.createdBy, + this.modifiedBy, + this.createdOn, + this.modifiedOn, + }); + + factory _$ChatMessageImpl.fromJson(Map json) => + _$$ChatMessageImplFromJson(json); + + @override + @JsonKey(name: '_id') + final String id; + @override + @JsonKey(name: '_class') + final String className; + @override + final String attachedTo; + @override + final String message; + @override + final String? createdBy; + @override + final String? modifiedBy; + @override + final int? createdOn; + @override + final int? modifiedOn; + + @override + String toString() { + return 'ChatMessage(id: $id, className: $className, attachedTo: $attachedTo, message: $message, createdBy: $createdBy, modifiedBy: $modifiedBy, createdOn: $createdOn, modifiedOn: $modifiedOn)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ChatMessageImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.className, className) || + other.className == className) && + (identical(other.attachedTo, attachedTo) || + other.attachedTo == attachedTo) && + (identical(other.message, message) || other.message == message) && + (identical(other.createdBy, createdBy) || + other.createdBy == createdBy) && + (identical(other.modifiedBy, modifiedBy) || + other.modifiedBy == modifiedBy) && + (identical(other.createdOn, createdOn) || + other.createdOn == createdOn) && + (identical(other.modifiedOn, modifiedOn) || + other.modifiedOn == modifiedOn)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + className, + attachedTo, + message, + createdBy, + modifiedBy, + createdOn, + modifiedOn, + ); + + /// Create a copy of ChatMessage + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ChatMessageImplCopyWith<_$ChatMessageImpl> get copyWith => + __$$ChatMessageImplCopyWithImpl<_$ChatMessageImpl>(this, _$identity); + + @override + Map toJson() { + return _$$ChatMessageImplToJson(this); + } +} + +abstract class _ChatMessage implements ChatMessage { + const factory _ChatMessage({ + @JsonKey(name: '_id') required final String id, + @JsonKey(name: '_class') required final String className, + required final String attachedTo, + required final String message, + final String? createdBy, + final String? modifiedBy, + final int? createdOn, + final int? modifiedOn, + }) = _$ChatMessageImpl; + + factory _ChatMessage.fromJson(Map json) = + _$ChatMessageImpl.fromJson; + + @override + @JsonKey(name: '_id') + String get id; + @override + @JsonKey(name: '_class') + String get className; + @override + String get attachedTo; + @override + String get message; + @override + String? get createdBy; + @override + String? get modifiedBy; + @override + int? get createdOn; + @override + int? get modifiedOn; + + /// Create a copy of ChatMessage + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ChatMessageImplCopyWith<_$ChatMessageImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/mobile/lib/core/models/activity.g.dart b/mobile/lib/core/models/activity.g.dart new file mode 100644 index 00000000000..7d56ef8060f --- /dev/null +++ b/mobile/lib/core/models/activity.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'activity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$ChatMessageImpl _$$ChatMessageImplFromJson(Map json) => + _$ChatMessageImpl( + id: json['_id'] as String, + className: json['_class'] as String, + attachedTo: json['attachedTo'] as String, + message: json['message'] as String, + createdBy: json['createdBy'] as String?, + modifiedBy: json['modifiedBy'] as String?, + createdOn: (json['createdOn'] as num?)?.toInt(), + modifiedOn: (json['modifiedOn'] as num?)?.toInt(), + ); + +Map _$$ChatMessageImplToJson(_$ChatMessageImpl instance) => + { + '_id': instance.id, + '_class': instance.className, + 'attachedTo': instance.attachedTo, + 'message': instance.message, + 'createdBy': instance.createdBy, + 'modifiedBy': instance.modifiedBy, + 'createdOn': instance.createdOn, + 'modifiedOn': instance.modifiedOn, + }; diff --git a/mobile/lib/core/models/attachment.dart b/mobile/lib/core/models/attachment.dart new file mode 100644 index 00000000000..2f8b7284240 --- /dev/null +++ b/mobile/lib/core/models/attachment.dart @@ -0,0 +1,20 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'attachment.freezed.dart'; +part 'attachment.g.dart'; + +/// Represents an attachment:class:Attachment document. +@freezed +class Attachment with _$Attachment { + const factory Attachment({ + @JsonKey(name: '_id') required String id, + required String name, + required String file, + required int size, + required String type, + String? attachedTo, + }) = _Attachment; + + factory Attachment.fromJson(Map json) => + _$AttachmentFromJson(json); +} diff --git a/mobile/lib/core/models/attachment.freezed.dart b/mobile/lib/core/models/attachment.freezed.dart new file mode 100644 index 00000000000..e813eb0ff66 --- /dev/null +++ b/mobile/lib/core/models/attachment.freezed.dart @@ -0,0 +1,284 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'attachment.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +Attachment _$AttachmentFromJson(Map json) { + return _Attachment.fromJson(json); +} + +/// @nodoc +mixin _$Attachment { + @JsonKey(name: '_id') + String get id => throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + String get file => throw _privateConstructorUsedError; + int get size => throw _privateConstructorUsedError; + String get type => throw _privateConstructorUsedError; + String? get attachedTo => throw _privateConstructorUsedError; + + /// Serializes this Attachment to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of Attachment + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AttachmentCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AttachmentCopyWith<$Res> { + factory $AttachmentCopyWith( + Attachment value, + $Res Function(Attachment) then, + ) = _$AttachmentCopyWithImpl<$Res, Attachment>; + @useResult + $Res call({ + @JsonKey(name: '_id') String id, + String name, + String file, + int size, + String type, + String? attachedTo, + }); +} + +/// @nodoc +class _$AttachmentCopyWithImpl<$Res, $Val extends Attachment> + implements $AttachmentCopyWith<$Res> { + _$AttachmentCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of Attachment + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = null, + Object? file = null, + Object? size = null, + Object? type = null, + Object? attachedTo = freezed, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + file: null == file + ? _value.file + : file // ignore: cast_nullable_to_non_nullable + as String, + size: null == size + ? _value.size + : size // ignore: cast_nullable_to_non_nullable + as int, + type: null == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as String, + attachedTo: freezed == attachedTo + ? _value.attachedTo + : attachedTo // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$AttachmentImplCopyWith<$Res> + implements $AttachmentCopyWith<$Res> { + factory _$$AttachmentImplCopyWith( + _$AttachmentImpl value, + $Res Function(_$AttachmentImpl) then, + ) = __$$AttachmentImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + @JsonKey(name: '_id') String id, + String name, + String file, + int size, + String type, + String? attachedTo, + }); +} + +/// @nodoc +class __$$AttachmentImplCopyWithImpl<$Res> + extends _$AttachmentCopyWithImpl<$Res, _$AttachmentImpl> + implements _$$AttachmentImplCopyWith<$Res> { + __$$AttachmentImplCopyWithImpl( + _$AttachmentImpl _value, + $Res Function(_$AttachmentImpl) _then, + ) : super(_value, _then); + + /// Create a copy of Attachment + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = null, + Object? file = null, + Object? size = null, + Object? type = null, + Object? attachedTo = freezed, + }) { + return _then( + _$AttachmentImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + file: null == file + ? _value.file + : file // ignore: cast_nullable_to_non_nullable + as String, + size: null == size + ? _value.size + : size // ignore: cast_nullable_to_non_nullable + as int, + type: null == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as String, + attachedTo: freezed == attachedTo + ? _value.attachedTo + : attachedTo // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$AttachmentImpl implements _Attachment { + const _$AttachmentImpl({ + @JsonKey(name: '_id') required this.id, + required this.name, + required this.file, + required this.size, + required this.type, + this.attachedTo, + }); + + factory _$AttachmentImpl.fromJson(Map json) => + _$$AttachmentImplFromJson(json); + + @override + @JsonKey(name: '_id') + final String id; + @override + final String name; + @override + final String file; + @override + final int size; + @override + final String type; + @override + final String? attachedTo; + + @override + String toString() { + return 'Attachment(id: $id, name: $name, file: $file, size: $size, type: $type, attachedTo: $attachedTo)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AttachmentImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.name, name) || other.name == name) && + (identical(other.file, file) || other.file == file) && + (identical(other.size, size) || other.size == size) && + (identical(other.type, type) || other.type == type) && + (identical(other.attachedTo, attachedTo) || + other.attachedTo == attachedTo)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => + Object.hash(runtimeType, id, name, file, size, type, attachedTo); + + /// Create a copy of Attachment + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AttachmentImplCopyWith<_$AttachmentImpl> get copyWith => + __$$AttachmentImplCopyWithImpl<_$AttachmentImpl>(this, _$identity); + + @override + Map toJson() { + return _$$AttachmentImplToJson(this); + } +} + +abstract class _Attachment implements Attachment { + const factory _Attachment({ + @JsonKey(name: '_id') required final String id, + required final String name, + required final String file, + required final int size, + required final String type, + final String? attachedTo, + }) = _$AttachmentImpl; + + factory _Attachment.fromJson(Map json) = + _$AttachmentImpl.fromJson; + + @override + @JsonKey(name: '_id') + String get id; + @override + String get name; + @override + String get file; + @override + int get size; + @override + String get type; + @override + String? get attachedTo; + + /// Create a copy of Attachment + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AttachmentImplCopyWith<_$AttachmentImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/mobile/lib/core/models/attachment.g.dart b/mobile/lib/core/models/attachment.g.dart new file mode 100644 index 00000000000..75444b44fb4 --- /dev/null +++ b/mobile/lib/core/models/attachment.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'attachment.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$AttachmentImpl _$$AttachmentImplFromJson(Map json) => + _$AttachmentImpl( + id: json['_id'] as String, + name: json['name'] as String, + file: json['file'] as String, + size: (json['size'] as num).toInt(), + type: json['type'] as String, + attachedTo: json['attachedTo'] as String?, + ); + +Map _$$AttachmentImplToJson(_$AttachmentImpl instance) => + { + '_id': instance.id, + 'name': instance.name, + 'file': instance.file, + 'size': instance.size, + 'type': instance.type, + 'attachedTo': instance.attachedTo, + }; diff --git a/mobile/lib/core/models/channel.dart b/mobile/lib/core/models/channel.dart new file mode 100644 index 00000000000..b2438d4b11f --- /dev/null +++ b/mobile/lib/core/models/channel.dart @@ -0,0 +1,21 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'channel.freezed.dart'; +part 'channel.g.dart'; + +/// Represents a chunter:class:Channel or chunter:class:DirectMessage. +@freezed +class Channel with _$Channel { + const factory Channel({ + @JsonKey(name: '_id') required String id, + @JsonKey(name: '_class') required String className, + required String name, + String? description, + String? topic, + @Default([]) List members, + int? modifiedOn, + }) = _Channel; + + factory Channel.fromJson(Map json) => + _$ChannelFromJson(json); +} diff --git a/mobile/lib/core/models/channel.freezed.dart b/mobile/lib/core/models/channel.freezed.dart new file mode 100644 index 00000000000..c9d51f78c0a --- /dev/null +++ b/mobile/lib/core/models/channel.freezed.dart @@ -0,0 +1,319 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'channel.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +Channel _$ChannelFromJson(Map json) { + return _Channel.fromJson(json); +} + +/// @nodoc +mixin _$Channel { + @JsonKey(name: '_id') + String get id => throw _privateConstructorUsedError; + @JsonKey(name: '_class') + String get className => throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + String? get description => throw _privateConstructorUsedError; + String? get topic => throw _privateConstructorUsedError; + List get members => throw _privateConstructorUsedError; + int? get modifiedOn => throw _privateConstructorUsedError; + + /// Serializes this Channel to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of Channel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ChannelCopyWith get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ChannelCopyWith<$Res> { + factory $ChannelCopyWith(Channel value, $Res Function(Channel) then) = + _$ChannelCopyWithImpl<$Res, Channel>; + @useResult + $Res call({ + @JsonKey(name: '_id') String id, + @JsonKey(name: '_class') String className, + String name, + String? description, + String? topic, + List members, + int? modifiedOn, + }); +} + +/// @nodoc +class _$ChannelCopyWithImpl<$Res, $Val extends Channel> + implements $ChannelCopyWith<$Res> { + _$ChannelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of Channel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? className = null, + Object? name = null, + Object? description = freezed, + Object? topic = freezed, + Object? members = null, + Object? modifiedOn = freezed, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + className: null == className + ? _value.className + : className // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + description: freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + topic: freezed == topic + ? _value.topic + : topic // ignore: cast_nullable_to_non_nullable + as String?, + members: null == members + ? _value.members + : members // ignore: cast_nullable_to_non_nullable + as List, + modifiedOn: freezed == modifiedOn + ? _value.modifiedOn + : modifiedOn // ignore: cast_nullable_to_non_nullable + as int?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$ChannelImplCopyWith<$Res> implements $ChannelCopyWith<$Res> { + factory _$$ChannelImplCopyWith( + _$ChannelImpl value, + $Res Function(_$ChannelImpl) then, + ) = __$$ChannelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + @JsonKey(name: '_id') String id, + @JsonKey(name: '_class') String className, + String name, + String? description, + String? topic, + List members, + int? modifiedOn, + }); +} + +/// @nodoc +class __$$ChannelImplCopyWithImpl<$Res> + extends _$ChannelCopyWithImpl<$Res, _$ChannelImpl> + implements _$$ChannelImplCopyWith<$Res> { + __$$ChannelImplCopyWithImpl( + _$ChannelImpl _value, + $Res Function(_$ChannelImpl) _then, + ) : super(_value, _then); + + /// Create a copy of Channel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? className = null, + Object? name = null, + Object? description = freezed, + Object? topic = freezed, + Object? members = null, + Object? modifiedOn = freezed, + }) { + return _then( + _$ChannelImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + className: null == className + ? _value.className + : className // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + description: freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + topic: freezed == topic + ? _value.topic + : topic // ignore: cast_nullable_to_non_nullable + as String?, + members: null == members + ? _value._members + : members // ignore: cast_nullable_to_non_nullable + as List, + modifiedOn: freezed == modifiedOn + ? _value.modifiedOn + : modifiedOn // ignore: cast_nullable_to_non_nullable + as int?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$ChannelImpl implements _Channel { + const _$ChannelImpl({ + @JsonKey(name: '_id') required this.id, + @JsonKey(name: '_class') required this.className, + required this.name, + this.description, + this.topic, + final List members = const [], + this.modifiedOn, + }) : _members = members; + + factory _$ChannelImpl.fromJson(Map json) => + _$$ChannelImplFromJson(json); + + @override + @JsonKey(name: '_id') + final String id; + @override + @JsonKey(name: '_class') + final String className; + @override + final String name; + @override + final String? description; + @override + final String? topic; + final List _members; + @override + @JsonKey() + List get members { + if (_members is EqualUnmodifiableListView) return _members; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_members); + } + + @override + final int? modifiedOn; + + @override + String toString() { + return 'Channel(id: $id, className: $className, name: $name, description: $description, topic: $topic, members: $members, modifiedOn: $modifiedOn)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ChannelImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.className, className) || + other.className == className) && + (identical(other.name, name) || other.name == name) && + (identical(other.description, description) || + other.description == description) && + (identical(other.topic, topic) || other.topic == topic) && + const DeepCollectionEquality().equals(other._members, _members) && + (identical(other.modifiedOn, modifiedOn) || + other.modifiedOn == modifiedOn)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + className, + name, + description, + topic, + const DeepCollectionEquality().hash(_members), + modifiedOn, + ); + + /// Create a copy of Channel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ChannelImplCopyWith<_$ChannelImpl> get copyWith => + __$$ChannelImplCopyWithImpl<_$ChannelImpl>(this, _$identity); + + @override + Map toJson() { + return _$$ChannelImplToJson(this); + } +} + +abstract class _Channel implements Channel { + const factory _Channel({ + @JsonKey(name: '_id') required final String id, + @JsonKey(name: '_class') required final String className, + required final String name, + final String? description, + final String? topic, + final List members, + final int? modifiedOn, + }) = _$ChannelImpl; + + factory _Channel.fromJson(Map json) = _$ChannelImpl.fromJson; + + @override + @JsonKey(name: '_id') + String get id; + @override + @JsonKey(name: '_class') + String get className; + @override + String get name; + @override + String? get description; + @override + String? get topic; + @override + List get members; + @override + int? get modifiedOn; + + /// Create a copy of Channel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ChannelImplCopyWith<_$ChannelImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/mobile/lib/core/models/channel.g.dart b/mobile/lib/core/models/channel.g.dart new file mode 100644 index 00000000000..b9fc64092f4 --- /dev/null +++ b/mobile/lib/core/models/channel.g.dart @@ -0,0 +1,33 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'channel.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$ChannelImpl _$$ChannelImplFromJson(Map json) => + _$ChannelImpl( + id: json['_id'] as String, + className: json['_class'] as String, + name: json['name'] as String, + description: json['description'] as String?, + topic: json['topic'] as String?, + members: + (json['members'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + modifiedOn: (json['modifiedOn'] as num?)?.toInt(), + ); + +Map _$$ChannelImplToJson(_$ChannelImpl instance) => + { + '_id': instance.id, + '_class': instance.className, + 'name': instance.name, + 'description': instance.description, + 'topic': instance.topic, + 'members': instance.members, + 'modifiedOn': instance.modifiedOn, + }; diff --git a/mobile/lib/core/models/issue.dart b/mobile/lib/core/models/issue.dart new file mode 100644 index 00000000000..b66a9face38 --- /dev/null +++ b/mobile/lib/core/models/issue.dart @@ -0,0 +1,49 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'issue.freezed.dart'; +part 'issue.g.dart'; + +/// Priority values matching tracker:IssuePriority +class IssuePriority { + static const int noPriority = 0; + static const int urgent = 1; + static const int high = 2; + static const int medium = 3; + static const int low = 4; + + static String label(int priority) { + switch (priority) { + case urgent: + return 'Urgent'; + case high: + return 'High'; + case medium: + return 'Medium'; + case low: + return 'Low'; + default: + return 'No priority'; + } + } +} + +@freezed +class Issue with _$Issue { + const factory Issue({ + @JsonKey(name: '_id') required String id, + @JsonKey(name: '_class') required String className, + required String title, + String? description, + required int priority, + required int number, + required String identifier, + required String status, + required String space, + String? assignee, + String? modifiedBy, + int? modifiedOn, + int? createdOn, + }) = _Issue; + + factory Issue.fromJson(Map json) => _$IssueFromJson(json); +} diff --git a/mobile/lib/core/models/issue.freezed.dart b/mobile/lib/core/models/issue.freezed.dart new file mode 100644 index 00000000000..696b96b6f2b --- /dev/null +++ b/mobile/lib/core/models/issue.freezed.dart @@ -0,0 +1,443 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'issue.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +Issue _$IssueFromJson(Map json) { + return _Issue.fromJson(json); +} + +/// @nodoc +mixin _$Issue { + @JsonKey(name: '_id') + String get id => throw _privateConstructorUsedError; + @JsonKey(name: '_class') + String get className => throw _privateConstructorUsedError; + String get title => throw _privateConstructorUsedError; + String? get description => throw _privateConstructorUsedError; + int get priority => throw _privateConstructorUsedError; + int get number => throw _privateConstructorUsedError; + String get identifier => throw _privateConstructorUsedError; + String get status => throw _privateConstructorUsedError; + String get space => throw _privateConstructorUsedError; + String? get assignee => throw _privateConstructorUsedError; + String? get modifiedBy => throw _privateConstructorUsedError; + int? get modifiedOn => throw _privateConstructorUsedError; + int? get createdOn => throw _privateConstructorUsedError; + + /// Serializes this Issue to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of Issue + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $IssueCopyWith get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $IssueCopyWith<$Res> { + factory $IssueCopyWith(Issue value, $Res Function(Issue) then) = + _$IssueCopyWithImpl<$Res, Issue>; + @useResult + $Res call({ + @JsonKey(name: '_id') String id, + @JsonKey(name: '_class') String className, + String title, + String? description, + int priority, + int number, + String identifier, + String status, + String space, + String? assignee, + String? modifiedBy, + int? modifiedOn, + int? createdOn, + }); +} + +/// @nodoc +class _$IssueCopyWithImpl<$Res, $Val extends Issue> + implements $IssueCopyWith<$Res> { + _$IssueCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of Issue + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? className = null, + Object? title = null, + Object? description = freezed, + Object? priority = null, + Object? number = null, + Object? identifier = null, + Object? status = null, + Object? space = null, + Object? assignee = freezed, + Object? modifiedBy = freezed, + Object? modifiedOn = freezed, + Object? createdOn = freezed, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + className: null == className + ? _value.className + : className // ignore: cast_nullable_to_non_nullable + as String, + title: null == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String, + description: freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + priority: null == priority + ? _value.priority + : priority // ignore: cast_nullable_to_non_nullable + as int, + number: null == number + ? _value.number + : number // ignore: cast_nullable_to_non_nullable + as int, + identifier: null == identifier + ? _value.identifier + : identifier // ignore: cast_nullable_to_non_nullable + as String, + status: null == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as String, + space: null == space + ? _value.space + : space // ignore: cast_nullable_to_non_nullable + as String, + assignee: freezed == assignee + ? _value.assignee + : assignee // ignore: cast_nullable_to_non_nullable + as String?, + modifiedBy: freezed == modifiedBy + ? _value.modifiedBy + : modifiedBy // ignore: cast_nullable_to_non_nullable + as String?, + modifiedOn: freezed == modifiedOn + ? _value.modifiedOn + : modifiedOn // ignore: cast_nullable_to_non_nullable + as int?, + createdOn: freezed == createdOn + ? _value.createdOn + : createdOn // ignore: cast_nullable_to_non_nullable + as int?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$IssueImplCopyWith<$Res> implements $IssueCopyWith<$Res> { + factory _$$IssueImplCopyWith( + _$IssueImpl value, + $Res Function(_$IssueImpl) then, + ) = __$$IssueImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + @JsonKey(name: '_id') String id, + @JsonKey(name: '_class') String className, + String title, + String? description, + int priority, + int number, + String identifier, + String status, + String space, + String? assignee, + String? modifiedBy, + int? modifiedOn, + int? createdOn, + }); +} + +/// @nodoc +class __$$IssueImplCopyWithImpl<$Res> + extends _$IssueCopyWithImpl<$Res, _$IssueImpl> + implements _$$IssueImplCopyWith<$Res> { + __$$IssueImplCopyWithImpl( + _$IssueImpl _value, + $Res Function(_$IssueImpl) _then, + ) : super(_value, _then); + + /// Create a copy of Issue + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? className = null, + Object? title = null, + Object? description = freezed, + Object? priority = null, + Object? number = null, + Object? identifier = null, + Object? status = null, + Object? space = null, + Object? assignee = freezed, + Object? modifiedBy = freezed, + Object? modifiedOn = freezed, + Object? createdOn = freezed, + }) { + return _then( + _$IssueImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + className: null == className + ? _value.className + : className // ignore: cast_nullable_to_non_nullable + as String, + title: null == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String, + description: freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + priority: null == priority + ? _value.priority + : priority // ignore: cast_nullable_to_non_nullable + as int, + number: null == number + ? _value.number + : number // ignore: cast_nullable_to_non_nullable + as int, + identifier: null == identifier + ? _value.identifier + : identifier // ignore: cast_nullable_to_non_nullable + as String, + status: null == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as String, + space: null == space + ? _value.space + : space // ignore: cast_nullable_to_non_nullable + as String, + assignee: freezed == assignee + ? _value.assignee + : assignee // ignore: cast_nullable_to_non_nullable + as String?, + modifiedBy: freezed == modifiedBy + ? _value.modifiedBy + : modifiedBy // ignore: cast_nullable_to_non_nullable + as String?, + modifiedOn: freezed == modifiedOn + ? _value.modifiedOn + : modifiedOn // ignore: cast_nullable_to_non_nullable + as int?, + createdOn: freezed == createdOn + ? _value.createdOn + : createdOn // ignore: cast_nullable_to_non_nullable + as int?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$IssueImpl implements _Issue { + const _$IssueImpl({ + @JsonKey(name: '_id') required this.id, + @JsonKey(name: '_class') required this.className, + required this.title, + this.description, + required this.priority, + required this.number, + required this.identifier, + required this.status, + required this.space, + this.assignee, + this.modifiedBy, + this.modifiedOn, + this.createdOn, + }); + + factory _$IssueImpl.fromJson(Map json) => + _$$IssueImplFromJson(json); + + @override + @JsonKey(name: '_id') + final String id; + @override + @JsonKey(name: '_class') + final String className; + @override + final String title; + @override + final String? description; + @override + final int priority; + @override + final int number; + @override + final String identifier; + @override + final String status; + @override + final String space; + @override + final String? assignee; + @override + final String? modifiedBy; + @override + final int? modifiedOn; + @override + final int? createdOn; + + @override + String toString() { + return 'Issue(id: $id, className: $className, title: $title, description: $description, priority: $priority, number: $number, identifier: $identifier, status: $status, space: $space, assignee: $assignee, modifiedBy: $modifiedBy, modifiedOn: $modifiedOn, createdOn: $createdOn)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$IssueImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.className, className) || + other.className == className) && + (identical(other.title, title) || other.title == title) && + (identical(other.description, description) || + other.description == description) && + (identical(other.priority, priority) || + other.priority == priority) && + (identical(other.number, number) || other.number == number) && + (identical(other.identifier, identifier) || + other.identifier == identifier) && + (identical(other.status, status) || other.status == status) && + (identical(other.space, space) || other.space == space) && + (identical(other.assignee, assignee) || + other.assignee == assignee) && + (identical(other.modifiedBy, modifiedBy) || + other.modifiedBy == modifiedBy) && + (identical(other.modifiedOn, modifiedOn) || + other.modifiedOn == modifiedOn) && + (identical(other.createdOn, createdOn) || + other.createdOn == createdOn)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + className, + title, + description, + priority, + number, + identifier, + status, + space, + assignee, + modifiedBy, + modifiedOn, + createdOn, + ); + + /// Create a copy of Issue + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$IssueImplCopyWith<_$IssueImpl> get copyWith => + __$$IssueImplCopyWithImpl<_$IssueImpl>(this, _$identity); + + @override + Map toJson() { + return _$$IssueImplToJson(this); + } +} + +abstract class _Issue implements Issue { + const factory _Issue({ + @JsonKey(name: '_id') required final String id, + @JsonKey(name: '_class') required final String className, + required final String title, + final String? description, + required final int priority, + required final int number, + required final String identifier, + required final String status, + required final String space, + final String? assignee, + final String? modifiedBy, + final int? modifiedOn, + final int? createdOn, + }) = _$IssueImpl; + + factory _Issue.fromJson(Map json) = _$IssueImpl.fromJson; + + @override + @JsonKey(name: '_id') + String get id; + @override + @JsonKey(name: '_class') + String get className; + @override + String get title; + @override + String? get description; + @override + int get priority; + @override + int get number; + @override + String get identifier; + @override + String get status; + @override + String get space; + @override + String? get assignee; + @override + String? get modifiedBy; + @override + int? get modifiedOn; + @override + int? get createdOn; + + /// Create a copy of Issue + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$IssueImplCopyWith<_$IssueImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/mobile/lib/core/models/issue.g.dart b/mobile/lib/core/models/issue.g.dart new file mode 100644 index 00000000000..25a4c71c22b --- /dev/null +++ b/mobile/lib/core/models/issue.g.dart @@ -0,0 +1,40 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'issue.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$IssueImpl _$$IssueImplFromJson(Map json) => _$IssueImpl( + id: json['_id'] as String, + className: json['_class'] as String, + title: json['title'] as String, + description: json['description'] as String?, + priority: (json['priority'] as num).toInt(), + number: (json['number'] as num).toInt(), + identifier: json['identifier'] as String, + status: json['status'] as String, + space: json['space'] as String, + assignee: json['assignee'] as String?, + modifiedBy: json['modifiedBy'] as String?, + modifiedOn: (json['modifiedOn'] as num?)?.toInt(), + createdOn: (json['createdOn'] as num?)?.toInt(), +); + +Map _$$IssueImplToJson(_$IssueImpl instance) => + { + '_id': instance.id, + '_class': instance.className, + 'title': instance.title, + 'description': instance.description, + 'priority': instance.priority, + 'number': instance.number, + 'identifier': instance.identifier, + 'status': instance.status, + 'space': instance.space, + 'assignee': instance.assignee, + 'modifiedBy': instance.modifiedBy, + 'modifiedOn': instance.modifiedOn, + 'createdOn': instance.createdOn, + }; diff --git a/mobile/lib/core/models/issue_status.dart b/mobile/lib/core/models/issue_status.dart new file mode 100644 index 00000000000..fbd65b525a9 --- /dev/null +++ b/mobile/lib/core/models/issue_status.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'issue_status.freezed.dart'; +part 'issue_status.g.dart'; + +/// Represents a tracker issue status (core:class:Status). +@freezed +class IssueStatus with _$IssueStatus { + const factory IssueStatus({ + @JsonKey(name: '_id') required String id, + required String name, + String? category, + @JsonKey(name: 'ofAttribute') String? ofAttribute, + @Default(0) int color, + }) = _IssueStatus; + + factory IssueStatus.fromJson(Map json) => + _$IssueStatusFromJson(json); +} diff --git a/mobile/lib/core/models/issue_status.freezed.dart b/mobile/lib/core/models/issue_status.freezed.dart new file mode 100644 index 00000000000..77d08d439e5 --- /dev/null +++ b/mobile/lib/core/models/issue_status.freezed.dart @@ -0,0 +1,269 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'issue_status.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +IssueStatus _$IssueStatusFromJson(Map json) { + return _IssueStatus.fromJson(json); +} + +/// @nodoc +mixin _$IssueStatus { + @JsonKey(name: '_id') + String get id => throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + String? get category => throw _privateConstructorUsedError; + @JsonKey(name: 'ofAttribute') + String? get ofAttribute => throw _privateConstructorUsedError; + int get color => throw _privateConstructorUsedError; + + /// Serializes this IssueStatus to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of IssueStatus + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $IssueStatusCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $IssueStatusCopyWith<$Res> { + factory $IssueStatusCopyWith( + IssueStatus value, + $Res Function(IssueStatus) then, + ) = _$IssueStatusCopyWithImpl<$Res, IssueStatus>; + @useResult + $Res call({ + @JsonKey(name: '_id') String id, + String name, + String? category, + @JsonKey(name: 'ofAttribute') String? ofAttribute, + int color, + }); +} + +/// @nodoc +class _$IssueStatusCopyWithImpl<$Res, $Val extends IssueStatus> + implements $IssueStatusCopyWith<$Res> { + _$IssueStatusCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of IssueStatus + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = null, + Object? category = freezed, + Object? ofAttribute = freezed, + Object? color = null, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + category: freezed == category + ? _value.category + : category // ignore: cast_nullable_to_non_nullable + as String?, + ofAttribute: freezed == ofAttribute + ? _value.ofAttribute + : ofAttribute // ignore: cast_nullable_to_non_nullable + as String?, + color: null == color + ? _value.color + : color // ignore: cast_nullable_to_non_nullable + as int, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$IssueStatusImplCopyWith<$Res> + implements $IssueStatusCopyWith<$Res> { + factory _$$IssueStatusImplCopyWith( + _$IssueStatusImpl value, + $Res Function(_$IssueStatusImpl) then, + ) = __$$IssueStatusImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + @JsonKey(name: '_id') String id, + String name, + String? category, + @JsonKey(name: 'ofAttribute') String? ofAttribute, + int color, + }); +} + +/// @nodoc +class __$$IssueStatusImplCopyWithImpl<$Res> + extends _$IssueStatusCopyWithImpl<$Res, _$IssueStatusImpl> + implements _$$IssueStatusImplCopyWith<$Res> { + __$$IssueStatusImplCopyWithImpl( + _$IssueStatusImpl _value, + $Res Function(_$IssueStatusImpl) _then, + ) : super(_value, _then); + + /// Create a copy of IssueStatus + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = null, + Object? category = freezed, + Object? ofAttribute = freezed, + Object? color = null, + }) { + return _then( + _$IssueStatusImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + category: freezed == category + ? _value.category + : category // ignore: cast_nullable_to_non_nullable + as String?, + ofAttribute: freezed == ofAttribute + ? _value.ofAttribute + : ofAttribute // ignore: cast_nullable_to_non_nullable + as String?, + color: null == color + ? _value.color + : color // ignore: cast_nullable_to_non_nullable + as int, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$IssueStatusImpl implements _IssueStatus { + const _$IssueStatusImpl({ + @JsonKey(name: '_id') required this.id, + required this.name, + this.category, + @JsonKey(name: 'ofAttribute') this.ofAttribute, + this.color = 0, + }); + + factory _$IssueStatusImpl.fromJson(Map json) => + _$$IssueStatusImplFromJson(json); + + @override + @JsonKey(name: '_id') + final String id; + @override + final String name; + @override + final String? category; + @override + @JsonKey(name: 'ofAttribute') + final String? ofAttribute; + @override + @JsonKey() + final int color; + + @override + String toString() { + return 'IssueStatus(id: $id, name: $name, category: $category, ofAttribute: $ofAttribute, color: $color)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$IssueStatusImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.name, name) || other.name == name) && + (identical(other.category, category) || + other.category == category) && + (identical(other.ofAttribute, ofAttribute) || + other.ofAttribute == ofAttribute) && + (identical(other.color, color) || other.color == color)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => + Object.hash(runtimeType, id, name, category, ofAttribute, color); + + /// Create a copy of IssueStatus + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$IssueStatusImplCopyWith<_$IssueStatusImpl> get copyWith => + __$$IssueStatusImplCopyWithImpl<_$IssueStatusImpl>(this, _$identity); + + @override + Map toJson() { + return _$$IssueStatusImplToJson(this); + } +} + +abstract class _IssueStatus implements IssueStatus { + const factory _IssueStatus({ + @JsonKey(name: '_id') required final String id, + required final String name, + final String? category, + @JsonKey(name: 'ofAttribute') final String? ofAttribute, + final int color, + }) = _$IssueStatusImpl; + + factory _IssueStatus.fromJson(Map json) = + _$IssueStatusImpl.fromJson; + + @override + @JsonKey(name: '_id') + String get id; + @override + String get name; + @override + String? get category; + @override + @JsonKey(name: 'ofAttribute') + String? get ofAttribute; + @override + int get color; + + /// Create a copy of IssueStatus + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$IssueStatusImplCopyWith<_$IssueStatusImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/mobile/lib/core/models/issue_status.g.dart b/mobile/lib/core/models/issue_status.g.dart new file mode 100644 index 00000000000..2eae1ed2659 --- /dev/null +++ b/mobile/lib/core/models/issue_status.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'issue_status.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$IssueStatusImpl _$$IssueStatusImplFromJson(Map json) => + _$IssueStatusImpl( + id: json['_id'] as String, + name: json['name'] as String, + category: json['category'] as String?, + ofAttribute: json['ofAttribute'] as String?, + color: (json['color'] as num?)?.toInt() ?? 0, + ); + +Map _$$IssueStatusImplToJson(_$IssueStatusImpl instance) => + { + '_id': instance.id, + 'name': instance.name, + 'category': instance.category, + 'ofAttribute': instance.ofAttribute, + 'color': instance.color, + }; diff --git a/mobile/lib/core/models/login_info.dart b/mobile/lib/core/models/login_info.dart new file mode 100644 index 00000000000..f83852eca4f --- /dev/null +++ b/mobile/lib/core/models/login_info.dart @@ -0,0 +1,64 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'login_info.freezed.dart'; +part 'login_info.g.dart'; + +@freezed +class LoginInfo with _$LoginInfo { + const factory LoginInfo({ + required String token, + @JsonKey(name: 'account') required String accountId, + @Default(false) bool tfaRequired, + }) = _LoginInfo; + + factory LoginInfo.fromJson(Map json) => + _$LoginInfoFromJson(json); +} + +@freezed +class WorkspaceInfo with _$WorkspaceInfo { + const factory WorkspaceInfo({ + required String workspaceUrl, + required String workspaceName, + String? workspaceId, + }) = _WorkspaceInfo; + + factory WorkspaceInfo.fromJson(Map json) => + _$WorkspaceInfoFromJson(json); +} + +@freezed +class WorkspaceLoginInfo with _$WorkspaceLoginInfo { + const factory WorkspaceLoginInfo({ + required String token, + required String endpoint, + @JsonKey(name: 'workspace') required String workspaceId, + String? workspaceUrl, + String? role, + }) = _WorkspaceLoginInfo; + + factory WorkspaceLoginInfo.fromJson(Map json) => + _$WorkspaceLoginInfoFromJson(json); +} + +@freezed +class OtpInfo with _$OtpInfo { + const factory OtpInfo({ + @Default(true) bool sent, + }) = _OtpInfo; + + factory OtpInfo.fromJson(Map json) => + _$OtpInfoFromJson(json); +} + +@freezed +class ProviderInfo with _$ProviderInfo { + const factory ProviderInfo({ + required String id, + String? name, + String? icon, + }) = _ProviderInfo; + + factory ProviderInfo.fromJson(Map json) => + _$ProviderInfoFromJson(json); +} diff --git a/mobile/lib/core/models/login_info.freezed.dart b/mobile/lib/core/models/login_info.freezed.dart new file mode 100644 index 00000000000..f588801d8bd --- /dev/null +++ b/mobile/lib/core/models/login_info.freezed.dart @@ -0,0 +1,1019 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'login_info.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +LoginInfo _$LoginInfoFromJson(Map json) { + return _LoginInfo.fromJson(json); +} + +/// @nodoc +mixin _$LoginInfo { + String get token => throw _privateConstructorUsedError; + @JsonKey(name: 'account') + String get accountId => throw _privateConstructorUsedError; + bool get tfaRequired => throw _privateConstructorUsedError; + + /// Serializes this LoginInfo to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of LoginInfo + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $LoginInfoCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $LoginInfoCopyWith<$Res> { + factory $LoginInfoCopyWith(LoginInfo value, $Res Function(LoginInfo) then) = + _$LoginInfoCopyWithImpl<$Res, LoginInfo>; + @useResult + $Res call({ + String token, + @JsonKey(name: 'account') String accountId, + bool tfaRequired, + }); +} + +/// @nodoc +class _$LoginInfoCopyWithImpl<$Res, $Val extends LoginInfo> + implements $LoginInfoCopyWith<$Res> { + _$LoginInfoCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of LoginInfo + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? token = null, + Object? accountId = null, + Object? tfaRequired = null, + }) { + return _then( + _value.copyWith( + token: null == token + ? _value.token + : token // ignore: cast_nullable_to_non_nullable + as String, + accountId: null == accountId + ? _value.accountId + : accountId // ignore: cast_nullable_to_non_nullable + as String, + tfaRequired: null == tfaRequired + ? _value.tfaRequired + : tfaRequired // ignore: cast_nullable_to_non_nullable + as bool, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$LoginInfoImplCopyWith<$Res> + implements $LoginInfoCopyWith<$Res> { + factory _$$LoginInfoImplCopyWith( + _$LoginInfoImpl value, + $Res Function(_$LoginInfoImpl) then, + ) = __$$LoginInfoImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + String token, + @JsonKey(name: 'account') String accountId, + bool tfaRequired, + }); +} + +/// @nodoc +class __$$LoginInfoImplCopyWithImpl<$Res> + extends _$LoginInfoCopyWithImpl<$Res, _$LoginInfoImpl> + implements _$$LoginInfoImplCopyWith<$Res> { + __$$LoginInfoImplCopyWithImpl( + _$LoginInfoImpl _value, + $Res Function(_$LoginInfoImpl) _then, + ) : super(_value, _then); + + /// Create a copy of LoginInfo + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? token = null, + Object? accountId = null, + Object? tfaRequired = null, + }) { + return _then( + _$LoginInfoImpl( + token: null == token + ? _value.token + : token // ignore: cast_nullable_to_non_nullable + as String, + accountId: null == accountId + ? _value.accountId + : accountId // ignore: cast_nullable_to_non_nullable + as String, + tfaRequired: null == tfaRequired + ? _value.tfaRequired + : tfaRequired // ignore: cast_nullable_to_non_nullable + as bool, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$LoginInfoImpl implements _LoginInfo { + const _$LoginInfoImpl({ + required this.token, + @JsonKey(name: 'account') required this.accountId, + this.tfaRequired = false, + }); + + factory _$LoginInfoImpl.fromJson(Map json) => + _$$LoginInfoImplFromJson(json); + + @override + final String token; + @override + @JsonKey(name: 'account') + final String accountId; + @override + @JsonKey() + final bool tfaRequired; + + @override + String toString() { + return 'LoginInfo(token: $token, accountId: $accountId, tfaRequired: $tfaRequired)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$LoginInfoImpl && + (identical(other.token, token) || other.token == token) && + (identical(other.accountId, accountId) || + other.accountId == accountId) && + (identical(other.tfaRequired, tfaRequired) || + other.tfaRequired == tfaRequired)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, token, accountId, tfaRequired); + + /// Create a copy of LoginInfo + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$LoginInfoImplCopyWith<_$LoginInfoImpl> get copyWith => + __$$LoginInfoImplCopyWithImpl<_$LoginInfoImpl>(this, _$identity); + + @override + Map toJson() { + return _$$LoginInfoImplToJson(this); + } +} + +abstract class _LoginInfo implements LoginInfo { + const factory _LoginInfo({ + required final String token, + @JsonKey(name: 'account') required final String accountId, + final bool tfaRequired, + }) = _$LoginInfoImpl; + + factory _LoginInfo.fromJson(Map json) = + _$LoginInfoImpl.fromJson; + + @override + String get token; + @override + @JsonKey(name: 'account') + String get accountId; + @override + bool get tfaRequired; + + /// Create a copy of LoginInfo + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$LoginInfoImplCopyWith<_$LoginInfoImpl> get copyWith => + throw _privateConstructorUsedError; +} + +WorkspaceInfo _$WorkspaceInfoFromJson(Map json) { + return _WorkspaceInfo.fromJson(json); +} + +/// @nodoc +mixin _$WorkspaceInfo { + String get workspaceUrl => throw _privateConstructorUsedError; + String get workspaceName => throw _privateConstructorUsedError; + String? get workspaceId => throw _privateConstructorUsedError; + + /// Serializes this WorkspaceInfo to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of WorkspaceInfo + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $WorkspaceInfoCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $WorkspaceInfoCopyWith<$Res> { + factory $WorkspaceInfoCopyWith( + WorkspaceInfo value, + $Res Function(WorkspaceInfo) then, + ) = _$WorkspaceInfoCopyWithImpl<$Res, WorkspaceInfo>; + @useResult + $Res call({String workspaceUrl, String workspaceName, String? workspaceId}); +} + +/// @nodoc +class _$WorkspaceInfoCopyWithImpl<$Res, $Val extends WorkspaceInfo> + implements $WorkspaceInfoCopyWith<$Res> { + _$WorkspaceInfoCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of WorkspaceInfo + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? workspaceUrl = null, + Object? workspaceName = null, + Object? workspaceId = freezed, + }) { + return _then( + _value.copyWith( + workspaceUrl: null == workspaceUrl + ? _value.workspaceUrl + : workspaceUrl // ignore: cast_nullable_to_non_nullable + as String, + workspaceName: null == workspaceName + ? _value.workspaceName + : workspaceName // ignore: cast_nullable_to_non_nullable + as String, + workspaceId: freezed == workspaceId + ? _value.workspaceId + : workspaceId // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$WorkspaceInfoImplCopyWith<$Res> + implements $WorkspaceInfoCopyWith<$Res> { + factory _$$WorkspaceInfoImplCopyWith( + _$WorkspaceInfoImpl value, + $Res Function(_$WorkspaceInfoImpl) then, + ) = __$$WorkspaceInfoImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String workspaceUrl, String workspaceName, String? workspaceId}); +} + +/// @nodoc +class __$$WorkspaceInfoImplCopyWithImpl<$Res> + extends _$WorkspaceInfoCopyWithImpl<$Res, _$WorkspaceInfoImpl> + implements _$$WorkspaceInfoImplCopyWith<$Res> { + __$$WorkspaceInfoImplCopyWithImpl( + _$WorkspaceInfoImpl _value, + $Res Function(_$WorkspaceInfoImpl) _then, + ) : super(_value, _then); + + /// Create a copy of WorkspaceInfo + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? workspaceUrl = null, + Object? workspaceName = null, + Object? workspaceId = freezed, + }) { + return _then( + _$WorkspaceInfoImpl( + workspaceUrl: null == workspaceUrl + ? _value.workspaceUrl + : workspaceUrl // ignore: cast_nullable_to_non_nullable + as String, + workspaceName: null == workspaceName + ? _value.workspaceName + : workspaceName // ignore: cast_nullable_to_non_nullable + as String, + workspaceId: freezed == workspaceId + ? _value.workspaceId + : workspaceId // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$WorkspaceInfoImpl implements _WorkspaceInfo { + const _$WorkspaceInfoImpl({ + required this.workspaceUrl, + required this.workspaceName, + this.workspaceId, + }); + + factory _$WorkspaceInfoImpl.fromJson(Map json) => + _$$WorkspaceInfoImplFromJson(json); + + @override + final String workspaceUrl; + @override + final String workspaceName; + @override + final String? workspaceId; + + @override + String toString() { + return 'WorkspaceInfo(workspaceUrl: $workspaceUrl, workspaceName: $workspaceName, workspaceId: $workspaceId)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$WorkspaceInfoImpl && + (identical(other.workspaceUrl, workspaceUrl) || + other.workspaceUrl == workspaceUrl) && + (identical(other.workspaceName, workspaceName) || + other.workspaceName == workspaceName) && + (identical(other.workspaceId, workspaceId) || + other.workspaceId == workspaceId)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => + Object.hash(runtimeType, workspaceUrl, workspaceName, workspaceId); + + /// Create a copy of WorkspaceInfo + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$WorkspaceInfoImplCopyWith<_$WorkspaceInfoImpl> get copyWith => + __$$WorkspaceInfoImplCopyWithImpl<_$WorkspaceInfoImpl>(this, _$identity); + + @override + Map toJson() { + return _$$WorkspaceInfoImplToJson(this); + } +} + +abstract class _WorkspaceInfo implements WorkspaceInfo { + const factory _WorkspaceInfo({ + required final String workspaceUrl, + required final String workspaceName, + final String? workspaceId, + }) = _$WorkspaceInfoImpl; + + factory _WorkspaceInfo.fromJson(Map json) = + _$WorkspaceInfoImpl.fromJson; + + @override + String get workspaceUrl; + @override + String get workspaceName; + @override + String? get workspaceId; + + /// Create a copy of WorkspaceInfo + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$WorkspaceInfoImplCopyWith<_$WorkspaceInfoImpl> get copyWith => + throw _privateConstructorUsedError; +} + +WorkspaceLoginInfo _$WorkspaceLoginInfoFromJson(Map json) { + return _WorkspaceLoginInfo.fromJson(json); +} + +/// @nodoc +mixin _$WorkspaceLoginInfo { + String get token => throw _privateConstructorUsedError; + String get endpoint => throw _privateConstructorUsedError; + @JsonKey(name: 'workspace') + String get workspaceId => throw _privateConstructorUsedError; + String? get workspaceUrl => throw _privateConstructorUsedError; + String? get role => throw _privateConstructorUsedError; + + /// Serializes this WorkspaceLoginInfo to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of WorkspaceLoginInfo + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $WorkspaceLoginInfoCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $WorkspaceLoginInfoCopyWith<$Res> { + factory $WorkspaceLoginInfoCopyWith( + WorkspaceLoginInfo value, + $Res Function(WorkspaceLoginInfo) then, + ) = _$WorkspaceLoginInfoCopyWithImpl<$Res, WorkspaceLoginInfo>; + @useResult + $Res call({ + String token, + String endpoint, + @JsonKey(name: 'workspace') String workspaceId, + String? workspaceUrl, + String? role, + }); +} + +/// @nodoc +class _$WorkspaceLoginInfoCopyWithImpl<$Res, $Val extends WorkspaceLoginInfo> + implements $WorkspaceLoginInfoCopyWith<$Res> { + _$WorkspaceLoginInfoCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of WorkspaceLoginInfo + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? token = null, + Object? endpoint = null, + Object? workspaceId = null, + Object? workspaceUrl = freezed, + Object? role = freezed, + }) { + return _then( + _value.copyWith( + token: null == token + ? _value.token + : token // ignore: cast_nullable_to_non_nullable + as String, + endpoint: null == endpoint + ? _value.endpoint + : endpoint // ignore: cast_nullable_to_non_nullable + as String, + workspaceId: null == workspaceId + ? _value.workspaceId + : workspaceId // ignore: cast_nullable_to_non_nullable + as String, + workspaceUrl: freezed == workspaceUrl + ? _value.workspaceUrl + : workspaceUrl // ignore: cast_nullable_to_non_nullable + as String?, + role: freezed == role + ? _value.role + : role // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$WorkspaceLoginInfoImplCopyWith<$Res> + implements $WorkspaceLoginInfoCopyWith<$Res> { + factory _$$WorkspaceLoginInfoImplCopyWith( + _$WorkspaceLoginInfoImpl value, + $Res Function(_$WorkspaceLoginInfoImpl) then, + ) = __$$WorkspaceLoginInfoImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + String token, + String endpoint, + @JsonKey(name: 'workspace') String workspaceId, + String? workspaceUrl, + String? role, + }); +} + +/// @nodoc +class __$$WorkspaceLoginInfoImplCopyWithImpl<$Res> + extends _$WorkspaceLoginInfoCopyWithImpl<$Res, _$WorkspaceLoginInfoImpl> + implements _$$WorkspaceLoginInfoImplCopyWith<$Res> { + __$$WorkspaceLoginInfoImplCopyWithImpl( + _$WorkspaceLoginInfoImpl _value, + $Res Function(_$WorkspaceLoginInfoImpl) _then, + ) : super(_value, _then); + + /// Create a copy of WorkspaceLoginInfo + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? token = null, + Object? endpoint = null, + Object? workspaceId = null, + Object? workspaceUrl = freezed, + Object? role = freezed, + }) { + return _then( + _$WorkspaceLoginInfoImpl( + token: null == token + ? _value.token + : token // ignore: cast_nullable_to_non_nullable + as String, + endpoint: null == endpoint + ? _value.endpoint + : endpoint // ignore: cast_nullable_to_non_nullable + as String, + workspaceId: null == workspaceId + ? _value.workspaceId + : workspaceId // ignore: cast_nullable_to_non_nullable + as String, + workspaceUrl: freezed == workspaceUrl + ? _value.workspaceUrl + : workspaceUrl // ignore: cast_nullable_to_non_nullable + as String?, + role: freezed == role + ? _value.role + : role // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$WorkspaceLoginInfoImpl implements _WorkspaceLoginInfo { + const _$WorkspaceLoginInfoImpl({ + required this.token, + required this.endpoint, + @JsonKey(name: 'workspace') required this.workspaceId, + this.workspaceUrl, + this.role, + }); + + factory _$WorkspaceLoginInfoImpl.fromJson(Map json) => + _$$WorkspaceLoginInfoImplFromJson(json); + + @override + final String token; + @override + final String endpoint; + @override + @JsonKey(name: 'workspace') + final String workspaceId; + @override + final String? workspaceUrl; + @override + final String? role; + + @override + String toString() { + return 'WorkspaceLoginInfo(token: $token, endpoint: $endpoint, workspaceId: $workspaceId, workspaceUrl: $workspaceUrl, role: $role)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$WorkspaceLoginInfoImpl && + (identical(other.token, token) || other.token == token) && + (identical(other.endpoint, endpoint) || + other.endpoint == endpoint) && + (identical(other.workspaceId, workspaceId) || + other.workspaceId == workspaceId) && + (identical(other.workspaceUrl, workspaceUrl) || + other.workspaceUrl == workspaceUrl) && + (identical(other.role, role) || other.role == role)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + token, + endpoint, + workspaceId, + workspaceUrl, + role, + ); + + /// Create a copy of WorkspaceLoginInfo + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$WorkspaceLoginInfoImplCopyWith<_$WorkspaceLoginInfoImpl> get copyWith => + __$$WorkspaceLoginInfoImplCopyWithImpl<_$WorkspaceLoginInfoImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$WorkspaceLoginInfoImplToJson(this); + } +} + +abstract class _WorkspaceLoginInfo implements WorkspaceLoginInfo { + const factory _WorkspaceLoginInfo({ + required final String token, + required final String endpoint, + @JsonKey(name: 'workspace') required final String workspaceId, + final String? workspaceUrl, + final String? role, + }) = _$WorkspaceLoginInfoImpl; + + factory _WorkspaceLoginInfo.fromJson(Map json) = + _$WorkspaceLoginInfoImpl.fromJson; + + @override + String get token; + @override + String get endpoint; + @override + @JsonKey(name: 'workspace') + String get workspaceId; + @override + String? get workspaceUrl; + @override + String? get role; + + /// Create a copy of WorkspaceLoginInfo + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$WorkspaceLoginInfoImplCopyWith<_$WorkspaceLoginInfoImpl> get copyWith => + throw _privateConstructorUsedError; +} + +OtpInfo _$OtpInfoFromJson(Map json) { + return _OtpInfo.fromJson(json); +} + +/// @nodoc +mixin _$OtpInfo { + bool get sent => throw _privateConstructorUsedError; + + /// Serializes this OtpInfo to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of OtpInfo + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $OtpInfoCopyWith get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $OtpInfoCopyWith<$Res> { + factory $OtpInfoCopyWith(OtpInfo value, $Res Function(OtpInfo) then) = + _$OtpInfoCopyWithImpl<$Res, OtpInfo>; + @useResult + $Res call({bool sent}); +} + +/// @nodoc +class _$OtpInfoCopyWithImpl<$Res, $Val extends OtpInfo> + implements $OtpInfoCopyWith<$Res> { + _$OtpInfoCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of OtpInfo + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? sent = null}) { + return _then( + _value.copyWith( + sent: null == sent + ? _value.sent + : sent // ignore: cast_nullable_to_non_nullable + as bool, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$OtpInfoImplCopyWith<$Res> implements $OtpInfoCopyWith<$Res> { + factory _$$OtpInfoImplCopyWith( + _$OtpInfoImpl value, + $Res Function(_$OtpInfoImpl) then, + ) = __$$OtpInfoImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({bool sent}); +} + +/// @nodoc +class __$$OtpInfoImplCopyWithImpl<$Res> + extends _$OtpInfoCopyWithImpl<$Res, _$OtpInfoImpl> + implements _$$OtpInfoImplCopyWith<$Res> { + __$$OtpInfoImplCopyWithImpl( + _$OtpInfoImpl _value, + $Res Function(_$OtpInfoImpl) _then, + ) : super(_value, _then); + + /// Create a copy of OtpInfo + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? sent = null}) { + return _then( + _$OtpInfoImpl( + sent: null == sent + ? _value.sent + : sent // ignore: cast_nullable_to_non_nullable + as bool, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$OtpInfoImpl implements _OtpInfo { + const _$OtpInfoImpl({this.sent = true}); + + factory _$OtpInfoImpl.fromJson(Map json) => + _$$OtpInfoImplFromJson(json); + + @override + @JsonKey() + final bool sent; + + @override + String toString() { + return 'OtpInfo(sent: $sent)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$OtpInfoImpl && + (identical(other.sent, sent) || other.sent == sent)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, sent); + + /// Create a copy of OtpInfo + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$OtpInfoImplCopyWith<_$OtpInfoImpl> get copyWith => + __$$OtpInfoImplCopyWithImpl<_$OtpInfoImpl>(this, _$identity); + + @override + Map toJson() { + return _$$OtpInfoImplToJson(this); + } +} + +abstract class _OtpInfo implements OtpInfo { + const factory _OtpInfo({final bool sent}) = _$OtpInfoImpl; + + factory _OtpInfo.fromJson(Map json) = _$OtpInfoImpl.fromJson; + + @override + bool get sent; + + /// Create a copy of OtpInfo + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$OtpInfoImplCopyWith<_$OtpInfoImpl> get copyWith => + throw _privateConstructorUsedError; +} + +ProviderInfo _$ProviderInfoFromJson(Map json) { + return _ProviderInfo.fromJson(json); +} + +/// @nodoc +mixin _$ProviderInfo { + String get id => throw _privateConstructorUsedError; + String? get name => throw _privateConstructorUsedError; + String? get icon => throw _privateConstructorUsedError; + + /// Serializes this ProviderInfo to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of ProviderInfo + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ProviderInfoCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ProviderInfoCopyWith<$Res> { + factory $ProviderInfoCopyWith( + ProviderInfo value, + $Res Function(ProviderInfo) then, + ) = _$ProviderInfoCopyWithImpl<$Res, ProviderInfo>; + @useResult + $Res call({String id, String? name, String? icon}); +} + +/// @nodoc +class _$ProviderInfoCopyWithImpl<$Res, $Val extends ProviderInfo> + implements $ProviderInfoCopyWith<$Res> { + _$ProviderInfoCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ProviderInfo + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = freezed, + Object? icon = freezed, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + name: freezed == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String?, + icon: freezed == icon + ? _value.icon + : icon // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$ProviderInfoImplCopyWith<$Res> + implements $ProviderInfoCopyWith<$Res> { + factory _$$ProviderInfoImplCopyWith( + _$ProviderInfoImpl value, + $Res Function(_$ProviderInfoImpl) then, + ) = __$$ProviderInfoImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String id, String? name, String? icon}); +} + +/// @nodoc +class __$$ProviderInfoImplCopyWithImpl<$Res> + extends _$ProviderInfoCopyWithImpl<$Res, _$ProviderInfoImpl> + implements _$$ProviderInfoImplCopyWith<$Res> { + __$$ProviderInfoImplCopyWithImpl( + _$ProviderInfoImpl _value, + $Res Function(_$ProviderInfoImpl) _then, + ) : super(_value, _then); + + /// Create a copy of ProviderInfo + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = freezed, + Object? icon = freezed, + }) { + return _then( + _$ProviderInfoImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + name: freezed == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String?, + icon: freezed == icon + ? _value.icon + : icon // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$ProviderInfoImpl implements _ProviderInfo { + const _$ProviderInfoImpl({required this.id, this.name, this.icon}); + + factory _$ProviderInfoImpl.fromJson(Map json) => + _$$ProviderInfoImplFromJson(json); + + @override + final String id; + @override + final String? name; + @override + final String? icon; + + @override + String toString() { + return 'ProviderInfo(id: $id, name: $name, icon: $icon)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ProviderInfoImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.name, name) || other.name == name) && + (identical(other.icon, icon) || other.icon == icon)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, id, name, icon); + + /// Create a copy of ProviderInfo + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ProviderInfoImplCopyWith<_$ProviderInfoImpl> get copyWith => + __$$ProviderInfoImplCopyWithImpl<_$ProviderInfoImpl>(this, _$identity); + + @override + Map toJson() { + return _$$ProviderInfoImplToJson(this); + } +} + +abstract class _ProviderInfo implements ProviderInfo { + const factory _ProviderInfo({ + required final String id, + final String? name, + final String? icon, + }) = _$ProviderInfoImpl; + + factory _ProviderInfo.fromJson(Map json) = + _$ProviderInfoImpl.fromJson; + + @override + String get id; + @override + String? get name; + @override + String? get icon; + + /// Create a copy of ProviderInfo + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ProviderInfoImplCopyWith<_$ProviderInfoImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/mobile/lib/core/models/login_info.g.dart b/mobile/lib/core/models/login_info.g.dart new file mode 100644 index 00000000000..2ea27de1bb1 --- /dev/null +++ b/mobile/lib/core/models/login_info.g.dart @@ -0,0 +1,75 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'login_info.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$LoginInfoImpl _$$LoginInfoImplFromJson(Map json) => + _$LoginInfoImpl( + token: json['token'] as String, + accountId: json['account'] as String, + tfaRequired: json['tfaRequired'] as bool? ?? false, + ); + +Map _$$LoginInfoImplToJson(_$LoginInfoImpl instance) => + { + 'token': instance.token, + 'account': instance.accountId, + 'tfaRequired': instance.tfaRequired, + }; + +_$WorkspaceInfoImpl _$$WorkspaceInfoImplFromJson(Map json) => + _$WorkspaceInfoImpl( + workspaceUrl: json['workspaceUrl'] as String, + workspaceName: json['workspaceName'] as String, + workspaceId: json['workspaceId'] as String?, + ); + +Map _$$WorkspaceInfoImplToJson(_$WorkspaceInfoImpl instance) => + { + 'workspaceUrl': instance.workspaceUrl, + 'workspaceName': instance.workspaceName, + 'workspaceId': instance.workspaceId, + }; + +_$WorkspaceLoginInfoImpl _$$WorkspaceLoginInfoImplFromJson( + Map json, +) => _$WorkspaceLoginInfoImpl( + token: json['token'] as String, + endpoint: json['endpoint'] as String, + workspaceId: json['workspace'] as String, + workspaceUrl: json['workspaceUrl'] as String?, + role: json['role'] as String?, +); + +Map _$$WorkspaceLoginInfoImplToJson( + _$WorkspaceLoginInfoImpl instance, +) => { + 'token': instance.token, + 'endpoint': instance.endpoint, + 'workspace': instance.workspaceId, + 'workspaceUrl': instance.workspaceUrl, + 'role': instance.role, +}; + +_$OtpInfoImpl _$$OtpInfoImplFromJson(Map json) => + _$OtpInfoImpl(sent: json['sent'] as bool? ?? true); + +Map _$$OtpInfoImplToJson(_$OtpInfoImpl instance) => + {'sent': instance.sent}; + +_$ProviderInfoImpl _$$ProviderInfoImplFromJson(Map json) => + _$ProviderInfoImpl( + id: json['id'] as String, + name: json['name'] as String?, + icon: json['icon'] as String?, + ); + +Map _$$ProviderInfoImplToJson(_$ProviderInfoImpl instance) => + { + 'id': instance.id, + 'name': instance.name, + 'icon': instance.icon, + }; diff --git a/mobile/lib/core/models/member.dart b/mobile/lib/core/models/member.dart new file mode 100644 index 00000000000..9ef6b391fd4 --- /dev/null +++ b/mobile/lib/core/models/member.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'member.freezed.dart'; +part 'member.g.dart'; + +/// Represents a contact:class:Person from the platform. +@freezed +class Member with _$Member { + const factory Member({ + @JsonKey(name: '_id') required String id, + required String name, + String? avatar, + String? city, + String? personUuid, + }) = _Member; + + factory Member.fromJson(Map json) => + _$MemberFromJson(json); +} diff --git a/mobile/lib/core/models/member.freezed.dart b/mobile/lib/core/models/member.freezed.dart new file mode 100644 index 00000000000..13ff7e79cc9 --- /dev/null +++ b/mobile/lib/core/models/member.freezed.dart @@ -0,0 +1,259 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'member.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +Member _$MemberFromJson(Map json) { + return _Member.fromJson(json); +} + +/// @nodoc +mixin _$Member { + @JsonKey(name: '_id') + String get id => throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + String? get avatar => throw _privateConstructorUsedError; + String? get city => throw _privateConstructorUsedError; + String? get personUuid => throw _privateConstructorUsedError; + + /// Serializes this Member to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of Member + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $MemberCopyWith get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $MemberCopyWith<$Res> { + factory $MemberCopyWith(Member value, $Res Function(Member) then) = + _$MemberCopyWithImpl<$Res, Member>; + @useResult + $Res call({ + @JsonKey(name: '_id') String id, + String name, + String? avatar, + String? city, + String? personUuid, + }); +} + +/// @nodoc +class _$MemberCopyWithImpl<$Res, $Val extends Member> + implements $MemberCopyWith<$Res> { + _$MemberCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of Member + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = null, + Object? avatar = freezed, + Object? city = freezed, + Object? personUuid = freezed, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + avatar: freezed == avatar + ? _value.avatar + : avatar // ignore: cast_nullable_to_non_nullable + as String?, + city: freezed == city + ? _value.city + : city // ignore: cast_nullable_to_non_nullable + as String?, + personUuid: freezed == personUuid + ? _value.personUuid + : personUuid // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$MemberImplCopyWith<$Res> implements $MemberCopyWith<$Res> { + factory _$$MemberImplCopyWith( + _$MemberImpl value, + $Res Function(_$MemberImpl) then, + ) = __$$MemberImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + @JsonKey(name: '_id') String id, + String name, + String? avatar, + String? city, + String? personUuid, + }); +} + +/// @nodoc +class __$$MemberImplCopyWithImpl<$Res> + extends _$MemberCopyWithImpl<$Res, _$MemberImpl> + implements _$$MemberImplCopyWith<$Res> { + __$$MemberImplCopyWithImpl( + _$MemberImpl _value, + $Res Function(_$MemberImpl) _then, + ) : super(_value, _then); + + /// Create a copy of Member + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? name = null, + Object? avatar = freezed, + Object? city = freezed, + Object? personUuid = freezed, + }) { + return _then( + _$MemberImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + avatar: freezed == avatar + ? _value.avatar + : avatar // ignore: cast_nullable_to_non_nullable + as String?, + city: freezed == city + ? _value.city + : city // ignore: cast_nullable_to_non_nullable + as String?, + personUuid: freezed == personUuid + ? _value.personUuid + : personUuid // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$MemberImpl implements _Member { + const _$MemberImpl({ + @JsonKey(name: '_id') required this.id, + required this.name, + this.avatar, + this.city, + this.personUuid, + }); + + factory _$MemberImpl.fromJson(Map json) => + _$$MemberImplFromJson(json); + + @override + @JsonKey(name: '_id') + final String id; + @override + final String name; + @override + final String? avatar; + @override + final String? city; + @override + final String? personUuid; + + @override + String toString() { + return 'Member(id: $id, name: $name, avatar: $avatar, city: $city, personUuid: $personUuid)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$MemberImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.name, name) || other.name == name) && + (identical(other.avatar, avatar) || other.avatar == avatar) && + (identical(other.city, city) || other.city == city) && + (identical(other.personUuid, personUuid) || + other.personUuid == personUuid)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => + Object.hash(runtimeType, id, name, avatar, city, personUuid); + + /// Create a copy of Member + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$MemberImplCopyWith<_$MemberImpl> get copyWith => + __$$MemberImplCopyWithImpl<_$MemberImpl>(this, _$identity); + + @override + Map toJson() { + return _$$MemberImplToJson(this); + } +} + +abstract class _Member implements Member { + const factory _Member({ + @JsonKey(name: '_id') required final String id, + required final String name, + final String? avatar, + final String? city, + final String? personUuid, + }) = _$MemberImpl; + + factory _Member.fromJson(Map json) = _$MemberImpl.fromJson; + + @override + @JsonKey(name: '_id') + String get id; + @override + String get name; + @override + String? get avatar; + @override + String? get city; + @override + String? get personUuid; + + /// Create a copy of Member + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$MemberImplCopyWith<_$MemberImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/mobile/lib/core/models/member.g.dart b/mobile/lib/core/models/member.g.dart new file mode 100644 index 00000000000..bc6a095a705 --- /dev/null +++ b/mobile/lib/core/models/member.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'member.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$MemberImpl _$$MemberImplFromJson(Map json) => _$MemberImpl( + id: json['_id'] as String, + name: json['name'] as String, + avatar: json['avatar'] as String?, + city: json['city'] as String?, + personUuid: json['personUuid'] as String?, +); + +Map _$$MemberImplToJson(_$MemberImpl instance) => + { + '_id': instance.id, + 'name': instance.name, + 'avatar': instance.avatar, + 'city': instance.city, + 'personUuid': instance.personUuid, + }; diff --git a/mobile/lib/core/models/project.dart b/mobile/lib/core/models/project.dart new file mode 100644 index 00000000000..e67ce44c731 --- /dev/null +++ b/mobile/lib/core/models/project.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'project.freezed.dart'; +part 'project.g.dart'; + +@freezed +class Project with _$Project { + const factory Project({ + @JsonKey(name: '_id') required String id, + @JsonKey(name: '_class') required String className, + required String name, + required String identifier, + String? description, + String? defaultIssueStatus, + }) = _Project; + + factory Project.fromJson(Map json) => + _$ProjectFromJson(json); +} diff --git a/mobile/lib/core/models/project.freezed.dart b/mobile/lib/core/models/project.freezed.dart new file mode 100644 index 00000000000..b502c991565 --- /dev/null +++ b/mobile/lib/core/models/project.freezed.dart @@ -0,0 +1,292 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'project.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +Project _$ProjectFromJson(Map json) { + return _Project.fromJson(json); +} + +/// @nodoc +mixin _$Project { + @JsonKey(name: '_id') + String get id => throw _privateConstructorUsedError; + @JsonKey(name: '_class') + String get className => throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + String get identifier => throw _privateConstructorUsedError; + String? get description => throw _privateConstructorUsedError; + String? get defaultIssueStatus => throw _privateConstructorUsedError; + + /// Serializes this Project to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of Project + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ProjectCopyWith get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ProjectCopyWith<$Res> { + factory $ProjectCopyWith(Project value, $Res Function(Project) then) = + _$ProjectCopyWithImpl<$Res, Project>; + @useResult + $Res call({ + @JsonKey(name: '_id') String id, + @JsonKey(name: '_class') String className, + String name, + String identifier, + String? description, + String? defaultIssueStatus, + }); +} + +/// @nodoc +class _$ProjectCopyWithImpl<$Res, $Val extends Project> + implements $ProjectCopyWith<$Res> { + _$ProjectCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of Project + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? className = null, + Object? name = null, + Object? identifier = null, + Object? description = freezed, + Object? defaultIssueStatus = freezed, + }) { + return _then( + _value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + className: null == className + ? _value.className + : className // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + identifier: null == identifier + ? _value.identifier + : identifier // ignore: cast_nullable_to_non_nullable + as String, + description: freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + defaultIssueStatus: freezed == defaultIssueStatus + ? _value.defaultIssueStatus + : defaultIssueStatus // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$ProjectImplCopyWith<$Res> implements $ProjectCopyWith<$Res> { + factory _$$ProjectImplCopyWith( + _$ProjectImpl value, + $Res Function(_$ProjectImpl) then, + ) = __$$ProjectImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + @JsonKey(name: '_id') String id, + @JsonKey(name: '_class') String className, + String name, + String identifier, + String? description, + String? defaultIssueStatus, + }); +} + +/// @nodoc +class __$$ProjectImplCopyWithImpl<$Res> + extends _$ProjectCopyWithImpl<$Res, _$ProjectImpl> + implements _$$ProjectImplCopyWith<$Res> { + __$$ProjectImplCopyWithImpl( + _$ProjectImpl _value, + $Res Function(_$ProjectImpl) _then, + ) : super(_value, _then); + + /// Create a copy of Project + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? className = null, + Object? name = null, + Object? identifier = null, + Object? description = freezed, + Object? defaultIssueStatus = freezed, + }) { + return _then( + _$ProjectImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + className: null == className + ? _value.className + : className // ignore: cast_nullable_to_non_nullable + as String, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + identifier: null == identifier + ? _value.identifier + : identifier // ignore: cast_nullable_to_non_nullable + as String, + description: freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + defaultIssueStatus: freezed == defaultIssueStatus + ? _value.defaultIssueStatus + : defaultIssueStatus // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$ProjectImpl implements _Project { + const _$ProjectImpl({ + @JsonKey(name: '_id') required this.id, + @JsonKey(name: '_class') required this.className, + required this.name, + required this.identifier, + this.description, + this.defaultIssueStatus, + }); + + factory _$ProjectImpl.fromJson(Map json) => + _$$ProjectImplFromJson(json); + + @override + @JsonKey(name: '_id') + final String id; + @override + @JsonKey(name: '_class') + final String className; + @override + final String name; + @override + final String identifier; + @override + final String? description; + @override + final String? defaultIssueStatus; + + @override + String toString() { + return 'Project(id: $id, className: $className, name: $name, identifier: $identifier, description: $description, defaultIssueStatus: $defaultIssueStatus)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ProjectImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.className, className) || + other.className == className) && + (identical(other.name, name) || other.name == name) && + (identical(other.identifier, identifier) || + other.identifier == identifier) && + (identical(other.description, description) || + other.description == description) && + (identical(other.defaultIssueStatus, defaultIssueStatus) || + other.defaultIssueStatus == defaultIssueStatus)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + className, + name, + identifier, + description, + defaultIssueStatus, + ); + + /// Create a copy of Project + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ProjectImplCopyWith<_$ProjectImpl> get copyWith => + __$$ProjectImplCopyWithImpl<_$ProjectImpl>(this, _$identity); + + @override + Map toJson() { + return _$$ProjectImplToJson(this); + } +} + +abstract class _Project implements Project { + const factory _Project({ + @JsonKey(name: '_id') required final String id, + @JsonKey(name: '_class') required final String className, + required final String name, + required final String identifier, + final String? description, + final String? defaultIssueStatus, + }) = _$ProjectImpl; + + factory _Project.fromJson(Map json) = _$ProjectImpl.fromJson; + + @override + @JsonKey(name: '_id') + String get id; + @override + @JsonKey(name: '_class') + String get className; + @override + String get name; + @override + String get identifier; + @override + String? get description; + @override + String? get defaultIssueStatus; + + /// Create a copy of Project + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ProjectImplCopyWith<_$ProjectImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/mobile/lib/core/models/project.g.dart b/mobile/lib/core/models/project.g.dart new file mode 100644 index 00000000000..0782ea0d51e --- /dev/null +++ b/mobile/lib/core/models/project.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'project.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$ProjectImpl _$$ProjectImplFromJson(Map json) => + _$ProjectImpl( + id: json['_id'] as String, + className: json['_class'] as String, + name: json['name'] as String, + identifier: json['identifier'] as String, + description: json['description'] as String?, + defaultIssueStatus: json['defaultIssueStatus'] as String?, + ); + +Map _$$ProjectImplToJson(_$ProjectImpl instance) => + { + '_id': instance.id, + '_class': instance.className, + 'name': instance.name, + 'identifier': instance.identifier, + 'description': instance.description, + 'defaultIssueStatus': instance.defaultIssueStatus, + }; diff --git a/mobile/lib/core/models/tx.dart b/mobile/lib/core/models/tx.dart new file mode 100644 index 00000000000..b2e5fd2ac09 --- /dev/null +++ b/mobile/lib/core/models/tx.dart @@ -0,0 +1,102 @@ +import 'package:uuid/uuid.dart'; + +const _uuid = Uuid(); + +/// Builds a TxUpdateDoc transaction for the Huly REST API. +Map buildUpdateIssueTx({ + required String issueId, + required String space, + required Map operations, + String? modifiedBy, +}) { + return { + '_class': 'core:class:TxUpdateDoc', + 'objectId': issueId, + 'objectClass': 'tracker:class:Issue', + 'objectSpace': space, + 'operations': operations, + if (modifiedBy != null) 'modifiedBy': modifiedBy, + }; +} + +/// Builds a TxCreateDoc transaction for an Attachment. +Map buildCreateAttachmentTx({ + required String attachedTo, + required String attachedToClass, + required String space, + required String name, + required String blobId, + required int size, + required String contentType, +}) { + final attachmentId = 'attachment:doc:${_uuid.v4()}'; + + return { + '_class': 'core:class:TxCreateDoc', + 'objectId': attachmentId, + 'objectClass': 'attachment:class:Attachment', + 'objectSpace': space, + 'attributes': { + 'attachedTo': attachedTo, + 'attachedToClass': attachedToClass, + 'collection': 'attachments', + 'name': name, + 'file': blobId, + 'size': size, + 'type': contentType, + 'lastModified': DateTime.now().millisecondsSinceEpoch, + }, + }; +} + +/// Builds a TxCreateDoc transaction for a ChatMessage. +Map buildCreateChatMessageTx({ + required String channelId, + required String message, + String? modifiedBy, +}) { + final msgId = 'chunter:msg:${_uuid.v4()}'; + + return { + '_class': 'core:class:TxCreateDoc', + 'objectId': msgId, + 'objectClass': 'chunter:class:ChatMessage', + 'objectSpace': channelId, + 'attributes': { + 'attachedTo': channelId, + 'attachedToClass': 'chunter:class:Channel', + 'collection': 'messages', + 'message': message, + }, + if (modifiedBy != null) 'modifiedBy': modifiedBy, + }; +} + +/// Builds a TxCreateDoc transaction for the Huly REST API. +Map buildCreateIssueTx({ + required String space, + required String title, + required String status, + required int priority, + String? description, + String? assignee, + String? modifiedBy, +}) { + final issueId = 'tracker:issue:${_uuid.v4()}'; + + return { + '_class': 'core:class:TxCreateDoc', + 'objectId': issueId, + 'objectClass': 'tracker:class:Issue', + 'objectSpace': space, + 'attributes': { + 'title': title, + 'description': description ?? '

', + 'status': status, + 'priority': priority, + 'kind': 'tracker:taskType:Issue', + if (assignee != null) 'assignee': assignee, + }, + if (modifiedBy != null) 'modifiedBy': modifiedBy, + }; +} diff --git a/mobile/lib/core/storage/secure_storage.dart b/mobile/lib/core/storage/secure_storage.dart new file mode 100644 index 00000000000..b7afdcc3fe2 --- /dev/null +++ b/mobile/lib/core/storage/secure_storage.dart @@ -0,0 +1,62 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Persists authentication tokens and server URL securely. +class SecureStorageService { + static const _keyServerUrl = 'server_url'; + static const _keyAccountsUrl = 'accounts_url'; + static const _keyToken = 'token'; + static const _keyWorkspaceId = 'workspace_id'; + static const _keyWorkspaceToken = 'workspace_token'; + static const _keyWorkspaceEndpoint = 'workspace_endpoint'; + static const _keyWorkspaceUrl = 'workspace_url'; + + final FlutterSecureStorage _storage; + + SecureStorageService({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + Future saveServerUrl(String url) => + _storage.write(key: _keyServerUrl, value: url); + Future getServerUrl() => _storage.read(key: _keyServerUrl); + + Future saveAccountsUrl(String url) => + _storage.write(key: _keyAccountsUrl, value: url); + Future getAccountsUrl() => _storage.read(key: _keyAccountsUrl); + + Future saveToken(String token) => + _storage.write(key: _keyToken, value: token); + Future getToken() => _storage.read(key: _keyToken); + + Future saveWorkspaceSession({ + required String workspaceId, + required String token, + required String endpoint, + String? workspaceUrl, + }) async { + await _storage.write(key: _keyWorkspaceId, value: workspaceId); + await _storage.write(key: _keyWorkspaceToken, value: token); + await _storage.write(key: _keyWorkspaceEndpoint, value: endpoint); + if (workspaceUrl != null) { + await _storage.write(key: _keyWorkspaceUrl, value: workspaceUrl); + } + } + + Future<({String id, String token, String endpoint, String? url})?> + getWorkspaceSession() async { + final id = await _storage.read(key: _keyWorkspaceId); + final token = await _storage.read(key: _keyWorkspaceToken); + final endpoint = await _storage.read(key: _keyWorkspaceEndpoint); + if (id == null || token == null || endpoint == null) return null; + final url = await _storage.read(key: _keyWorkspaceUrl); + return (id: id, token: token, endpoint: endpoint, url: url); + } + + static const _keyBiometricEnabled = 'biometric_enabled'; + + Future setBiometricEnabled(bool enabled) => + _storage.write(key: _keyBiometricEnabled, value: enabled.toString()); + Future isBiometricEnabled() async => + (await _storage.read(key: _keyBiometricEnabled)) == 'true'; + + Future clearAll() => _storage.deleteAll(); +} diff --git a/mobile/lib/core/theme/huly_theme.dart b/mobile/lib/core/theme/huly_theme.dart new file mode 100644 index 00000000000..19acce59864 --- /dev/null +++ b/mobile/lib/core/theme/huly_theme.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; + +/// Huly design tokens extracted from packages/theme/styles/_colors.scss .theme-dark +class HulyColors { + HulyColors._(); + + // Backgrounds + static const background = Color(0xFF1A1A28); + static const deepBackground = Color(0xFF0F0F18); + static const navPanel = Color(0xFF14141F); + static const header = Color(0xFF1F1F2C); + static const listRow = Color(0xFF21212F); + static const inputFill = Color(0xFF262634); + + // Primary / accent + static const primaryButton = Color(0xFF205DC2); + static const primaryHover = Color(0xFF3575DE); + static const accent = Color(0xFF377AE6); + + // Semantic + static const positive = Color(0xFF05A05C); + static const negative = Color(0xFFCB4B42); + static const errorText = Color(0xFFEE7A7A); + + // Text + static const contentText = Color(0xCCFFFFFF); // white 80% + static const darkText = Color(0x99FFFFFF); // white 60% + static const darkerText = Color(0x66FFFFFF); // white 40% + + // Divider + static const divider = Color(0x0FFFFFFF); // white 6% + + // Priority + static const priorityUrgent = Color(0xFFCB4B42); + static const priorityHigh = Color(0xFFF47758); + static const priorityMedium = Color(0xFFFCC500); + static const priorityLow = Color(0xFF377AE6); + static const priorityNone = Color(0x66FFFFFF); + + static Color priorityColor(int priority) { + switch (priority) { + case 1: + return priorityUrgent; + case 2: + return priorityHigh; + case 3: + return priorityMedium; + case 4: + return priorityLow; + default: + return priorityNone; + } + } +} + +final hulyDarkTheme = ThemeData.dark().copyWith( + scaffoldBackgroundColor: HulyColors.background, + colorScheme: const ColorScheme.dark( + primary: HulyColors.primaryButton, + secondary: HulyColors.accent, + surface: HulyColors.header, + error: HulyColors.negative, + ), + appBarTheme: const AppBarTheme( + backgroundColor: HulyColors.header, + foregroundColor: HulyColors.contentText, + elevation: 0, + ), + tabBarTheme: const TabBarThemeData( + labelColor: Colors.white, + unselectedLabelColor: HulyColors.darkerText, + indicatorColor: HulyColors.accent, + ), + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: HulyColors.primaryButton, + foregroundColor: Colors.white, + ), + dividerTheme: const DividerThemeData( + color: HulyColors.divider, + thickness: 1, + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: HulyColors.accent, + ), +); + +InputDecoration hulyInputDecoration(String label, [String? hint]) { + return InputDecoration( + labelText: label, + hintText: hint, + labelStyle: const TextStyle(color: HulyColors.darkText, fontSize: 14), + hintStyle: const TextStyle(color: HulyColors.darkerText, fontSize: 14), + filled: true, + fillColor: HulyColors.inputFill, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: HulyColors.accent, width: 1), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ); +} + +ButtonStyle hulyPrimaryButtonStyle() { + return FilledButton.styleFrom( + backgroundColor: HulyColors.primaryButton, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500), + ); +} + +ButtonStyle hulyGhostButtonStyle() { + return TextButton.styleFrom( + foregroundColor: HulyColors.darkText, + padding: const EdgeInsets.symmetric(vertical: 12), + textStyle: const TextStyle(fontSize: 14), + ); +} diff --git a/mobile/lib/core/utils/html.dart b/mobile/lib/core/utils/html.dart new file mode 100644 index 00000000000..8ef06e359b6 --- /dev/null +++ b/mobile/lib/core/utils/html.dart @@ -0,0 +1,19 @@ +/// Escape HTML special characters for safe embedding in markup. +String escapeHtml(String text) { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} + +/// Strip HTML tags and trim whitespace. +String stripHtml(String html) { + return html.replaceAll(RegExp(r'<[^>]*>'), '').trim(); +} + +/// Format a millisecond timestamp to a short date/time string. +String formatTimestamp(int? timestamp) { + if (timestamp == null) return ''; + final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); + return '${dt.month}/${dt.day} ${dt.hour}:${dt.minute.toString().padLeft(2, '0')}'; +} diff --git a/mobile/lib/core/widgets/huly_button.dart b/mobile/lib/core/widgets/huly_button.dart new file mode 100644 index 00000000000..4f3d8a923b6 --- /dev/null +++ b/mobile/lib/core/widgets/huly_button.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import '../theme/huly_theme.dart'; + +enum HulyButtonKind { primary, ghost, positive, negative } + +class HulyButton extends StatelessWidget { + final String label; + final VoidCallback? onPressed; + final HulyButtonKind kind; + final bool loading; + final IconData? icon; + + const HulyButton({ + super.key, + required this.label, + this.onPressed, + this.kind = HulyButtonKind.primary, + this.loading = false, + this.icon, + }); + + @override + Widget build(BuildContext context) { + final child = loading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : icon != null + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 20), + const SizedBox(width: 8), + Text(label), + ], + ) + : Text(label); + + switch (kind) { + case HulyButtonKind.primary: + return FilledButton( + onPressed: loading ? null : onPressed, + style: hulyPrimaryButtonStyle(), + child: child, + ); + case HulyButtonKind.ghost: + return TextButton( + onPressed: loading ? null : onPressed, + style: hulyGhostButtonStyle(), + child: child, + ); + case HulyButtonKind.positive: + return FilledButton( + onPressed: loading ? null : onPressed, + style: hulyPrimaryButtonStyle().copyWith( + backgroundColor: WidgetStatePropertyAll(HulyColors.positive), + ), + child: child, + ); + case HulyButtonKind.negative: + return FilledButton( + onPressed: loading ? null : onPressed, + style: hulyPrimaryButtonStyle().copyWith( + backgroundColor: WidgetStatePropertyAll(HulyColors.negative), + ), + child: child, + ); + } + } +} diff --git a/mobile/lib/core/widgets/huly_chip.dart b/mobile/lib/core/widgets/huly_chip.dart new file mode 100644 index 00000000000..d001477ce1e --- /dev/null +++ b/mobile/lib/core/widgets/huly_chip.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import '../theme/huly_theme.dart'; + +class HulyChip extends StatelessWidget { + final String label; + final Color? color; + final IconData? icon; + + const HulyChip({ + super.key, + required this.label, + this.color, + this.icon, + }); + + @override + Widget build(BuildContext context) { + final chipColor = color ?? HulyColors.darkerText; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: chipColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 14, color: chipColor), + const SizedBox(width: 4), + ], + Text( + label, + style: TextStyle( + color: chipColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/core/widgets/huly_input.dart b/mobile/lib/core/widgets/huly_input.dart new file mode 100644 index 00000000000..2f55b6b857b --- /dev/null +++ b/mobile/lib/core/widgets/huly_input.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../theme/huly_theme.dart'; + +class HulyInput extends StatelessWidget { + final TextEditingController? controller; + final String label; + final String? hint; + final bool obscureText; + final TextInputType? keyboardType; + final bool autocorrect; + final TextAlign textAlign; + final TextStyle? style; + final int? maxLines; + final int? minLines; + final bool autofocus; + final List? inputFormatters; + final String? Function(String?)? validator; + final void Function(String)? onFieldSubmitted; + + const HulyInput({ + super.key, + this.controller, + required this.label, + this.hint, + this.obscureText = false, + this.keyboardType, + this.autocorrect = true, + this.textAlign = TextAlign.start, + this.style, + this.maxLines = 1, + this.minLines, + this.autofocus = false, + this.inputFormatters, + this.validator, + this.onFieldSubmitted, + }); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + decoration: hulyInputDecoration(label, hint), + obscureText: obscureText, + keyboardType: keyboardType, + autocorrect: autocorrect, + textAlign: textAlign, + style: style ?? const TextStyle(color: HulyColors.contentText), + maxLines: maxLines, + minLines: minLines, + autofocus: autofocus, + inputFormatters: inputFormatters, + validator: validator, + onFieldSubmitted: onFieldSubmitted, + ); + } +} diff --git a/mobile/lib/core/widgets/message_bubble.dart b/mobile/lib/core/widgets/message_bubble.dart new file mode 100644 index 00000000000..e69165eb93c --- /dev/null +++ b/mobile/lib/core/widgets/message_bubble.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import '../theme/huly_theme.dart'; +import '../utils/html.dart'; + +/// Shared message bubble used in activity feeds and chat threads. +class MessageBubble extends StatelessWidget { + final String authorName; + final String messageHtml; + final int? timestamp; + final double avatarRadius; + + const MessageBubble({ + super.key, + required this.authorName, + required this.messageHtml, + this.timestamp, + this.avatarRadius = 14, + }); + + @override + Widget build(BuildContext context) { + final text = stripHtml(messageHtml); + final initial = + authorName.isNotEmpty ? authorName[0].toUpperCase() : '?'; + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: avatarRadius, + backgroundColor: HulyColors.inputFill, + child: Text( + initial, + style: TextStyle( + color: HulyColors.contentText, + fontSize: avatarRadius - 2, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + authorName, + style: const TextStyle( + color: HulyColors.contentText, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + const SizedBox(width: 8), + Text( + formatTimestamp(timestamp), + style: const TextStyle( + color: HulyColors.darkerText, + fontSize: 11, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + text, + style: const TextStyle( + color: HulyColors.contentText, + fontSize: 13, + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/core/widgets/priority_chip.dart b/mobile/lib/core/widgets/priority_chip.dart new file mode 100644 index 00000000000..7777aa43334 --- /dev/null +++ b/mobile/lib/core/widgets/priority_chip.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import '../theme/huly_theme.dart'; +import 'priority_icon.dart'; + +class PriorityChip extends StatelessWidget { + final int priority; + final bool selected; + final VoidCallback onTap; + + const PriorityChip({ + super.key, + required this.priority, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final color = HulyColors.priorityColor(priority); + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: + selected ? color.withValues(alpha: 0.2) : HulyColors.inputFill, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: selected ? color : Colors.transparent, + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + PriorityIcon(priority: priority, size: 16), + const SizedBox(width: 6), + Text( + PriorityIcon.label(priority), + style: TextStyle( + color: selected ? color : HulyColors.darkText, + fontSize: 13, + fontWeight: selected ? FontWeight.w500 : FontWeight.normal, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/core/widgets/priority_icon.dart b/mobile/lib/core/widgets/priority_icon.dart new file mode 100644 index 00000000000..750f547f1f9 --- /dev/null +++ b/mobile/lib/core/widgets/priority_icon.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import '../theme/huly_theme.dart'; + +class PriorityIcon extends StatelessWidget { + final int priority; + final double size; + + const PriorityIcon({super.key, required this.priority, this.size = 18}); + + @override + Widget build(BuildContext context) { + return Icon(_icon, color: HulyColors.priorityColor(priority), size: size); + } + + IconData get _icon { + switch (priority) { + case 1: + return Icons.keyboard_double_arrow_up; + case 2: + return Icons.keyboard_arrow_up; + case 3: + return Icons.remove; + case 4: + return Icons.keyboard_arrow_down; + default: + return Icons.more_horiz; + } + } + + static String label(int priority) { + switch (priority) { + case 1: + return 'Urgent'; + case 2: + return 'High'; + case 3: + return 'Medium'; + case 4: + return 'Low'; + default: + return 'No priority'; + } + } +} diff --git a/mobile/lib/features/auth/auth_provider.dart b/mobile/lib/features/auth/auth_provider.dart new file mode 100644 index 00000000000..6a7fc5e8f66 --- /dev/null +++ b/mobile/lib/features/auth/auth_provider.dart @@ -0,0 +1,344 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:local_auth/local_auth.dart'; +import '../../core/api/account_client.dart'; +import '../../core/api/rest_client.dart'; +import '../../core/models/login_info.dart'; +import '../../core/storage/secure_storage.dart'; + +/// Auth state: unauthenticated → (otpPending | tfaPending) → loggedIn → workspaceSelected. +/// locked = workspace is selected but biometric auth required. +enum AuthStatus { unauthenticated, otpPending, tfaPending, loggedIn, workspaceSelected, locked } + +class AuthState { + final AuthStatus status; + final String? serverUrl; + final String? accountsUrl; + final LoginInfo? loginInfo; + final List? workspaces; + final WorkspaceLoginInfo? workspaceLogin; + final List? providers; + final String? otpEmail; + final String? error; + final bool loading; + final bool biometricEnabled; + + const AuthState({ + this.status = AuthStatus.unauthenticated, + this.serverUrl, + this.accountsUrl, + this.loginInfo, + this.workspaces, + this.workspaceLogin, + this.providers, + this.otpEmail, + this.error, + this.loading = false, + this.biometricEnabled = false, + }); + + AuthState copyWith({ + AuthStatus? status, + String? serverUrl, + String? accountsUrl, + LoginInfo? loginInfo, + List? workspaces, + WorkspaceLoginInfo? workspaceLogin, + List? providers, + String? otpEmail, + String? error, + bool? loading, + bool? biometricEnabled, + }) { + return AuthState( + status: status ?? this.status, + serverUrl: serverUrl ?? this.serverUrl, + accountsUrl: accountsUrl ?? this.accountsUrl, + loginInfo: loginInfo ?? this.loginInfo, + workspaces: workspaces ?? this.workspaces, + workspaceLogin: workspaceLogin ?? this.workspaceLogin, + providers: providers ?? this.providers, + otpEmail: otpEmail ?? this.otpEmail, + error: error, + loading: loading ?? this.loading, + biometricEnabled: biometricEnabled ?? this.biometricEnabled, + ); + } +} + +class AuthNotifier extends Notifier { + late final SecureStorageService _storage; + + @override + AuthState build() { + _storage = SecureStorageService(); + // Try to restore session on next microtask. + Future.microtask(() => restoreSession()); + return const AuthState(); + } + + AccountClient? _accountClient; + + AccountClient get accountClient { + if (_accountClient == null) { + throw StateError('Account client not initialized — set server URL first'); + } + return _accountClient!; + } + + /// Validate server URL by fetching config.json and extracting ACCOUNTS_URL. + Future setServerUrl(String url) async { + state = state.copyWith(loading: true, error: null); + try { + final accountsUrl = await AccountClient.getAccountsUrl(url); + _accountClient = AccountClient(accountsUrl: accountsUrl); + await _storage.saveServerUrl(url); + await _storage.saveAccountsUrl(accountsUrl); + + // Fetch available OAuth providers. + final providers = await _accountClient!.getProviders(); + + state = state.copyWith( + serverUrl: url, + accountsUrl: accountsUrl, + providers: providers, + status: AuthStatus.unauthenticated, + loading: false, + ); + } catch (e) { + state = state.copyWith( + loading: false, + error: 'Could not connect to server: $e', + ); + } + } + + /// Login with email/password. + Future login(String email, String password) async { + state = state.copyWith(loading: true, error: null); + try { + final loginInfo = await accountClient.login(email, password); + if (loginInfo.tfaRequired) { + state = state.copyWith( + status: AuthStatus.tfaPending, + loginInfo: loginInfo, + loading: false, + ); + return; + } + await _storage.saveToken(loginInfo.token); + + final workspaces = await accountClient.getUserWorkspaces(); + state = state.copyWith( + status: AuthStatus.loggedIn, + loginInfo: loginInfo, + workspaces: workspaces, + loading: false, + ); + } on AccountRpcError catch (e) { + state = state.copyWith(loading: false, error: e.message); + } catch (e) { + state = state.copyWith(loading: false, error: 'Login failed: $e'); + } + } + + /// Request OTP code for the given email. + Future requestOtp(String email) async { + state = state.copyWith(loading: true, error: null); + try { + await accountClient.loginOtp(email); + state = state.copyWith( + status: AuthStatus.otpPending, + otpEmail: email, + loading: false, + ); + } on AccountRpcError catch (e) { + state = state.copyWith(loading: false, error: e.message); + } catch (e) { + state = state.copyWith(loading: false, error: 'Failed to send code: $e'); + } + } + + /// Validate OTP code and complete login. + Future validateOtp(String code) async { + final email = state.otpEmail; + if (email == null) return; + state = state.copyWith(loading: true, error: null); + try { + final loginInfo = await accountClient.validateOtp(email, code); + await _storage.saveToken(loginInfo.token); + + final workspaces = await accountClient.getUserWorkspaces(); + state = state.copyWith( + status: AuthStatus.loggedIn, + loginInfo: loginInfo, + workspaces: workspaces, + loading: false, + ); + } on AccountRpcError catch (e) { + state = state.copyWith(loading: false, error: e.message); + } catch (e) { + state = state.copyWith(loading: false, error: 'Invalid code: $e'); + } + } + + /// Verify a 2FA/TOTP code to complete login. + Future verify2fa(String code) async { + final partialToken = state.loginInfo?.token; + if (partialToken == null) return; + state = state.copyWith(loading: true, error: null); + try { + final loginInfo = await accountClient.verify2fa(partialToken, code); + await _storage.saveToken(loginInfo.token); + + final workspaces = await accountClient.getUserWorkspaces(); + state = state.copyWith( + status: AuthStatus.loggedIn, + loginInfo: loginInfo, + workspaces: workspaces, + loading: false, + ); + } on AccountRpcError catch (e) { + state = state.copyWith(loading: false, error: e.message); + } catch (e) { + state = state.copyWith(loading: false, error: 'Invalid 2FA code: $e'); + } + } + + /// Handle token received from OAuth flow (Google, etc.) + Future loginWithToken(String token) async { + state = state.copyWith(loading: true, error: null); + try { + _accountClient!.setToken(token); + await _storage.saveToken(token); + + final workspaces = await accountClient.getUserWorkspaces(); + state = state.copyWith( + status: AuthStatus.loggedIn, + workspaces: workspaces, + loading: false, + ); + } catch (e) { + state = state.copyWith(loading: false, error: 'OAuth login failed: $e'); + } + } + + /// Build an OAuth URL for in-app browser login. + /// The providers endpoint is on the accounts URL (e.g. /_accounts/auth/google). + String? getOAuthUrl(String provider) { + final accountsUrl = state.accountsUrl; + if (accountsUrl == null) return null; + final base = accountsUrl.endsWith('/') ? accountsUrl : '$accountsUrl/'; + return '${base}auth/$provider'; + } + + /// Select a workspace. + Future selectWorkspace(String workspaceUrl) async { + state = state.copyWith(loading: true, error: null); + try { + final wsLogin = await accountClient.selectWorkspace(workspaceUrl); + await _storage.saveWorkspaceSession( + workspaceId: wsLogin.workspaceId, + token: wsLogin.token, + endpoint: wsLogin.endpoint, + workspaceUrl: workspaceUrl, + ); + state = state.copyWith( + status: AuthStatus.workspaceSelected, + workspaceLogin: wsLogin, + loading: false, + ); + } catch (e) { + state = state.copyWith(loading: false, error: 'Workspace error: $e'); + } + } + + /// Restore session from secure storage. + Future restoreSession() async { + final serverUrl = await _storage.getServerUrl(); + final accountsUrl = await _storage.getAccountsUrl(); + if (serverUrl == null || accountsUrl == null) return; + + _accountClient = AccountClient(accountsUrl: accountsUrl); + + final token = await _storage.getToken(); + if (token == null) { + state = state.copyWith(serverUrl: serverUrl, accountsUrl: accountsUrl); + return; + } + _accountClient!.setToken(token); + + final ws = await _storage.getWorkspaceSession(); + if (ws != null) { + final biometricEnabled = await _storage.isBiometricEnabled(); + state = state.copyWith( + serverUrl: serverUrl, + accountsUrl: accountsUrl, + status: biometricEnabled + ? AuthStatus.locked + : AuthStatus.workspaceSelected, + biometricEnabled: biometricEnabled, + workspaceLogin: WorkspaceLoginInfo( + token: ws.token, + endpoint: ws.endpoint, + workspaceId: ws.id, + workspaceUrl: ws.url, + ), + ); + } else { + state = state.copyWith( + serverUrl: serverUrl, + accountsUrl: accountsUrl, + status: AuthStatus.unauthenticated, + ); + } + } + + /// Toggle biometric lock. + Future setBiometricEnabled(bool enabled) async { + await _storage.setBiometricEnabled(enabled); + state = state.copyWith(biometricEnabled: enabled); + } + + /// Authenticate with biometrics to unlock the app. + Future unlockWithBiometrics() async { + final localAuth = LocalAuthentication(); + try { + final didAuthenticate = await localAuth.authenticate( + localizedReason: 'Authenticate to access Huly', + options: const AuthenticationOptions(biometricOnly: true), + ); + if (didAuthenticate) { + state = state.copyWith(status: AuthStatus.workspaceSelected); + } + } catch (e) { + state = state.copyWith(error: 'Biometric auth failed: $e'); + } + } + + /// Check if biometrics are available on this device. + Future canUseBiometrics() async { + final localAuth = LocalAuthentication(); + return await localAuth.canCheckBiometrics || await localAuth.isDeviceSupported(); + } + + /// Logout and clear stored credentials. + Future logout() async { + await _storage.clearAll(); + _accountClient = null; + state = const AuthState(); + } +} + +final authProvider = NotifierProvider(AuthNotifier.new); + +/// Provides a configured REST client when a workspace is selected. +final restClientProvider = Provider((ref) { + final auth = ref.watch(authProvider); + final ws = auth.workspaceLogin; + if (ws == null) return null; + return HulyRestClient( + endpoint: ws.endpoint, + workspaceId: ws.workspaceId, + token: ws.token, + ); +}); diff --git a/mobile/lib/features/auth/lock_screen.dart b/mobile/lib/features/auth/lock_screen.dart new file mode 100644 index 00000000000..c13e3bbb96c --- /dev/null +++ b/mobile/lib/features/auth/lock_screen.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/theme/huly_theme.dart'; +import '../../core/widgets/huly_button.dart'; +import 'auth_provider.dart'; + +class LockScreen extends ConsumerStatefulWidget { + const LockScreen({super.key}); + + @override + ConsumerState createState() => _LockScreenState(); +} + +class _LockScreenState extends ConsumerState { + @override + void initState() { + super.initState(); + // Auto-prompt biometrics on screen load. + Future.microtask( + () => ref.read(authProvider.notifier).unlockWithBiometrics()); + } + + @override + Widget build(BuildContext context) { + final auth = ref.watch(authProvider); + + return Scaffold( + backgroundColor: HulyColors.background, + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.lock_outline, + color: HulyColors.accent, size: 64), + const SizedBox(height: 24), + Text( + 'Huly is locked', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + const Text( + 'Authenticate to continue', + style: TextStyle(color: HulyColors.darkerText, fontSize: 14), + ), + const SizedBox(height: 32), + if (auth.error != null) ...[ + Text(auth.error!, + style: const TextStyle(color: HulyColors.errorText)), + const SizedBox(height: 16), + ], + HulyButton( + label: 'Unlock', + onPressed: () => + ref.read(authProvider.notifier).unlockWithBiometrics(), + ), + const SizedBox(height: 16), + HulyButton( + label: 'Sign out', + kind: HulyButtonKind.ghost, + onPressed: () => ref.read(authProvider.notifier).logout(), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/auth/login_screen.dart b/mobile/lib/features/auth/login_screen.dart new file mode 100644 index 00000000000..ea48084a103 --- /dev/null +++ b/mobile/lib/features/auth/login_screen.dart @@ -0,0 +1,296 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/theme/huly_theme.dart'; +import '../../core/widgets/huly_button.dart'; +import 'auth_provider.dart'; +import 'oauth_webview_screen.dart'; + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _otpController = TextEditingController(); + final _formKey = GlobalKey(); + bool _showPasswordField = false; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _otpController.dispose(); + super.dispose(); + } + + Future _submitOtpRequest() async { + if (!_formKey.currentState!.validate()) return; + await ref.read(authProvider.notifier).requestOtp( + _emailController.text.trim(), + ); + } + + Future _submitOtpCode() async { + final code = _otpController.text.trim(); + if (code.isEmpty) return; + await ref.read(authProvider.notifier).validateOtp(code); + } + + Future _submitPassword() async { + if (!_formKey.currentState!.validate()) return; + await ref.read(authProvider.notifier).login( + _emailController.text.trim(), + _passwordController.text, + ); + } + + Future _loginWithProvider(String provider) async { + final url = ref.read(authProvider.notifier).getOAuthUrl(provider); + if (url == null) return; + + final token = await performOAuthLogin(url); + + if (token != null && mounted) { + await ref.read(authProvider.notifier).loginWithToken(token); + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Sign in was cancelled or failed'), + backgroundColor: HulyColors.negative, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final auth = ref.watch(authProvider); + final isOtpPending = auth.status == AuthStatus.otpPending; + final providers = auth.providers ?? []; + + return Scaffold( + backgroundColor: HulyColors.background, + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 80), + Text( + isOtpPending ? 'Check your email' : 'Sign In', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + isOtpPending + ? 'Enter the code sent to ${auth.otpEmail}' + : auth.serverUrl ?? '', + style: const TextStyle( + color: HulyColors.darkerText, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + + if (isOtpPending) ...[ + // OTP code entry + TextFormField( + controller: _otpController, + decoration: hulyInputDecoration('Verification code'), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + letterSpacing: 8, + ), + autofocus: true, + onFieldSubmitted: (_) => _submitOtpCode(), + ), + const SizedBox(height: 16), + if (auth.error != null) _ErrorText(auth.error!), + HulyButton( + label: 'Verify', + onPressed: _submitOtpCode, + loading: auth.loading, + ), + const SizedBox(height: 12), + HulyButton( + label: 'Resend code', + kind: HulyButtonKind.ghost, + onPressed: () { + _otpController.clear(); + ref.read(authProvider.notifier).requestOtp(auth.otpEmail!); + }, + ), + ] else ...[ + // Email field + TextFormField( + controller: _emailController, + decoration: hulyInputDecoration('Email', 'you@example.com'), + keyboardType: TextInputType.emailAddress, + autocorrect: false, + style: const TextStyle(color: HulyColors.contentText), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + const SizedBox(height: 16), + + if (_showPasswordField) ...[ + TextFormField( + controller: _passwordController, + decoration: hulyInputDecoration('Password'), + obscureText: true, + style: const TextStyle(color: HulyColors.contentText), + validator: (v) => + (v == null || v.isEmpty) ? 'Required' : null, + onFieldSubmitted: (_) => _submitPassword(), + ), + const SizedBox(height: 16), + ], + + if (auth.error != null) _ErrorText(auth.error!), + + // Primary action + if (_showPasswordField) ...[ + HulyButton( + label: 'Sign In', + onPressed: _submitPassword, + loading: auth.loading, + ), + const SizedBox(height: 8), + HulyButton( + label: 'Sign in with email code instead', + kind: HulyButtonKind.ghost, + onPressed: () => setState(() => _showPasswordField = false), + ), + ] else ...[ + HulyButton( + label: 'Send sign-in code', + onPressed: _submitOtpRequest, + loading: auth.loading, + ), + const SizedBox(height: 8), + HulyButton( + label: 'Sign in with password instead', + kind: HulyButtonKind.ghost, + onPressed: () => setState(() => _showPasswordField = true), + ), + ], + + // OAuth provider buttons + if (providers.isNotEmpty) ...[ + const SizedBox(height: 24), + const _Divider(), + const SizedBox(height: 24), + for (final provider in providers) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: OutlinedButton.icon( + onPressed: auth.loading + ? null + : () => _loginWithProvider(provider.id), + icon: Icon(_providerIcon(provider.id), size: 22), + label: Text('Continue with ${_providerLabel(provider.id)}'), + style: OutlinedButton.styleFrom( + foregroundColor: HulyColors.contentText, + side: const BorderSide(color: HulyColors.divider), + backgroundColor: HulyColors.inputFill, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ], + + const SizedBox(height: 24), + HulyButton( + label: 'Change server', + kind: HulyButtonKind.ghost, + onPressed: () => ref.read(authProvider.notifier).logout(), + ), + ], + ), + ), + ), + ), + ); + } +} + +IconData _providerIcon(String id) { + switch (id) { + case 'google': + return Icons.g_mobiledata; + case 'github': + return Icons.code; + case 'openid': + return Icons.lock_open; + default: + return Icons.login; + } +} + +String _providerLabel(String id) { + switch (id) { + case 'google': + return 'Google'; + case 'github': + return 'GitHub'; + case 'openid': + return 'SSO'; + default: + return id[0].toUpperCase() + id.substring(1); + } +} + +class _ErrorText extends StatelessWidget { + final String text; + const _ErrorText(this.text); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text(text, style: const TextStyle(color: HulyColors.errorText)), + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded(child: Divider(color: HulyColors.divider)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'or', + style: TextStyle(color: HulyColors.darkerText, fontSize: 13), + ), + ), + Expanded(child: Divider(color: HulyColors.divider)), + ], + ); + } +} diff --git a/mobile/lib/features/auth/oauth_webview_screen.dart b/mobile/lib/features/auth/oauth_webview_screen.dart new file mode 100644 index 00000000000..9d0b8ff1605 --- /dev/null +++ b/mobile/lib/features/auth/oauth_webview_screen.dart @@ -0,0 +1,31 @@ +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; + +/// Performs OAuth login using ASWebAuthenticationSession (system browser). +/// +/// Opens [url] in a secure system browser sheet. The server redirects to +/// `huly://login/auth?token=...` which is intercepted and returned. +/// +/// Returns the token string on success, or null on failure/cancellation. +Future performOAuthLogin(String url) async { + try { + // Append mobileRedirect=huly so the server redirects to huly:// scheme + final separator = url.contains('?') ? '&' : '?'; + final oauthUrl = '$url${separator}mobileRedirect=huly'; + + final result = await FlutterWebAuth2.authenticate( + url: oauthUrl, + callbackUrlScheme: 'huly', + ); + + final uri = Uri.parse(result); + + // Check for error + final error = uri.queryParameters['error']; + if (error != null) return null; + + return uri.queryParameters['token']; + } catch (_) { + // User cancelled or auth failed + return null; + } +} diff --git a/mobile/lib/features/auth/server_url_screen.dart b/mobile/lib/features/auth/server_url_screen.dart new file mode 100644 index 00000000000..c806e5c5cb6 --- /dev/null +++ b/mobile/lib/features/auth/server_url_screen.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/theme/huly_theme.dart'; +import '../../core/widgets/huly_button.dart'; +import 'auth_provider.dart'; + +class ServerUrlScreen extends ConsumerStatefulWidget { + const ServerUrlScreen({super.key}); + + @override + ConsumerState createState() => _ServerUrlScreenState(); +} + +class _ServerUrlScreenState extends ConsumerState { + final _controller = TextEditingController(); + final _formKey = GlobalKey(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + var url = _controller.text.trim(); + if (!url.startsWith('http')) url = 'https://$url'; + await ref.read(authProvider.notifier).setServerUrl(url); + } + + @override + Widget build(BuildContext context) { + final auth = ref.watch(authProvider); + + return Scaffold( + backgroundColor: HulyColors.background, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Logo / branding + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: HulyColors.primaryButton, + borderRadius: BorderRadius.circular(16), + ), + child: const Center( + child: Text( + 'H', + style: TextStyle( + color: Colors.white, + fontSize: 36, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(height: 24), + Text( + 'Huly', + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + 'Connect to your instance', + style: TextStyle(color: HulyColors.darkText, fontSize: 15), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + TextFormField( + controller: _controller, + decoration: hulyInputDecoration('Server URL', 'huly.example.com'), + keyboardType: TextInputType.url, + autocorrect: false, + style: const TextStyle(color: HulyColors.contentText), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + onFieldSubmitted: (_) => _submit(), + ), + const SizedBox(height: 16), + if (auth.error != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + auth.error!, + style: const TextStyle(color: HulyColors.errorText), + ), + ), + HulyButton( + label: 'Connect', + onPressed: _submit, + loading: auth.loading, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/auth/tfa_screen.dart b/mobile/lib/features/auth/tfa_screen.dart new file mode 100644 index 00000000000..175cf816eb0 --- /dev/null +++ b/mobile/lib/features/auth/tfa_screen.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/theme/huly_theme.dart'; +import '../../core/widgets/huly_button.dart'; +import 'auth_provider.dart'; + +class TfaScreen extends ConsumerStatefulWidget { + const TfaScreen({super.key}); + + @override + ConsumerState createState() => _TfaScreenState(); +} + +class _TfaScreenState extends ConsumerState { + final _codeController = TextEditingController(); + + @override + void dispose() { + _codeController.dispose(); + super.dispose(); + } + + Future _submit() async { + final code = _codeController.text.trim(); + if (code.isEmpty) return; + await ref.read(authProvider.notifier).verify2fa(code); + } + + @override + Widget build(BuildContext context) { + final auth = ref.watch(authProvider); + + return Scaffold( + backgroundColor: HulyColors.background, + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 80), + Text( + 'Two-Factor Authentication', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + 'Enter the 6-digit code from your authenticator app', + style: TextStyle( + color: HulyColors.darkerText, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + TextFormField( + controller: _codeController, + decoration: hulyInputDecoration('Authentication code'), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + letterSpacing: 8, + ), + autofocus: true, + onFieldSubmitted: (_) => _submit(), + ), + const SizedBox(height: 16), + if (auth.error != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + auth.error!, + style: const TextStyle(color: HulyColors.errorText), + ), + ), + HulyButton( + label: 'Verify', + onPressed: _submit, + loading: auth.loading, + ), + const SizedBox(height: 24), + HulyButton( + label: 'Back to login', + kind: HulyButtonKind.ghost, + onPressed: () => ref.read(authProvider.notifier).logout(), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/auth/workspace_screen.dart b/mobile/lib/features/auth/workspace_screen.dart new file mode 100644 index 00000000000..f19177ba6f7 --- /dev/null +++ b/mobile/lib/features/auth/workspace_screen.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/theme/huly_theme.dart'; +import '../../core/widgets/huly_button.dart'; +import 'auth_provider.dart'; + +class WorkspaceScreen extends ConsumerWidget { + const WorkspaceScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final auth = ref.watch(authProvider); + final workspaces = auth.workspaces ?? []; + + return Scaffold( + backgroundColor: HulyColors.background, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 48), + Text( + 'Select Workspace', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + if (auth.error != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + auth.error!, + style: const TextStyle(color: HulyColors.errorText), + ), + ), + if (auth.loading) + const Center(child: CircularProgressIndicator()) + else if (workspaces.isEmpty) + const Text( + 'No workspaces found.', + style: TextStyle(color: HulyColors.darkText), + textAlign: TextAlign.center, + ) + else + Expanded( + child: ListView.separated( + itemCount: workspaces.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final ws = workspaces[index]; + return _WorkspaceTile( + name: ws.workspaceName, + url: ws.workspaceUrl, + onTap: () => ref + .read(authProvider.notifier) + .selectWorkspace(ws.workspaceUrl), + ); + }, + ), + ), + const SizedBox(height: 16), + HulyButton( + label: 'Sign out', + kind: HulyButtonKind.ghost, + onPressed: () => ref.read(authProvider.notifier).logout(), + ), + ], + ), + ), + ), + ); + } +} + +class _WorkspaceTile extends StatelessWidget { + final String name; + final String url; + final VoidCallback onTap; + + const _WorkspaceTile({ + required this.name, + required this.url, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + // Generate initials for avatar + final initials = name.isNotEmpty + ? name + .split(' ') + .take(2) + .map((w) => w.isNotEmpty ? w[0].toUpperCase() : '') + .join() + : '?'; + + return Material( + color: HulyColors.listRow, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: HulyColors.primaryButton, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + initials, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle( + color: HulyColors.contentText, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + url, + style: const TextStyle( + color: HulyColors.darkerText, + fontSize: 13, + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right, + color: HulyColors.darkerText, + size: 20, + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/chat/channel_list_screen.dart b/mobile/lib/features/chat/channel_list_screen.dart new file mode 100644 index 00000000000..d9d0be189f1 --- /dev/null +++ b/mobile/lib/features/chat/channel_list_screen.dart @@ -0,0 +1,160 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/models/channel.dart'; +import '../../core/models/member.dart'; +import '../../core/theme/huly_theme.dart'; +import '../issues/issue_provider.dart'; +import 'chat_provider.dart'; + +class ChannelListScreen extends ConsumerStatefulWidget { + const ChannelListScreen({super.key}); + + @override + ConsumerState createState() => _ChannelListScreenState(); +} + +class _ChannelListScreenState extends ConsumerState + with WidgetsBindingObserver { + Timer? _pollTimer; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _startPoll(); + } + + @override + void dispose() { + _pollTimer?.cancel(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + ref.invalidate(channelsProvider); + _startPoll(); + } else if (state == AppLifecycleState.paused) { + _pollTimer?.cancel(); + } + } + + void _startPoll() { + _pollTimer?.cancel(); + _pollTimer = Timer.periodic(const Duration(seconds: 30), (_) { + ref.invalidate(channelsProvider); + }); + } + + @override + Widget build(BuildContext context) { + final channelsAsync = ref.watch(channelsProvider); + final membersAsync = ref.watch(membersProvider); + + return Scaffold( + backgroundColor: HulyColors.background, + appBar: AppBar( + backgroundColor: HulyColors.header, + title: const Text('Chat'), + ), + body: channelsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Text('Error: $e', + style: const TextStyle(color: HulyColors.errorText)), + ), + data: (channels) { + if (channels.isEmpty) { + return const Center( + child: Text('No channels or DMs.', + style: TextStyle(color: HulyColors.darkText)), + ); + } + return RefreshIndicator( + onRefresh: () => ref.refresh(channelsProvider.future), + child: ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: channels.length, + itemBuilder: (context, index) { + final channel = channels[index]; + return _ChannelTile( + channel: channel, + members: membersAsync.valueOrNull, + ); + }, + ), + ); + }, + ), + ); + } +} + +class _ChannelTile extends StatelessWidget { + final Channel channel; + final Map? members; + + const _ChannelTile({required this.channel, this.members}); + + bool get _isDm => channel.className.contains('DirectMessage'); + + String get _displayName { + if (channel.name.isNotEmpty) return channel.name; + if (_isDm && members != null) { + final names = channel.members + .map((id) => members![id]?.name ?? id) + .take(3) + .join(', '); + return names.isNotEmpty ? names : 'Direct Message'; + } + return _isDm ? 'Direct Message' : 'Channel'; + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Material( + color: HulyColors.listRow, + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => context.push( + Uri( + path: '/chat/${channel.id}', + queryParameters: {'name': _displayName}, + ).toString(), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Icon( + _isDm ? Icons.person_outline : Icons.tag, + color: HulyColors.darkText, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _displayName, + style: const TextStyle( + color: HulyColors.contentText, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/chat/chat_provider.dart b/mobile/lib/features/chat/chat_provider.dart new file mode 100644 index 00000000000..f82484f9092 --- /dev/null +++ b/mobile/lib/features/chat/chat_provider.dart @@ -0,0 +1,34 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/api/realtime_provider.dart'; +import '../../core/models/activity.dart'; +import '../../core/models/channel.dart'; +import '../auth/auth_provider.dart'; + +/// Fetches all channels the user can see. +/// Auto-refreshes when WebSocket Tx events arrive. +final channelsProvider = FutureProvider>((ref) async { + ref.watch(dataVersionProvider); + final client = ref.watch(restClientProvider); + if (client == null) return []; + final channels = await client.findAll('chunter:class:Channel'); + final dms = await client.findAll('chunter:class:DirectMessage'); + return [...channels, ...dms] + .map((e) => Channel.fromJson(e)) + .toList() + ..sort((a, b) => (b.modifiedOn ?? 0).compareTo(a.modifiedOn ?? 0)); +}); + +/// Fetches messages for a given channel/DM. +/// Auto-refreshes when WebSocket Tx events arrive. +final channelMessagesProvider = + FutureProvider.family, String>((ref, channelId) async { + ref.watch(dataVersionProvider); + final client = ref.watch(restClientProvider); + if (client == null) return []; + final results = await client.findAll( + 'chunter:class:ChatMessage', + query: {'attachedTo': channelId}, + options: {'sort': {'createdOn': 1}, 'limit': 200}, + ); + return results.map((e) => ChatMessage.fromJson(e)).toList(); +}); diff --git a/mobile/lib/features/chat/message_thread_screen.dart b/mobile/lib/features/chat/message_thread_screen.dart new file mode 100644 index 00000000000..16cbe7fd3e4 --- /dev/null +++ b/mobile/lib/features/chat/message_thread_screen.dart @@ -0,0 +1,208 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/models/tx.dart'; +import '../../core/theme/huly_theme.dart'; +import '../../core/utils/html.dart'; +import '../../core/widgets/message_bubble.dart'; +import '../auth/auth_provider.dart'; +import '../issues/issue_provider.dart'; +import 'chat_provider.dart'; + +class MessageThreadScreen extends ConsumerStatefulWidget { + final String channelId; + final String? channelName; + + const MessageThreadScreen({ + super.key, + required this.channelId, + this.channelName, + }); + + @override + ConsumerState createState() => + _MessageThreadScreenState(); +} + +class _MessageThreadScreenState extends ConsumerState + with WidgetsBindingObserver { + final _messageController = TextEditingController(); + final _scrollController = ScrollController(); + Timer? _pollTimer; + bool _sending = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _startPoll(); + } + + @override + void dispose() { + _pollTimer?.cancel(); + WidgetsBinding.instance.removeObserver(this); + _messageController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + ref.invalidate(channelMessagesProvider(widget.channelId)); + _startPoll(); + } else if (state == AppLifecycleState.paused) { + _pollTimer?.cancel(); + } + } + + void _startPoll() { + _pollTimer?.cancel(); + _pollTimer = Timer.periodic(const Duration(seconds: 10), (_) { + ref.invalidate(channelMessagesProvider(widget.channelId)); + }); + } + + Future _send() async { + final text = _messageController.text.trim(); + if (text.isEmpty) return; + + setState(() => _sending = true); + try { + final client = ref.read(restClientProvider); + if (client == null) return; + + final tx = buildCreateChatMessageTx( + channelId: widget.channelId, + message: '

${escapeHtml(text)}

', + ); + await client.tx(tx); + _messageController.clear(); + ref.invalidate(channelMessagesProvider(widget.channelId)); + + // Scroll to bottom after a short delay for the rebuild. + Future.delayed(const Duration(milliseconds: 300), () { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + }); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to send: $e'), + backgroundColor: HulyColors.negative, + ), + ); + } + } finally { + if (mounted) setState(() => _sending = false); + } + } + + @override + Widget build(BuildContext context) { + final messagesAsync = ref.watch(channelMessagesProvider(widget.channelId)); + final membersAsync = ref.watch(membersProvider); + + return Scaffold( + backgroundColor: HulyColors.background, + appBar: AppBar( + backgroundColor: HulyColors.header, + title: Text(widget.channelName ?? 'Chat'), + ), + body: Column( + children: [ + Expanded( + child: messagesAsync.when( + loading: () => + const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Text('Error: $e', + style: const TextStyle(color: HulyColors.errorText)), + ), + data: (messages) { + if (messages.isEmpty) { + return const Center( + child: Text('No messages yet.', + style: TextStyle(color: HulyColors.darkText)), + ); + } + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(12), + itemCount: messages.length, + itemBuilder: (context, index) { + final msg = messages[index]; + final author = msg.createdBy ?? msg.modifiedBy; + final members = membersAsync.valueOrNull; + final name = (members != null && author != null) + ? (members[author]?.name ?? author) + : (author ?? 'Unknown'); + + return MessageBubble( + authorName: name, + messageHtml: msg.message, + timestamp: msg.createdOn ?? msg.modifiedOn, + avatarRadius: 16, + ); + }, + ); + }, + ), + ), + // Message input + Container( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), + decoration: const BoxDecoration( + color: HulyColors.header, + border: Border(top: BorderSide(color: HulyColors.divider)), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Type a message...', + hintStyle: + const TextStyle(color: HulyColors.darkerText), + filled: true, + fillColor: HulyColors.inputFill, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 10), + ), + style: const TextStyle(color: HulyColors.contentText), + textInputAction: TextInputAction.send, + onSubmitted: (_) => _send(), + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: _sending ? null : _send, + icon: _sending + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon(Icons.send, color: HulyColors.accent), + ), + ], + ), + ), + ], + ), + ); + } + +} diff --git a/mobile/lib/features/create_issue/create_issue_provider.dart b/mobile/lib/features/create_issue/create_issue_provider.dart new file mode 100644 index 00000000000..7e854676ec2 --- /dev/null +++ b/mobile/lib/features/create_issue/create_issue_provider.dart @@ -0,0 +1,64 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/models/issue.dart'; +import '../../core/models/tx.dart'; +import '../auth/auth_provider.dart'; +import '../issues/issue_provider.dart'; + +class CreateIssueState { + final bool loading; + final String? error; + final bool success; + + const CreateIssueState({ + this.loading = false, + this.error, + this.success = false, + }); +} + +class CreateIssueNotifier extends Notifier { + @override + CreateIssueState build() => const CreateIssueState(); + + Future createIssue({ + required String space, + required String title, + required String status, + int priority = IssuePriority.noPriority, + String? description, + }) async { + state = const CreateIssueState(loading: true); + try { + final client = ref.read(restClientProvider); + if (client == null) { + state = const CreateIssueState(error: 'Not connected to workspace'); + return false; + } + + final tx = buildCreateIssueTx( + space: space, + title: title, + status: status, + priority: priority, + description: description, + ); + + await client.tx(tx); + + // Invalidate issue list so it refreshes. + ref.invalidate(issuesProvider(space)); + + state = const CreateIssueState(success: true); + return true; + } catch (e) { + state = CreateIssueState(error: 'Failed to create issue: $e'); + return false; + } + } + + void reset() => state = const CreateIssueState(); +} + +final createIssueProvider = + NotifierProvider( + CreateIssueNotifier.new); diff --git a/mobile/lib/features/create_issue/create_issue_screen.dart b/mobile/lib/features/create_issue/create_issue_screen.dart new file mode 100644 index 00000000000..a8174de9f8e --- /dev/null +++ b/mobile/lib/features/create_issue/create_issue_screen.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/models/issue.dart'; +import '../../core/models/project.dart'; +import '../../core/theme/huly_theme.dart'; +import '../../core/utils/html.dart'; +import '../../core/widgets/huly_button.dart'; +import '../../core/widgets/priority_chip.dart'; +import '../issues/issue_provider.dart'; +import 'create_issue_provider.dart'; + +class CreateIssueScreen extends ConsumerStatefulWidget { + final String? initialTitle; + final String? initialDescription; + + const CreateIssueScreen({ + super.key, + this.initialTitle, + this.initialDescription, + }); + + @override + ConsumerState createState() => _CreateIssueScreenState(); +} + +class _CreateIssueScreenState extends ConsumerState { + final _formKey = GlobalKey(); + late final TextEditingController _titleController; + late final TextEditingController _descController; + int _priority = IssuePriority.noPriority; + Project? _selectedProject; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.initialTitle ?? ''); + _descController = + TextEditingController(text: widget.initialDescription ?? ''); + } + + @override + void dispose() { + _titleController.dispose(); + _descController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + if (_selectedProject == null) return; + + final desc = _descController.text.trim(); + final htmlDesc = desc.isEmpty ? null : '

${escapeHtml(desc)}

'; + + final success = await ref.read(createIssueProvider.notifier).createIssue( + space: _selectedProject!.id, + title: _titleController.text.trim(), + status: _selectedProject!.defaultIssueStatus ?? '', + priority: _priority, + description: htmlDesc, + ); + + if (success && mounted) { + ref.read(createIssueProvider.notifier).reset(); + context.pop(); + } + } + + @override + Widget build(BuildContext context) { + final projectsAsync = ref.watch(projectsProvider); + final createState = ref.watch(createIssueProvider); + + return Scaffold( + backgroundColor: HulyColors.background, + appBar: AppBar( + backgroundColor: HulyColors.header, + title: const Text('New Issue'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Project picker + projectsAsync.when( + loading: () => const LinearProgressIndicator(), + error: (e, _) => Text('Error loading projects: $e', + style: const TextStyle(color: HulyColors.errorText)), + data: (projects) { + if (_selectedProject == null && projects.isNotEmpty) { + _selectedProject = projects.first; + } + return DropdownButtonFormField( + initialValue: _selectedProject, + dropdownColor: HulyColors.inputFill, + style: const TextStyle(color: HulyColors.contentText), + decoration: hulyInputDecoration('Project'), + items: projects + .map((p) => DropdownMenuItem( + value: p, + child: Text(p.name), + )) + .toList(), + onChanged: (p) => setState(() => _selectedProject = p), + ); + }, + ), + const SizedBox(height: 16), + + // Title + TextFormField( + controller: _titleController, + decoration: hulyInputDecoration('Title'), + style: const TextStyle(color: HulyColors.contentText), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + const SizedBox(height: 16), + + // Description + TextFormField( + controller: _descController, + decoration: hulyInputDecoration('Description'), + style: const TextStyle(color: HulyColors.contentText), + maxLines: 5, + minLines: 3, + ), + const SizedBox(height: 16), + + // Priority — chip-style picker + const Text( + 'Priority', + style: TextStyle( + color: HulyColors.darkText, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final p in [0, 1, 2, 3, 4]) + PriorityChip( + priority: p, + selected: _priority == p, + onTap: () => setState(() => _priority = p), + ), + ], + ), + const SizedBox(height: 24), + + if (createState.error != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + createState.error!, + style: const TextStyle(color: HulyColors.errorText), + ), + ), + + HulyButton( + label: 'Create Issue', + onPressed: _submit, + loading: createState.loading, + ), + ], + ), + ), + ), + ); + } +} + diff --git a/mobile/lib/features/issues/edit_issue_screen.dart b/mobile/lib/features/issues/edit_issue_screen.dart new file mode 100644 index 00000000000..0b9d93223d1 --- /dev/null +++ b/mobile/lib/features/issues/edit_issue_screen.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/utils/html.dart'; +import '../../core/models/issue.dart'; +import '../../core/models/tx.dart'; +import '../../core/theme/huly_theme.dart'; +import '../../core/widgets/huly_button.dart'; +import '../../core/widgets/priority_chip.dart'; +import '../auth/auth_provider.dart'; +import 'issue_detail_screen.dart'; +import 'issue_provider.dart'; + +class EditIssueScreen extends ConsumerStatefulWidget { + final Issue issue; + + const EditIssueScreen({super.key, required this.issue}); + + @override + ConsumerState createState() => _EditIssueScreenState(); +} + +class _EditIssueScreenState extends ConsumerState { + late final TextEditingController _titleController; + late final TextEditingController _descController; + late int _priority; + late String _statusId; + bool _loading = false; + String? _error; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.issue.title); + final rawDesc = stripHtml(widget.issue.description ?? ''); + _descController = TextEditingController(text: rawDesc); + _priority = widget.issue.priority; + _statusId = widget.issue.status; + } + + @override + void dispose() { + _titleController.dispose(); + _descController.dispose(); + super.dispose(); + } + + Future _submit() async { + final title = _titleController.text.trim(); + if (title.isEmpty) return; + + setState(() { + _loading = true; + _error = null; + }); + + try { + final client = ref.read(restClientProvider); + if (client == null) throw Exception('Not connected'); + + final operations = {}; + if (title != widget.issue.title) { + operations['title'] = title; + } + + final desc = _descController.text.trim(); + final htmlDesc = desc.isEmpty ? '

' : '

${escapeHtml(desc)}

'; + final oldDesc = widget.issue.description ?? '

'; + if (htmlDesc != oldDesc) { + operations['description'] = htmlDesc; + } + + if (_priority != widget.issue.priority) { + operations['priority'] = _priority; + } + + if (_statusId != widget.issue.status) { + operations['status'] = _statusId; + } + + if (operations.isEmpty) { + if (mounted) context.pop(); + return; + } + + final tx = buildUpdateIssueTx( + issueId: widget.issue.id, + space: widget.issue.space, + operations: operations, + ); + + await client.tx(tx); + + if (mounted) { + // Invalidate detail and list providers to reflect changes. + ref.invalidate(issueDetailProvider(widget.issue.id)); + ref.invalidate(issuesProvider(widget.issue.space)); + context.pop(); + } + } catch (e) { + setState(() => _error = 'Failed to update: $e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final statusesAsync = ref.watch(issueStatusesProvider); + + return Scaffold( + backgroundColor: HulyColors.background, + appBar: AppBar( + backgroundColor: HulyColors.header, + title: Text(widget.issue.identifier), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _titleController, + decoration: hulyInputDecoration('Title'), + style: const TextStyle(color: HulyColors.contentText), + ), + const SizedBox(height: 16), + TextFormField( + controller: _descController, + decoration: hulyInputDecoration('Description'), + style: const TextStyle(color: HulyColors.contentText), + maxLines: 5, + minLines: 3, + ), + const SizedBox(height: 16), + // Status dropdown + if (statusesAsync.valueOrNull != null && + statusesAsync.valueOrNull!.isNotEmpty) ...[ + const Text( + 'Status', + style: TextStyle( + color: HulyColors.darkText, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _statusId, + decoration: hulyInputDecoration('Status'), + dropdownColor: HulyColors.inputFill, + style: const TextStyle(color: HulyColors.contentText), + items: statusesAsync.valueOrNull! + .map((s) => DropdownMenuItem( + value: s.id, + child: Text(s.name), + )) + .toList(), + onChanged: (value) { + if (value != null) setState(() => _statusId = value); + }, + ), + const SizedBox(height: 16), + ], + const Text( + 'Priority', + style: TextStyle( + color: HulyColors.darkText, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final p in [0, 1, 2, 3, 4]) + PriorityChip( + priority: p, + selected: _priority == p, + onTap: () => setState(() => _priority = p), + ), + ], + ), + const SizedBox(height: 24), + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + _error!, + style: const TextStyle(color: HulyColors.errorText), + ), + ), + HulyButton( + label: 'Save Changes', + onPressed: _submit, + loading: _loading, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/issues/issue_detail_screen.dart b/mobile/lib/features/issues/issue_detail_screen.dart new file mode 100644 index 00000000000..34808940fc6 --- /dev/null +++ b/mobile/lib/features/issues/issue_detail_screen.dart @@ -0,0 +1,483 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import '../../core/models/activity.dart'; +import '../../core/models/attachment.dart'; +import '../../core/models/issue.dart'; +import '../../core/models/issue_status.dart'; +import '../../core/models/member.dart'; +import '../../core/models/tx.dart'; +import '../../core/theme/huly_theme.dart'; +import '../../core/utils/html.dart'; +import '../../core/widgets/huly_chip.dart'; +import '../../core/widgets/message_bubble.dart'; +import '../../core/widgets/priority_icon.dart'; +import '../auth/auth_provider.dart'; +import 'edit_issue_screen.dart'; +import 'issue_provider.dart'; + +/// Fetches a single issue by ID. +final issueDetailProvider = + FutureProvider.family((ref, issueId) async { + final client = ref.watch(restClientProvider); + if (client == null) return null; + final results = await client.findAll( + 'tracker:class:Issue', + query: {'_id': issueId}, + ); + if (results.isEmpty) return null; + return Issue.fromJson(results.first); +}); + +class IssueDetailScreen extends ConsumerStatefulWidget { + final String issueId; + const IssueDetailScreen({super.key, required this.issueId}); + + @override + ConsumerState createState() => _IssueDetailScreenState(); +} + +class _IssueDetailScreenState extends ConsumerState { + final _commentController = TextEditingController(); + final _imagePicker = ImagePicker(); + bool _sendingComment = false; + bool _uploading = false; + + @override + void dispose() { + _commentController.dispose(); + super.dispose(); + } + + Future _postComment() async { + final text = _commentController.text.trim(); + if (text.isEmpty) return; + + setState(() => _sendingComment = true); + try { + final client = ref.read(restClientProvider); + if (client == null) return; + + final tx = buildCreateChatMessageTx( + channelId: widget.issueId, + message: '

${escapeHtml(text)}

', + ); + await client.tx(tx); + _commentController.clear(); + ref.invalidate(activityProvider(widget.issueId)); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to post comment: $e'), + backgroundColor: HulyColors.negative, + ), + ); + } + } finally { + if (mounted) setState(() => _sendingComment = false); + } + } + + Future _pickAndUpload() async { + final file = await _imagePicker.pickImage(source: ImageSource.gallery); + if (file == null) return; + + setState(() => _uploading = true); + try { + final client = ref.read(restClientProvider); + if (client == null) return; + + final bytes = await file.readAsBytes(); + final blobId = + 'blob:${DateTime.now().millisecondsSinceEpoch}:${file.name}'; + final contentType = file.mimeType ?? 'image/jpeg'; + + await client.uploadBlob( + name: blobId, + contentType: contentType, + size: bytes.length, + bytes: bytes, + ); + + final issueAsync = ref.read(issueDetailProvider(widget.issueId)); + final issue = issueAsync.valueOrNull; + if (issue == null) return; + + final tx = buildCreateAttachmentTx( + attachedTo: widget.issueId, + attachedToClass: 'tracker:class:Issue', + space: issue.space, + name: file.name, + blobId: blobId, + size: bytes.length, + contentType: contentType, + ); + await client.tx(tx); + ref.invalidate(attachmentsProvider(widget.issueId)); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Upload failed: $e'), + backgroundColor: HulyColors.negative, + ), + ); + } + } finally { + if (mounted) setState(() => _uploading = false); + } + } + + @override + Widget build(BuildContext context) { + final issueAsync = ref.watch(issueDetailProvider(widget.issueId)); + final statusesAsync = ref.watch(issueStatusesProvider); + final membersAsync = ref.watch(membersProvider); + final activityAsync = ref.watch(activityProvider(widget.issueId)); + final attachmentsAsync = ref.watch(attachmentsProvider(widget.issueId)); + + return Scaffold( + backgroundColor: HulyColors.background, + appBar: AppBar( + backgroundColor: HulyColors.header, + actions: [ + if (issueAsync.valueOrNull != null) + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + EditIssueScreen(issue: issueAsync.valueOrNull!), + ), + ); + }, + ), + ], + ), + body: issueAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Text('Error: $e', + style: const TextStyle(color: HulyColors.errorText)), + ), + data: (issue) { + if (issue == null) { + return const Center( + child: Text('Issue not found.', + style: TextStyle(color: HulyColors.darkText)), + ); + } + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + issue.identifier, + style: const TextStyle( + color: HulyColors.darkerText, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + Text( + issue.title, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 20), + + // Property chips + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + HulyChip( + label: PriorityIcon.label(issue.priority), + color: HulyColors.priorityColor(issue.priority), + icon: _priorityIcon(issue.priority), + ), + HulyChip( + label: _resolveStatusName( + issue.status, statusesAsync.valueOrNull), + color: HulyColors.accent, + ), + if (issue.assignee != null) + HulyChip( + label: _resolveMemberName( + issue.assignee!, membersAsync.valueOrNull), + icon: Icons.person_outline, + ), + ], + ), + + if (issue.description != null && + issue.description!.isNotEmpty) ...[ + const SizedBox(height: 24), + const Divider(color: HulyColors.divider), + const SizedBox(height: 16), + const Text( + 'Description', + style: TextStyle( + color: HulyColors.darkText, + fontWeight: FontWeight.w500, + fontSize: 13, + ), + ), + const SizedBox(height: 8), + Text( + stripHtml(issue.description!), + style: const TextStyle( + color: HulyColors.contentText, + height: 1.5, + ), + ), + ], + // Attachments + const SizedBox(height: 24), + const Divider(color: HulyColors.divider), + const SizedBox(height: 16), + Row( + children: [ + const Text( + 'Attachments', + style: TextStyle( + color: HulyColors.darkText, + fontWeight: FontWeight.w500, + fontSize: 13, + ), + ), + const Spacer(), + IconButton( + icon: _uploading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : const Icon(Icons.attach_file, + color: HulyColors.accent, size: 20), + onPressed: _uploading ? null : _pickAndUpload, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 8), + _AttachmentsList( + attachmentsAsync: attachmentsAsync), + // Activity / comments + const SizedBox(height: 24), + const Divider(color: HulyColors.divider), + const SizedBox(height: 16), + const Text( + 'Activity', + style: TextStyle( + color: HulyColors.darkText, + fontWeight: FontWeight.w500, + fontSize: 13, + ), + ), + const SizedBox(height: 8), + _ActivityFeed( + activityAsync: activityAsync, + members: membersAsync.valueOrNull, + ), + ], + ), + ), + ), + // Comment input bar + Container( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), + decoration: const BoxDecoration( + color: HulyColors.header, + border: + Border(top: BorderSide(color: HulyColors.divider)), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _commentController, + decoration: InputDecoration( + hintText: 'Add a comment...', + hintStyle: const TextStyle( + color: HulyColors.darkerText), + filled: true, + fillColor: HulyColors.inputFill, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 10), + ), + style: const TextStyle( + color: HulyColors.contentText), + textInputAction: TextInputAction.send, + onSubmitted: (_) => _postComment(), + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: + _sendingComment ? null : _postComment, + icon: _sendingComment + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : Icon(Icons.send, + color: HulyColors.accent), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } + + String _resolveMemberName(String ref, Map? members) { + if (members == null) return ref; + return members[ref]?.name ?? ref; + } + + String _resolveStatusName(String statusRef, List? statuses) { + if (statuses == null) return statusRef; + final match = statuses.where((s) => s.id == statusRef); + return match.isNotEmpty ? match.first.name : statusRef; + } + + IconData _priorityIcon(int priority) { + switch (priority) { + case 1: + return Icons.keyboard_double_arrow_up; + case 2: + return Icons.keyboard_arrow_up; + case 3: + return Icons.remove; + case 4: + return Icons.keyboard_arrow_down; + default: + return Icons.more_horiz; + } + } +} + +class _AttachmentsList extends StatelessWidget { + final AsyncValue> attachmentsAsync; + + const _AttachmentsList({required this.attachmentsAsync}); + + 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'; + } + + @override + Widget build(BuildContext context) { + return attachmentsAsync.when( + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + data: (attachments) { + if (attachments.isEmpty) { + return const Text('No attachments.', + style: TextStyle(color: HulyColors.darkerText, fontSize: 13)); + } + return Column( + children: attachments + .map((a) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Icon( + a.type.startsWith('image/') + ? Icons.image_outlined + : Icons.insert_drive_file_outlined, + color: HulyColors.darkText, + size: 18, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + a.name, + style: const TextStyle( + color: HulyColors.contentText, + fontSize: 13, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + _formatSize(a.size), + style: const TextStyle( + color: HulyColors.darkerText, + fontSize: 11, + ), + ), + ], + ), + )) + .toList(), + ); + }, + ); + } +} + +class _ActivityFeed extends StatelessWidget { + final AsyncValue> activityAsync; + final Map? members; + + const _ActivityFeed({required this.activityAsync, this.members}); + + String _resolveAuthor(String? ref) { + if (ref == null) return 'Unknown'; + if (members == null) return ref; + return members![ref]?.name ?? ref; + } + + @override + Widget build(BuildContext context) { + return activityAsync.when( + loading: () => const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Text('Error loading activity: $e', + style: const TextStyle(color: HulyColors.errorText, fontSize: 12)), + data: (messages) { + if (messages.isEmpty) { + return const Text('No comments yet.', + style: TextStyle(color: HulyColors.darkerText, fontSize: 13)); + } + return Column( + children: messages + .map((msg) => MessageBubble( + authorName: + _resolveAuthor(msg.createdBy ?? msg.modifiedBy), + messageHtml: msg.message, + timestamp: msg.createdOn ?? msg.modifiedOn, + )) + .toList(), + ); + }, + ); + } +} diff --git a/mobile/lib/features/issues/issue_list_screen.dart b/mobile/lib/features/issues/issue_list_screen.dart new file mode 100644 index 00000000000..3e64c72e36d --- /dev/null +++ b/mobile/lib/features/issues/issue_list_screen.dart @@ -0,0 +1,266 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/models/issue.dart'; +import '../../core/models/project.dart'; +import '../../core/theme/huly_theme.dart'; +import '../../core/widgets/priority_icon.dart'; +import 'issue_provider.dart'; + +class IssueListScreen extends ConsumerStatefulWidget { + const IssueListScreen({super.key}); + + @override + ConsumerState createState() => _IssueListScreenState(); +} + +class _IssueListScreenState extends ConsumerState + with SingleTickerProviderStateMixin, WidgetsBindingObserver { + TabController? _tabController; + List _projects = []; + Timer? _refreshTimer; + final _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _startRefreshTimer(); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + _searchController.dispose(); + WidgetsBinding.instance.removeObserver(this); + _tabController?.dispose(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _refreshAllProjects(); + _startRefreshTimer(); + } else if (state == AppLifecycleState.paused) { + _refreshTimer?.cancel(); + } + } + + void _startRefreshTimer() { + _refreshTimer?.cancel(); + _refreshTimer = Timer.periodic(const Duration(seconds: 45), (_) { + _refreshAllProjects(); + }); + } + + void _refreshAllProjects() { + for (final project in _projects) { + ref.invalidate(issuesProvider(project.id)); + } + } + + @override + Widget build(BuildContext context) { + final projectsAsync = ref.watch(projectsProvider); + + return Scaffold( + backgroundColor: HulyColors.background, + appBar: AppBar( + backgroundColor: HulyColors.header, + title: const Text('Issues'), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => context.push('/create'), + child: const Icon(Icons.add), + ), + body: projectsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Text('Error: $e', + style: const TextStyle(color: HulyColors.errorText)), + ), + data: (projects) { + if (projects.isEmpty) { + return const Center( + child: Text('No projects found.', + style: TextStyle(color: HulyColors.darkText)), + ); + } + + // Rebuild tab controller if projects changed. + if (_projects.length != projects.length) { + _tabController?.dispose(); + _tabController = + TabController(length: projects.length, vsync: this); + _projects = projects; + } + + return Column( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search issues...', + hintStyle: const TextStyle(color: HulyColors.darkerText), + prefixIcon: const Icon(Icons.search, + color: HulyColors.darkerText), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, + color: HulyColors.darkerText), + onPressed: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + ) + : null, + filled: true, + fillColor: HulyColors.inputFill, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 0), + ), + style: const TextStyle(color: HulyColors.contentText), + onChanged: (value) => + setState(() => _searchQuery = value.trim()), + ), + ), + Material( + color: HulyColors.header, + child: TabBar( + controller: _tabController, + isScrollable: true, + tabs: projects + .map((p) => Tab(text: p.identifier)) + .toList(), + ), + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: projects + .map((p) => _ProjectIssueList( + projectId: p.id, + searchQuery: _searchQuery, + )) + .toList(), + ), + ), + ], + ); + }, + ), + ); + } +} + +class _ProjectIssueList extends ConsumerWidget { + final String projectId; + final String searchQuery; + const _ProjectIssueList({ + required this.projectId, + this.searchQuery = '', + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final issuesAsync = ref.watch(issuesProvider(projectId)); + + return issuesAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Text('Error: $e', + style: const TextStyle(color: HulyColors.errorText)), + ), + data: (issues) { + var filtered = issues; + if (searchQuery.isNotEmpty) { + final q = searchQuery.toLowerCase(); + filtered = issues + .where((i) => + i.title.toLowerCase().contains(q) || + i.identifier.toLowerCase().contains(q)) + .toList(); + } + if (filtered.isEmpty) { + return Center( + child: Text( + searchQuery.isNotEmpty ? 'No matching issues.' : 'No issues.', + style: const TextStyle(color: HulyColors.darkText), + ), + ); + } + return RefreshIndicator( + onRefresh: () => ref.refresh(issuesProvider(projectId).future), + child: ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: filtered.length, + itemBuilder: (context, index) => + _IssueTile(issue: filtered[index]), + ), + ); + }, + ); + } +} + +class _IssueTile extends StatelessWidget { + final Issue issue; + const _IssueTile({required this.issue}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Material( + color: HulyColors.listRow, + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => context.push('/issue/${issue.id}'), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + PriorityIcon(priority: issue.priority, size: 18), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + issue.identifier, + style: const TextStyle( + color: HulyColors.darkerText, + fontSize: 12, + ), + ), + const SizedBox(height: 2), + Text( + issue.title, + style: const TextStyle( + color: HulyColors.contentText, + fontSize: 14, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/issues/issue_provider.dart b/mobile/lib/features/issues/issue_provider.dart new file mode 100644 index 00000000000..dcdee83f76f --- /dev/null +++ b/mobile/lib/features/issues/issue_provider.dart @@ -0,0 +1,78 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/api/realtime_provider.dart'; +import '../../core/models/activity.dart'; +import '../../core/models/attachment.dart'; +import '../../core/models/issue.dart'; +import '../../core/models/issue_status.dart'; +import '../../core/models/member.dart'; +import '../../core/models/project.dart'; +import '../auth/auth_provider.dart'; + +/// Fetches projects from the tracker. +final projectsProvider = FutureProvider>((ref) async { + final client = ref.watch(restClientProvider); + if (client == null) return []; + final results = await client.findAll('tracker:class:Project'); + return results.map((e) => Project.fromJson(e)).toList(); +}); + +/// Fetches all tracker issue statuses. +final issueStatusesProvider = + FutureProvider>((ref) async { + final client = ref.watch(restClientProvider); + if (client == null) return []; + final results = await client.findAll( + 'core:class:Status', + query: {'ofAttribute': 'tracker:attribute:IssueStatus'}, + ); + return results.map((e) => IssueStatus.fromJson(e)).toList(); +}); + +/// Fetches workspace members (contact:class:Person documents). +final membersProvider = FutureProvider>((ref) async { + final client = ref.watch(restClientProvider); + if (client == null) return {}; + final results = await client.findAll('contact:class:Person'); + final members = results.map((e) => Member.fromJson(e)).toList(); + return {for (final m in members) m.id: m}; +}); + +/// Fetches activity messages (comments) for a given document. +final activityProvider = + FutureProvider.family, String>((ref, docId) async { + final client = ref.watch(restClientProvider); + if (client == null) return []; + final results = await client.findAll( + 'chunter:class:ChatMessage', + query: {'attachedTo': docId}, + options: {'sort': {'createdOn': 1}, 'limit': 100}, + ); + return results.map((e) => ChatMessage.fromJson(e)).toList(); +}); + +/// Fetches attachments for a given document. +final attachmentsProvider = + FutureProvider.family, String>((ref, docId) async { + final client = ref.watch(restClientProvider); + if (client == null) return []; + final results = await client.findAll( + 'attachment:class:Attachment', + query: {'attachedTo': docId}, + ); + return results.map((e) => Attachment.fromJson(e)).toList(); +}); + +/// Fetches issues for a given project space ID. +/// Auto-refreshes when WebSocket Tx events arrive. +final issuesProvider = + FutureProvider.family, String>((ref, spaceId) async { + ref.watch(dataVersionProvider); + final client = ref.watch(restClientProvider); + if (client == null) return []; + final results = await client.findAll( + 'tracker:class:Issue', + query: {'space': spaceId}, + options: {'sort': {'modifiedOn': -1}, 'limit': 50}, + ); + return results.map((e) => Issue.fromJson(e)).toList(); +}); diff --git a/mobile/lib/features/settings/settings_screen.dart b/mobile/lib/features/settings/settings_screen.dart new file mode 100644 index 00000000000..44595e22f46 --- /dev/null +++ b/mobile/lib/features/settings/settings_screen.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/theme/huly_theme.dart'; +import '../auth/auth_provider.dart'; + +class SettingsScreen extends ConsumerWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final auth = ref.watch(authProvider); + + return Scaffold( + backgroundColor: HulyColors.background, + appBar: AppBar( + backgroundColor: HulyColors.header, + title: const Text('Settings'), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _SettingsSection( + title: 'Connection', + children: [ + _SettingsTile( + icon: Icons.dns_outlined, + label: 'Server', + value: auth.serverUrl ?? 'Not set', + ), + _SettingsTile( + icon: Icons.workspaces_outlined, + label: 'Workspace', + value: auth.workspaceLogin?.workspaceUrl ?? 'Not selected', + ), + ], + ), + const SizedBox(height: 24), + _SettingsSection( + title: 'Security', + children: [ + _SettingsTile( + icon: Icons.fingerprint, + label: 'Biometric lock', + trailing: Switch.adaptive( + value: auth.biometricEnabled, + onChanged: (value) => + ref.read(authProvider.notifier).setBiometricEnabled(value), + activeColor: HulyColors.accent, + ), + ), + ], + ), + const SizedBox(height: 24), + _SettingsSection( + title: 'Account', + children: [ + _SettingsTile( + icon: Icons.logout, + label: 'Sign out', + onTap: () => ref.read(authProvider.notifier).logout(), + isDestructive: true, + ), + ], + ), + const SizedBox(height: 24), + Center( + child: Text( + 'Huly Mobile v0.2.0', + style: TextStyle( + color: HulyColors.darkerText, + fontSize: 12, + ), + ), + ), + ], + ), + ); + } +} + +class _SettingsSection extends StatelessWidget { + final String title; + final List children; + + const _SettingsSection({required this.title, required this.children}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + color: HulyColors.darkerText, + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: HulyColors.listRow, + borderRadius: BorderRadius.circular(10), + ), + child: Column(children: children), + ), + ], + ); + } +} + +class _SettingsTile extends StatelessWidget { + final IconData icon; + final String label; + final String? value; + final VoidCallback? onTap; + final bool isDestructive; + final Widget? trailing; + + const _SettingsTile({ + required this.icon, + required this.label, + this.value, + this.onTap, + this.isDestructive = false, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + final color = isDestructive ? HulyColors.negative : HulyColors.contentText; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text(label, style: TextStyle(color: color, fontSize: 15)), + ), + if (trailing != null) trailing!, + if (trailing == null && value != null) + Flexible( + child: Text( + value!, + style: const TextStyle( + color: HulyColors.darkerText, fontSize: 13), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + ), + ), + if (trailing == null && onTap != null && value == null) + const Icon(Icons.chevron_right, color: HulyColors.darkerText), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart new file mode 100644 index 00000000000..9aa350c56ec --- /dev/null +++ b/mobile/lib/main.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'app.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Firebase and share handler initialized lazily after auth. + // ShareHandler requires native platform channel setup (share extension). + // Firebase requires GoogleService-Info.plist. + + runApp(const ProviderScope(child: HulyApp())); +} diff --git a/mobile/lib/services/push_notification_service.dart b/mobile/lib/services/push_notification_service.dart new file mode 100644 index 00000000000..1e710c85ab1 --- /dev/null +++ b/mobile/lib/services/push_notification_service.dart @@ -0,0 +1,109 @@ +import 'dart:convert'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:uuid/uuid.dart'; +import '../core/api/rest_client.dart'; + +const _uuid = Uuid(); + +/// Manages FCM push notifications and registers as a PushSubscription +/// in the Huly workspace so the notification service can deliver alerts. +class PushNotificationService { + final FlutterLocalNotificationsPlugin _localNotifications = + FlutterLocalNotificationsPlugin(); + String? _fcmToken; + + /// Initialize FCM and local notifications. + Future initialize() async { + // Request permission. + final messaging = FirebaseMessaging.instance; + await messaging.requestPermission( + alert: true, + badge: true, + sound: true, + ); + + // Initialize local notifications for foreground display. + await _localNotifications.initialize( + const InitializationSettings( + iOS: DarwinInitializationSettings(), + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + ), + ); + + // Get FCM token. + _fcmToken = await messaging.getToken(); + + // Listen for foreground messages. + FirebaseMessaging.onMessage.listen(_handleForegroundMessage); + + // Listen for token refresh. + messaging.onTokenRefresh.listen((token) { + _fcmToken = token; + }); + } + + /// Get the current FCM token. + String? get fcmToken => _fcmToken; + + /// Register the FCM token as a PushSubscription in the Huly workspace. + /// This allows the Huly notification service to send push notifications. + Future registerWithWorkspace(HulyRestClient client) async { + if (_fcmToken == null) return; + + final subscriptionId = 'push:sub:${_uuid.v4()}'; + + // The FCM endpoint URL is a valid Web Push endpoint. + // The Huly notification service uses web-push which supports FCM URLs. + final tx = { + '_class': 'core:class:TxCreateDoc', + 'objectId': subscriptionId, + 'objectClass': 'notification:class:PushSubscription', + 'objectSpace': 'core:space:Space', + 'attributes': { + 'endpoint': 'https://fcm.googleapis.com/fcm/send/$_fcmToken', + 'keys': { + 'p256dh': '', + 'auth': '', + }, + }, + }; + + try { + await client.tx(tx); + } catch (e) { + // Subscription may already exist or workspace may not support it. + } + } + + void _handleForegroundMessage(RemoteMessage message) { + final notification = message.notification; + if (notification == null) return; + + _localNotifications.show( + notification.hashCode, + notification.title ?? 'Huly', + notification.body, + const NotificationDetails( + iOS: DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ), + android: AndroidNotificationDetails( + 'huly_notifications', + 'Huly Notifications', + importance: Importance.high, + priority: Priority.high, + ), + ), + payload: jsonEncode(message.data), + ); + } +} + +/// Background message handler (must be top-level function). +@pragma('vm:entry-point') +Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { + // Background messages are automatically displayed by the system. +} diff --git a/mobile/lib/services/share_handler.dart b/mobile/lib/services/share_handler.dart new file mode 100644 index 00000000000..8c1f76ef7e8 --- /dev/null +++ b/mobile/lib/services/share_handler.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/services.dart'; + +/// Reads shared content from the iOS App Group UserDefaults. +/// The native share extension writes JSON to the app group, +/// and this service reads/clears it. +class ShareHandler { + static const _channel = MethodChannel('com.ledoweb.huly/share'); + + /// Check for pending shared content from the share extension. + /// Returns null if nothing is pending. + static Future getPendingShare() async { + try { + final result = await _channel.invokeMethod('getPendingShare'); + if (result == null || result.isEmpty) return null; + final json = jsonDecode(result) as Map; + return SharedContent( + text: json['text'] as String?, + url: json['url'] as String?, + ); + } on PlatformException { + return null; + } + } + + /// Clear pending share data after it has been consumed. + static Future clearPendingShare() async { + try { + await _channel.invokeMethod('clearPendingShare'); + } on PlatformException { + // Ignore — share data may already be cleared. + } + } +} + +class SharedContent { + final String? text; + final String? url; + + SharedContent({this.text, this.url}); + + /// Build a title for the issue from shared content. + String get suggestedTitle { + if (text != null && text!.isNotEmpty) { + final firstLine = text!.split('\n').first.trim(); + return firstLine.length > 100 + ? '${firstLine.substring(0, 100)}...' + : firstLine; + } + if (url != null) return 'Shared: $url'; + return ''; + } + + /// Build a description for the issue from shared content. + String get suggestedDescription { + final parts = []; + if (text != null && text!.isNotEmpty) parts.add(text!); + if (url != null && url!.isNotEmpty) parts.add(url!); + return parts.join('\n\n'); + } +} diff --git a/mobile/linux/.gitignore b/mobile/linux/.gitignore new file mode 100644 index 00000000000..d3896c98444 --- /dev/null +++ b/mobile/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/mobile/linux/CMakeLists.txt b/mobile/linux/CMakeLists.txt new file mode 100644 index 00000000000..7e57b5f4c7d --- /dev/null +++ b/mobile/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 "huly_mobile") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.ledoweb.huly_mobile") + +# 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/mobile/linux/flutter/CMakeLists.txt b/mobile/linux/flutter/CMakeLists.txt new file mode 100644 index 00000000000..d5bd01648a9 --- /dev/null +++ b/mobile/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/mobile/linux/flutter/ephemeral/.plugin_symlinks/desktop_webview_window b/mobile/linux/flutter/ephemeral/.plugin_symlinks/desktop_webview_window new file mode 120000 index 00000000000..7617f8de6e5 --- /dev/null +++ b/mobile/linux/flutter/ephemeral/.plugin_symlinks/desktop_webview_window @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/desktop_webview_window-0.2.3/ \ No newline at end of file diff --git a/mobile/linux/flutter/ephemeral/.plugin_symlinks/file_selector_linux b/mobile/linux/flutter/ephemeral/.plugin_symlinks/file_selector_linux new file mode 120000 index 00000000000..f8a7f01db59 --- /dev/null +++ b/mobile/linux/flutter/ephemeral/.plugin_symlinks/file_selector_linux @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.4/ \ No newline at end of file diff --git a/mobile/linux/flutter/ephemeral/.plugin_symlinks/flutter_local_notifications_linux b/mobile/linux/flutter/ephemeral/.plugin_symlinks/flutter_local_notifications_linux new file mode 120000 index 00000000000..c7cb947541b --- /dev/null +++ b/mobile/linux/flutter/ephemeral/.plugin_symlinks/flutter_local_notifications_linux @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/flutter_local_notifications_linux-5.0.0/ \ No newline at end of file diff --git a/mobile/linux/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_linux b/mobile/linux/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_linux new file mode 120000 index 00000000000..e26051f9c47 --- /dev/null +++ b/mobile/linux/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_linux @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/ \ No newline at end of file diff --git a/mobile/linux/flutter/ephemeral/.plugin_symlinks/flutter_web_auth_2 b/mobile/linux/flutter/ephemeral/.plugin_symlinks/flutter_web_auth_2 new file mode 120000 index 00000000000..58a85a34a7a --- /dev/null +++ b/mobile/linux/flutter/ephemeral/.plugin_symlinks/flutter_web_auth_2 @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/flutter_web_auth_2-4.1.0/ \ No newline at end of file diff --git a/mobile/linux/flutter/ephemeral/.plugin_symlinks/image_picker_linux b/mobile/linux/flutter/ephemeral/.plugin_symlinks/image_picker_linux new file mode 120000 index 00000000000..de236c1d73b --- /dev/null +++ b/mobile/linux/flutter/ephemeral/.plugin_symlinks/image_picker_linux @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/image_picker_linux-0.2.2/ \ No newline at end of file diff --git a/mobile/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux b/mobile/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux new file mode 120000 index 00000000000..8f5944efb43 --- /dev/null +++ b/mobile/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/ \ No newline at end of file diff --git a/mobile/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux b/mobile/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux new file mode 120000 index 00000000000..5a5e20a2ed8 --- /dev/null +++ b/mobile/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.2/ \ No newline at end of file diff --git a/mobile/linux/flutter/ephemeral/.plugin_symlinks/window_to_front b/mobile/linux/flutter/ephemeral/.plugin_symlinks/window_to_front new file mode 120000 index 00000000000..aaafe2063ab --- /dev/null +++ b/mobile/linux/flutter/ephemeral/.plugin_symlinks/window_to_front @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/window_to_front-0.0.3/ \ No newline at end of file diff --git a/mobile/linux/flutter/generated_plugin_registrant.cc b/mobile/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000000..1d8dcf99140 --- /dev/null +++ b/mobile/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,31 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) desktop_webview_window_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopWebviewWindowPlugin"); + desktop_webview_window_plugin_register_with_registrar(desktop_webview_window_registrar); + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + 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); + g_autoptr(FlPluginRegistrar) window_to_front_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowToFrontPlugin"); + window_to_front_plugin_register_with_registrar(window_to_front_registrar); +} diff --git a/mobile/linux/flutter/generated_plugin_registrant.h b/mobile/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000000..e0f0a47bc08 --- /dev/null +++ b/mobile/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/mobile/linux/flutter/generated_plugins.cmake b/mobile/linux/flutter/generated_plugins.cmake new file mode 100644 index 00000000000..29b443b60f8 --- /dev/null +++ b/mobile/linux/flutter/generated_plugins.cmake @@ -0,0 +1,28 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + desktop_webview_window + file_selector_linux + flutter_secure_storage_linux + url_launcher_linux + window_to_front +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/mobile/linux/runner/CMakeLists.txt b/mobile/linux/runner/CMakeLists.txt new file mode 100644 index 00000000000..e97dabc7028 --- /dev/null +++ b/mobile/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/mobile/linux/runner/main.cc b/mobile/linux/runner/main.cc new file mode 100644 index 00000000000..e7c5c543703 --- /dev/null +++ b/mobile/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/mobile/linux/runner/my_application.cc b/mobile/linux/runner/my_application.cc new file mode 100644 index 00000000000..28de4b8e94d --- /dev/null +++ b/mobile/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, "huly_mobile"); + 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, "huly_mobile"); + } + + 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/mobile/linux/runner/my_application.h b/mobile/linux/runner/my_application.h new file mode 100644 index 00000000000..db16367a77d --- /dev/null +++ b/mobile/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/mobile/macos/.gitignore b/mobile/macos/.gitignore new file mode 100644 index 00000000000..746adbb6b9e --- /dev/null +++ b/mobile/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/mobile/macos/Flutter/Flutter-Debug.xcconfig b/mobile/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 00000000000..4b81f9b2d20 --- /dev/null +++ b/mobile/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/mobile/macos/Flutter/Flutter-Release.xcconfig b/mobile/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 00000000000..5caa9d1579e --- /dev/null +++ b/mobile/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/mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/mobile/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 00000000000..e26bab5d8d5 --- /dev/null +++ b/mobile/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,30 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import desktop_webview_window +import file_selector_macos +import firebase_core +import firebase_messaging +import flutter_local_notifications +import flutter_secure_storage_macos +import flutter_web_auth_2 +import local_auth_darwin +import url_launcher_macos +import window_to_front + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DesktopWebviewWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewWindowPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + FlutterWebAuth2Plugin.register(with: registry.registrar(forPlugin: "FlutterWebAuth2Plugin")) + LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WindowToFrontPlugin.register(with: registry.registrar(forPlugin: "WindowToFrontPlugin")) +} diff --git a/mobile/macos/Flutter/ephemeral/Flutter-Generated.xcconfig b/mobile/macos/Flutter/ephemeral/Flutter-Generated.xcconfig new file mode 100644 index 00000000000..0d54e14567d --- /dev/null +++ b/mobile/macos/Flutter/ephemeral/Flutter-Generated.xcconfig @@ -0,0 +1,11 @@ +// This is a generated file; do not edit or check into version control. +FLUTTER_ROOT=/opt/homebrew/share/flutter +FLUTTER_APPLICATION_PATH=/Users/dkendall/projects/ledoent/huly-platform/mobile +COCOAPODS_PARALLEL_CODE_SIGN=true +FLUTTER_BUILD_DIR=build +FLUTTER_BUILD_NAME=0.2.0 +FLUTTER_BUILD_NUMBER=2 +DART_OBFUSCATION=false +TRACK_WIDGET_CREATION=true +TREE_SHAKE_ICONS=false +PACKAGE_CONFIG=.dart_tool/package_config.json diff --git a/mobile/macos/Flutter/ephemeral/flutter_export_environment.sh b/mobile/macos/Flutter/ephemeral/flutter_export_environment.sh new file mode 100755 index 00000000000..b42aa7bfa92 --- /dev/null +++ b/mobile/macos/Flutter/ephemeral/flutter_export_environment.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# This is a generated file; do not edit or check into version control. +export "FLUTTER_ROOT=/opt/homebrew/share/flutter" +export "FLUTTER_APPLICATION_PATH=/Users/dkendall/projects/ledoent/huly-platform/mobile" +export "COCOAPODS_PARALLEL_CODE_SIGN=true" +export "FLUTTER_BUILD_DIR=build" +export "FLUTTER_BUILD_NAME=0.2.0" +export "FLUTTER_BUILD_NUMBER=2" +export "DART_OBFUSCATION=false" +export "TRACK_WIDGET_CREATION=true" +export "TREE_SHAKE_ICONS=false" +export "PACKAGE_CONFIG=.dart_tool/package_config.json" diff --git a/mobile/macos/Podfile b/mobile/macos/Podfile new file mode 100644 index 00000000000..ff5ddb3b8bd --- /dev/null +++ b/mobile/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/mobile/macos/Runner.xcodeproj/project.pbxproj b/mobile/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..a716813fd4f --- /dev/null +++ b/mobile/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* huly_mobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "huly_mobile.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* huly_mobile.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* huly_mobile.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.ledoweb.hulyMobile.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/huly_mobile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/huly_mobile"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.ledoweb.hulyMobile.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/huly_mobile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/huly_mobile"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.ledoweb.hulyMobile.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/huly_mobile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/huly_mobile"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000000..18d981003d6 --- /dev/null +++ b/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000000..23121369c50 --- /dev/null +++ b/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/macos/Runner.xcworkspace/contents.xcworkspacedata b/mobile/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000000..1d526a16ed0 --- /dev/null +++ b/mobile/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000000..18d981003d6 --- /dev/null +++ b/mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/macos/Runner/AppDelegate.swift b/mobile/macos/Runner/AppDelegate.swift new file mode 100644 index 00000000000..b3c17614122 --- /dev/null +++ b/mobile/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/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000000..a2ec33f19f1 --- /dev/null +++ b/mobile/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/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 00000000000..82b6f9d9a33 Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 00000000000..13b35eba55c Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 00000000000..0a3f5fa40fb Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 00000000000..bdb57226d5f Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 00000000000..f083318e09c Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 00000000000..326c0e72c9d Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 00000000000..2f1632cfddf Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/mobile/macos/Runner/Base.lproj/MainMenu.xib b/mobile/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 00000000000..80e867a4e06 --- /dev/null +++ b/mobile/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/macos/Runner/Configs/AppInfo.xcconfig b/mobile/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 00000000000..e23ec5f6a7a --- /dev/null +++ b/mobile/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 = huly_mobile + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.ledoweb.hulyMobile + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.ledoweb. All rights reserved. diff --git a/mobile/macos/Runner/Configs/Debug.xcconfig b/mobile/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 00000000000..36b0fd9464f --- /dev/null +++ b/mobile/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/mobile/macos/Runner/Configs/Release.xcconfig b/mobile/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 00000000000..dff4f49561c --- /dev/null +++ b/mobile/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/mobile/macos/Runner/Configs/Warnings.xcconfig b/mobile/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 00000000000..42bcbf4780b --- /dev/null +++ b/mobile/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/mobile/macos/Runner/DebugProfile.entitlements b/mobile/macos/Runner/DebugProfile.entitlements new file mode 100644 index 00000000000..dddb8a30c85 --- /dev/null +++ b/mobile/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/mobile/macos/Runner/Info.plist b/mobile/macos/Runner/Info.plist new file mode 100644 index 00000000000..4789daa6a44 --- /dev/null +++ b/mobile/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/mobile/macos/Runner/MainFlutterWindow.swift b/mobile/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 00000000000..3cc05eb2349 --- /dev/null +++ b/mobile/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/mobile/macos/Runner/Release.entitlements b/mobile/macos/Runner/Release.entitlements new file mode 100644 index 00000000000..852fa1a4728 --- /dev/null +++ b/mobile/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/mobile/macos/RunnerTests/RunnerTests.swift b/mobile/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000000..61f3bd1fc50 --- /dev/null +++ b/mobile/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/mobile/pubspec.lock b/mobile/pubspec.lock new file mode 100644 index 00000000000..c85316b28f7 --- /dev/null +++ b/mobile/pubspec.lock @@ -0,0 +1,1258 @@ +# 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" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 + url: "https://pub.dev" + source: hosted + version: "1.3.59" + 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: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + 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: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" + url: "https://pub.dev" + source: hosted + version: "8.12.4" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + 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: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + 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" + 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" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + 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" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + desktop_webview_window: + dependency: transitive + description: + name: desktop_webview_window + sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0" + url: "https://pub.dev" + source: hosted + version: "0.2.3" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + 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_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" + url: "https://pub.dev" + source: hosted + version: "3.15.2" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" + url: "https://pub.dev" + source: hosted + version: "2.24.1" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc" + url: "https://pub.dev" + source: hosted + version: "15.2.10" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754" + url: "https://pub.dev" + source: hosted + version: "4.6.10" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390" + url: "https://pub.dev" + source: hosted + version: "3.10.10" + 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_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610 + url: "https://pub.dev" + source: hosted + version: "18.0.1" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52" + url: "https://pub.dev" + source: hosted + version: "8.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_auth_2: + dependency: "direct main" + description: + name: flutter_web_auth_2 + sha256: "3c14babeaa066c371f3a743f204dd0d348b7d42ffa6fae7a9847a521aff33696" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + flutter_web_auth_2_platform_interface: + dependency: transitive + description: + name: flutter_web_auth_2_platform_interface + sha256: c63a472c8070998e4e422f6b34a17070e60782ac442107c70000dd1bed645f4d + url: "https://pub.dev" + source: hosted + version: "4.1.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" + 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" + 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: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + http: + dependency: transitive + 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" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "9eae0cbd672549dacc18df855c2a23782afe4854ada5190b7d63b30ee0b0d3fd" + url: "https://pub.dev" + source: hosted + version: "0.8.13+15" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: transitive + 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" + 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: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467 + url: "https://pub.dev" + source: hosted + version: "1.0.56" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49" + url: "https://pub.dev" + source: hosted + version: "1.6.1" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5 + url: "https://pub.dev" + source: hosted + version: "1.0.11" + 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: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + 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" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + 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_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + 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" + 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" + 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" + receive_sharing_intent: + dependency: "direct main" + description: + name: receive_sharing_intent + sha256: ec76056e4d258ad708e76d85591d933678625318e411564dcb9059048ca3a593 + url: "https://pub.dev" + source: hosted + version: "1.8.1" + 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" + 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" + 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" + 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" + 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: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" + 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: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + 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: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + 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_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + 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: "direct main" + 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" + window_to_front: + dependency: transitive + description: + name: window_to_front + sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee" + url: "https://pub.dev" + source: hosted + version: "0.0.3" + 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.11.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml new file mode 100644 index 00000000000..9760e134166 --- /dev/null +++ b/mobile/pubspec.yaml @@ -0,0 +1,43 @@ +name: huly_mobile +description: Huly mobile client for iOS +publish_to: 'none' +version: 0.2.0+2 + +environment: + sdk: ^3.11.3 + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + flutter_riverpod: ^2.5.0 + riverpod_annotation: ^2.3.0 + dio: ^5.4.0 + flutter_secure_storage: ^9.0.0 + go_router: ^14.0.0 + freezed_annotation: ^2.4.0 + json_annotation: ^4.8.0 + receive_sharing_intent: ^1.8.0 + uuid: ^4.3.0 + flutter_web_auth_2: ^4.0.0 + image_picker: ^1.0.0 + local_auth: ^2.1.0 + web_socket_channel: ^3.0.0 + firebase_core: ^3.0.0 + firebase_messaging: ^15.0.0 + flutter_local_notifications: ^18.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + build_runner: ^2.4.0 + freezed: ^2.4.0 + json_serializable: ^6.7.0 + riverpod_generator: ^2.4.0 + mocktail: ^1.0.0 + +flutter: + uses-material-design: true + assets: + - assets/ diff --git a/mobile/test/api/account_client_test.dart b/mobile/test/api/account_client_test.dart new file mode 100644 index 00000000000..9eac065246f --- /dev/null +++ b/mobile/test/api/account_client_test.dart @@ -0,0 +1,138 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:huly_mobile/core/api/account_client.dart'; +import 'package:huly_mobile/core/models/login_info.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockDio extends Mock implements Dio {} + +class MockResponse extends Mock implements Response {} + +void main() { + late MockDio mockDio; + late AccountClient client; + + setUp(() { + mockDio = MockDio(); + client = AccountClient(accountsUrl: 'https://example.com/accounts', dio: mockDio); + registerFallbackValue(Options()); + }); + + group('AccountClient', () { + test('login returns LoginInfo on success', () async { + final response = MockResponse(); + when(() => response.data).thenReturn({ + 'result': { + 'token': 'test-token', + 'account': 'acc-123', + }, + }); + when(() => mockDio.post( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + )).thenAnswer((_) async => response); + + final result = await client.login('test@example.com', 'password'); + + expect(result, isA()); + expect(result.token, 'test-token'); + expect(result.accountId, 'acc-123'); + expect(result.tfaRequired, false); + }); + + test('login throws AccountRpcError on error response', () async { + final response = MockResponse(); + when(() => response.data).thenReturn({ + 'error': { + 'code': 401, + 'message': 'Invalid credentials', + }, + }); + when(() => mockDio.post( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + )).thenAnswer((_) async => response); + + expect( + () => client.login('test@example.com', 'wrong'), + throwsA(isA()), + ); + }); + + test('getUserWorkspaces returns list', () async { + client.setToken('test-token'); + final response = MockResponse(); + when(() => response.data).thenReturn({ + 'result': [ + { + 'workspaceUrl': 'my-workspace', + 'workspaceName': 'My Workspace', + }, + ], + }); + when(() => mockDio.post( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + )).thenAnswer((_) async => response); + + final workspaces = await client.getUserWorkspaces(); + + expect(workspaces, hasLength(1)); + expect(workspaces.first.workspaceName, 'My Workspace'); + expect(workspaces.first.workspaceUrl, 'my-workspace'); + }); + + test('selectWorkspace returns WorkspaceLoginInfo', () async { + client.setToken('test-token'); + final response = MockResponse(); + when(() => response.data).thenReturn({ + 'result': { + 'token': 'ws-token', + 'endpoint': 'wss://ws.example.com', + 'workspace': 'ws-123', + }, + }); + when(() => mockDio.post( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + )).thenAnswer((_) async => response); + + final wsLogin = await client.selectWorkspace('my-workspace'); + + expect(wsLogin.token, 'ws-token'); + expect(wsLogin.endpoint, 'wss://ws.example.com'); + expect(wsLogin.workspaceId, 'ws-123'); + }); + }); + + group('getAccountsUrl', () { + test('extracts ACCOUNTS_URL from config.json', () async { + final dio = MockDio(); + final response = MockResponse(); + when(() => response.data).thenReturn({ + 'ACCOUNTS_URL': 'https://example.com/accounts', + }); + when(() => dio.get(any())).thenAnswer((_) async => response); + + final url = await AccountClient.getAccountsUrl('https://example.com', dio: dio); + + expect(url, 'https://example.com/accounts'); + }); + + test('throws when ACCOUNTS_URL missing', () async { + final dio = MockDio(); + final response = MockResponse(); + when(() => response.data).thenReturn({}); + when(() => dio.get(any())).thenAnswer((_) async => response); + + expect( + () => AccountClient.getAccountsUrl('https://example.com', dio: dio), + throwsA(isA()), + ); + }); + }); +} diff --git a/mobile/test/api/rest_client_test.dart b/mobile/test/api/rest_client_test.dart new file mode 100644 index 00000000000..d443cfd7113 --- /dev/null +++ b/mobile/test/api/rest_client_test.dart @@ -0,0 +1,50 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:huly_mobile/core/api/rest_client.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockDio extends Mock implements Dio { + @override + BaseOptions get options => BaseOptions(); + + @override + set options(BaseOptions value) {} +} + +void main() { + group('HulyRestClient', () { + test('converts ws:// endpoint to http://', () { + final client = HulyRestClient( + endpoint: 'ws://localhost:3333', + workspaceId: 'ws-1', + token: 'tok', + ); + expect(client.baseUrl, 'http://localhost:3333'); + }); + + test('converts wss:// endpoint to https://', () { + final client = HulyRestClient( + endpoint: 'wss://huly.example.com', + workspaceId: 'ws-1', + token: 'tok', + ); + expect(client.baseUrl, 'https://huly.example.com'); + }); + + test('leaves http:// endpoint unchanged', () { + final client = HulyRestClient( + endpoint: 'https://huly.example.com', + workspaceId: 'ws-1', + token: 'tok', + ); + expect(client.baseUrl, 'https://huly.example.com'); + }); + }); + + group('RateLimitException', () { + test('toString includes retry time', () { + final ex = RateLimitException(retryAfterSeconds: 30); + expect(ex.toString(), contains('30')); + }); + }); +} diff --git a/mobile/test/features/auth/auth_provider_test.dart b/mobile/test/features/auth/auth_provider_test.dart new file mode 100644 index 00000000000..82911da8aeb --- /dev/null +++ b/mobile/test/features/auth/auth_provider_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:huly_mobile/core/models/login_info.dart'; +import 'package:huly_mobile/features/auth/auth_provider.dart'; + +void main() { + group('AuthState', () { + test('default state is unauthenticated', () { + const state = AuthState(); + expect(state.status, AuthStatus.unauthenticated); + expect(state.serverUrl, isNull); + expect(state.loginInfo, isNull); + expect(state.workspaceLogin, isNull); + expect(state.loading, false); + expect(state.error, isNull); + }); + + test('copyWith preserves unchanged fields', () { + const state = AuthState( + status: AuthStatus.loggedIn, + serverUrl: 'https://huly.example.com', + ); + final updated = state.copyWith(loading: true); + expect(updated.status, AuthStatus.loggedIn); + expect(updated.serverUrl, 'https://huly.example.com'); + expect(updated.loading, true); + }); + + test('copyWith clears error when set to null', () { + const state = AuthState(error: 'something went wrong'); + final updated = state.copyWith(error: null); + expect(updated.error, isNull); + }); + }); + + group('LoginInfo model', () { + test('fromJson parses correctly', () { + final info = LoginInfo.fromJson({ + 'token': 'abc', + 'account': 'acc-1', + }); + expect(info.token, 'abc'); + expect(info.accountId, 'acc-1'); + expect(info.tfaRequired, false); + }); + + test('fromJson with tfaRequired', () { + final info = LoginInfo.fromJson({ + 'token': 'abc', + 'account': 'acc-1', + 'tfaRequired': true, + }); + expect(info.tfaRequired, true); + }); + }); + + group('WorkspaceLoginInfo model', () { + test('fromJson parses correctly', () { + final info = WorkspaceLoginInfo.fromJson({ + 'token': 'ws-tok', + 'endpoint': 'wss://ws.example.com', + 'workspace': 'ws-123', + }); + expect(info.token, 'ws-tok'); + expect(info.endpoint, 'wss://ws.example.com'); + expect(info.workspaceId, 'ws-123'); + expect(info.workspaceUrl, isNull); + }); + }); +} diff --git a/mobile/test/features/chat/chat_provider_test.dart b/mobile/test/features/chat/chat_provider_test.dart new file mode 100644 index 00000000000..be5db0e81be --- /dev/null +++ b/mobile/test/features/chat/chat_provider_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:huly_mobile/core/api/rest_client.dart'; +import 'package:huly_mobile/features/auth/auth_provider.dart'; +import 'package:huly_mobile/features/chat/chat_provider.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockRestClient extends Mock implements HulyRestClient {} + +void main() { + group('ChatProvider', () { + late MockRestClient mockRestClient; + + setUp(() { + mockRestClient = MockRestClient(); + }); + + ProviderContainer createContainer({ + List overrides = const [], + }) { + final container = ProviderContainer(overrides: overrides); + addTearDown(container.dispose); + return container; + } + + test('channelsProvider fetches channels and DMs and sorts them', () async { + final container = createContainer( + overrides: [ + restClientProvider.overrideWithValue(mockRestClient), + ], + ); + + when(() => mockRestClient.findAll('chunter:class:Channel')).thenAnswer( + (_) async => [ + { + '_id': 'c1', + '_class': 'chunter:class:Channel', + 'name': 'Channel 1', + 'modifiedOn': 100, + }, + ], + ); + + when(() => mockRestClient.findAll('chunter:class:DirectMessage')).thenAnswer( + (_) async => [ + { + '_id': 'dm1', + '_class': 'chunter:class:DirectMessage', + 'name': 'DM 1', + 'modifiedOn': 200, + }, + ], + ); + + final channels = await container.read(channelsProvider.future); + expect(channels.length, 2); + // Sorted by modifiedOn descending + expect(channels[0].id, 'dm1'); + expect(channels[1].id, 'c1'); + }); + + test('channelMessagesProvider fetches messages for channel', () async { + final container = createContainer( + overrides: [ + restClientProvider.overrideWithValue(mockRestClient), + ], + ); + + when(() => mockRestClient.findAll( + 'chunter:class:ChatMessage', + query: {'attachedTo': 'c1'}, + options: {'sort': {'createdOn': 1}, 'limit': 200}, + )).thenAnswer( + (_) async => [ + { + '_id': 'm1', + '_class': 'chunter:class:ChatMessage', + 'attachedTo': 'c1', + 'message': 'Hello', + 'createdOn': 1000, + }, + ], + ); + + final messages = await container.read(channelMessagesProvider('c1').future); + expect(messages.length, 1); + expect(messages[0].message, 'Hello'); + }); + }); +} diff --git a/mobile/test/features/chat/message_thread_screen_test.dart b/mobile/test/features/chat/message_thread_screen_test.dart new file mode 100644 index 00000000000..2bfca65c36c --- /dev/null +++ b/mobile/test/features/chat/message_thread_screen_test.dart @@ -0,0 +1,112 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:huly_mobile/core/api/rest_client.dart'; +import 'package:huly_mobile/core/models/activity.dart'; +import 'package:huly_mobile/core/models/member.dart'; +import 'package:huly_mobile/features/auth/auth_provider.dart'; +import 'package:huly_mobile/features/chat/chat_provider.dart'; +import 'package:huly_mobile/features/chat/message_thread_screen.dart'; +import 'package:huly_mobile/features/issues/issue_provider.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockRestClient extends Mock implements HulyRestClient {} + +void main() { + setUpAll(() { + registerFallbackValue({}); + }); + + group('MessageThreadScreen', () { + late MockRestClient mockRestClient; + + setUp(() { + mockRestClient = MockRestClient(); + }); + + testWidgets('shows loading indicator', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + channelMessagesProvider('c1').overrideWith((ref) => Completer>().future), + membersProvider.overrideWith((ref) => Completer>().future), + ], + child: const MaterialApp( + home: MessageThreadScreen(channelId: 'c1', channelName: 'General'), + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsWidgets); + }); + + testWidgets('shows messages and author names', (tester) async { + final messages = [ + ChatMessage( + id: 'm1', + className: 'chunter:class:ChatMessage', + attachedTo: 'c1', + message: '

Hello world

', + createdBy: 'u1', + createdOn: 1000, + ), + ]; + + final members = { + 'u1': const Member(id: 'u1', name: 'Alice'), + }; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + channelMessagesProvider('c1').overrideWith((ref) => messages), + membersProvider.overrideWith((ref) => members), + ], + child: const MaterialApp( + home: MessageThreadScreen(channelId: 'c1', channelName: 'General'), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('General'), findsOneWidget); + expect(find.text('Alice'), findsOneWidget); + expect(find.text('Hello world'), findsOneWidget); + }); + + testWidgets('sends a message', (tester) async { + final messages = []; + final members = {}; + + when(() => mockRestClient.tx(any())).thenAnswer((_) async => {}); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + channelMessagesProvider('c1').overrideWith((ref) => messages), + membersProvider.overrideWith((ref) => members), + restClientProvider.overrideWithValue(mockRestClient), + ], + child: const MaterialApp( + home: MessageThreadScreen(channelId: 'c1', channelName: 'General'), + ), + ), + ); + + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'New message'); + await tester.tap(find.byIcon(Icons.send)); + + // Wait for the tx response and the 300ms scroll timer + await tester.pump(); // Start send + await tester.pump(const Duration(milliseconds: 400)); // Finish scroll delay + await tester.pumpAndSettle(); // Finish animations + + verify(() => mockRestClient.tx(any())).called(1); + expect(find.text('New message'), findsNothing); + }); + }); +} diff --git a/mobile/test/features/issues/create_issue_screen_test.dart b/mobile/test/features/issues/create_issue_screen_test.dart new file mode 100644 index 00000000000..075c573c742 --- /dev/null +++ b/mobile/test/features/issues/create_issue_screen_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:huly_mobile/core/api/rest_client.dart'; +import 'package:huly_mobile/core/models/project.dart'; +import 'package:huly_mobile/features/auth/auth_provider.dart'; +import 'package:huly_mobile/features/create_issue/create_issue_screen.dart'; +import 'package:huly_mobile/features/issues/issue_provider.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockRestClient extends Mock implements HulyRestClient {} + +void main() { + setUpAll(() { + registerFallbackValue({}); + }); + + group('CreateIssueScreen', () { + late MockRestClient mockRestClient; + + setUp(() { + mockRestClient = MockRestClient(); + }); + + testWidgets('shows initial values and project picker', (tester) async { + final project = Project( + id: 'p1', + className: 'tracker:class:Project', + name: 'Project 1', + identifier: 'P1', + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + projectsProvider.overrideWith((ref) => [project]), + ], + child: const MaterialApp( + home: CreateIssueScreen( + initialTitle: 'Bug', + initialDescription: 'Broken', + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Bug'), findsOneWidget); + expect(find.text('Broken'), findsOneWidget); + expect(find.text('Project 1'), findsOneWidget); + }); + + testWidgets('validates title is required', (tester) async { + final project = Project( + id: 'p1', + className: 'tracker:class:Project', + name: 'Project 1', + identifier: 'P1', + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + projectsProvider.overrideWith((ref) => [project]), + ], + child: const MaterialApp(home: CreateIssueScreen()), + ), + ); + + await tester.pumpAndSettle(); + + await tester.tap(find.text('Create Issue')); + await tester.pump(); + + expect(find.text('Required'), findsOneWidget); + }); + + testWidgets('submits form and calls API', (tester) async { + final project = Project( + id: 'p1', + className: 'tracker:class:Project', + name: 'Project 1', + identifier: 'P1', + defaultIssueStatus: 'open', + ); + + when(() => mockRestClient.tx(any())).thenAnswer((_) async => {}); + + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute(path: '/', builder: (_, __) => const Scaffold(body: Text('Home'))), + GoRoute(path: '/create', builder: (_, __) => const CreateIssueScreen()), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + projectsProvider.overrideWith((ref) => [project]), + restClientProvider.overrideWithValue(mockRestClient), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + + // Navigate to /create + router.push('/create'); + await tester.pumpAndSettle(); + + await tester.enterText(find.widgetWithText(TextFormField, 'Title'), 'New Task'); + await tester.enterText(find.widgetWithText(TextFormField, 'Description'), 'Description text'); + + await tester.tap(find.text('Create Issue')); + await tester.pumpAndSettle(); + + verify(() => mockRestClient.tx(any())).called(1); + expect(find.text('Home'), findsOneWidget); + }); + }); +} diff --git a/mobile/test/features/issues/issue_detail_screen_test.dart b/mobile/test/features/issues/issue_detail_screen_test.dart new file mode 100644 index 00000000000..9602b3e6759 --- /dev/null +++ b/mobile/test/features/issues/issue_detail_screen_test.dart @@ -0,0 +1,149 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:huly_mobile/core/api/rest_client.dart'; +import 'package:huly_mobile/core/models/activity.dart'; +import 'package:huly_mobile/core/models/attachment.dart'; +import 'package:huly_mobile/core/models/issue.dart'; +import 'package:huly_mobile/core/models/issue_status.dart'; +import 'package:huly_mobile/core/models/member.dart'; +import 'package:huly_mobile/features/auth/auth_provider.dart'; +import 'package:huly_mobile/features/issues/issue_detail_screen.dart'; +import 'package:huly_mobile/features/issues/issue_provider.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockRestClient extends Mock implements HulyRestClient {} + +void main() { + setUpAll(() { + registerFallbackValue({}); + }); + + group('IssueDetailScreen', () { + late MockRestClient mockRestClient; + + setUp(() { + mockRestClient = MockRestClient(); + }); + + testWidgets('shows loading indicator', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + issueDetailProvider('i1').overrideWith((ref) => Completer().future), + ], + child: const MaterialApp(home: IssueDetailScreen(issueId: 'i1')), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('shows issue details and components', (tester) async { + final issue = Issue( + id: 'i1', + className: 'tracker:class:Issue', + title: 'Detailed Issue', + priority: 1, + number: 1, + identifier: 'P1-1', + status: 'open', + space: 'p1', + description: '

Some description

', + assignee: 'u1', + ); + + final members = { + 'u1': const Member(id: 'u1', name: 'Alice'), + }; + + final statuses = [ + const IssueStatus(id: 'open', name: 'Open'), + ]; + + final attachments = [ + const Attachment( + id: 'a1', + name: 'test.jpg', + size: 1024, + type: 'image/jpeg', + file: 'blob1', + attachedTo: 'i1', + ), + ]; + + final messages = [ + ChatMessage( + id: 'm1', + className: 'chunter:class:ChatMessage', + attachedTo: 'i1', + message: '

Comment 1

', + createdBy: 'u1', + createdOn: 1000, + ), + ]; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + issueDetailProvider('i1').overrideWith((ref) => issue), + issueStatusesProvider.overrideWith((ref) => statuses), + membersProvider.overrideWith((ref) => members), + activityProvider('i1').overrideWith((ref) => messages), + attachmentsProvider('i1').overrideWith((ref) => attachments), + ], + child: const MaterialApp(home: IssueDetailScreen(issueId: 'i1')), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('P1-1'), findsOneWidget); + expect(find.text('Detailed Issue'), findsOneWidget); + expect(find.text('Alice'), findsWidgets); + expect(find.text('Open'), findsOneWidget); + expect(find.text('Some description'), findsOneWidget); + expect(find.text('test.jpg'), findsOneWidget); + expect(find.text('Comment 1'), findsOneWidget); + }); + + testWidgets('posts a comment', (tester) async { + final issue = Issue( + id: 'i1', + className: 'tracker:class:Issue', + title: 'Detailed Issue', + priority: 1, + number: 1, + identifier: 'P1-1', + status: 'open', + space: 'p1', + ); + + when(() => mockRestClient.tx(any())).thenAnswer((_) async => {}); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + issueDetailProvider('i1').overrideWith((ref) => issue), + issueStatusesProvider.overrideWith((ref) => []), + membersProvider.overrideWith((ref) => {}), + activityProvider('i1').overrideWith((ref) => []), + attachmentsProvider('i1').overrideWith((ref) => []), + restClientProvider.overrideWithValue(mockRestClient), + ], + child: const MaterialApp(home: IssueDetailScreen(issueId: 'i1')), + ), + ); + + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'New comment'); + await tester.tap(find.byIcon(Icons.send)); + await tester.pump(); + + verify(() => mockRestClient.tx(any())).called(1); + expect(find.text('New comment'), findsNothing); + }); + }); +} diff --git a/mobile/test/features/issues/issue_list_screen_test.dart b/mobile/test/features/issues/issue_list_screen_test.dart new file mode 100644 index 00000000000..863ebe451ce --- /dev/null +++ b/mobile/test/features/issues/issue_list_screen_test.dart @@ -0,0 +1,131 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:huly_mobile/core/models/issue.dart'; +import 'package:huly_mobile/core/models/project.dart'; +import 'package:huly_mobile/features/issues/issue_list_screen.dart'; +import 'package:huly_mobile/features/issues/issue_provider.dart'; + +void main() { + group('IssueListScreen', () { + testWidgets('shows loading indicator while projects are loading', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + projectsProvider.overrideWith((ref) => Completer>().future), + ], + child: const MaterialApp(home: IssueListScreen()), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('shows error message on failure', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + projectsProvider.overrideWith((ref) => Future.error('Failed to load')), + ], + child: const MaterialApp(home: IssueListScreen()), + ), + ); + + await tester.pump(); // Let the error propagate + + expect(find.textContaining('Failed to load'), findsOneWidget); + }); + + testWidgets('shows project tabs and issues', (tester) async { + final project = Project( + id: 'p1', + className: 'tracker:class:Project', + name: 'Project One', + identifier: 'P1', + ); + + final issue = Issue( + id: 'i1', + className: 'tracker:class:Issue', + title: 'Fix the bug', + priority: 1, + number: 1, + identifier: 'P1-1', + status: 'open', + space: 'p1', + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + projectsProvider.overrideWith((ref) => [project]), + issuesProvider('p1').overrideWith((ref) => [issue]), + ], + child: const MaterialApp(home: IssueListScreen()), + ), + ); + + // Wait for projects and issues to load + await tester.pumpAndSettle(); + + expect(find.text('P1'), findsWidgets); // Tab identifier + expect(find.text('Fix the bug'), findsOneWidget); + expect(find.text('P1-1'), findsOneWidget); + }); + + testWidgets('filters issues by search query', (tester) async { + final project = Project( + id: 'p1', + className: 'tracker:class:Project', + name: 'Project One', + identifier: 'P1', + ); + + final issues = [ + Issue( + id: 'i1', + className: 'tracker:class:Issue', + title: 'Apple', + priority: 1, + number: 1, + identifier: 'P1-1', + status: 'open', + space: 'p1', + ), + Issue( + id: 'i2', + className: 'tracker:class:Issue', + title: 'Banana', + priority: 1, + number: 2, + identifier: 'P1-2', + status: 'open', + space: 'p1', + ), + ]; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + projectsProvider.overrideWith((ref) => [project]), + issuesProvider('p1').overrideWith((ref) => issues), + ], + child: const MaterialApp(home: IssueListScreen()), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Apple'), findsOneWidget); + expect(find.text('Banana'), findsOneWidget); + + // Type into search + await tester.enterText(find.byType(TextField), 'app'); + await tester.pump(); + + expect(find.text('Apple'), findsOneWidget); + expect(find.text('Banana'), findsNothing); + }); + }); +} diff --git a/mobile/test/features/issues/issue_provider_test.dart b/mobile/test/features/issues/issue_provider_test.dart new file mode 100644 index 00000000000..f55fdda3704 --- /dev/null +++ b/mobile/test/features/issues/issue_provider_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:huly_mobile/core/api/rest_client.dart'; +import 'package:huly_mobile/core/api/realtime_provider.dart'; +import 'package:huly_mobile/features/auth/auth_provider.dart'; +import 'package:huly_mobile/features/issues/issue_provider.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockRestClient extends Mock implements HulyRestClient {} + +void main() { + group('IssueProvider', () { + late MockRestClient mockRestClient; + + setUp(() { + mockRestClient = MockRestClient(); + }); + + ProviderContainer createContainer({ + List overrides = const [], + }) { + final container = ProviderContainer(overrides: overrides); + addTearDown(container.dispose); + return container; + } + + test('projectsProvider fetches projects', () async { + final container = createContainer( + overrides: [ + restClientProvider.overrideWithValue(mockRestClient), + ], + ); + + when(() => mockRestClient.findAll('tracker:class:Project')).thenAnswer( + (_) async => [ + { + '_id': 'p1', + '_class': 'tracker:class:Project', + 'name': 'Project 1', + 'identifier': 'P1', + }, + ], + ); + + final projects = await container.read(projectsProvider.future); + expect(projects.length, 1); + expect(projects[0].name, 'Project 1'); + expect(projects[0].id, 'p1'); + }); + + test('issuesProvider fetches issues for space', () async { + final container = createContainer( + overrides: [ + restClientProvider.overrideWithValue(mockRestClient), + ], + ); + + when(() => mockRestClient.findAll( + 'tracker:class:Issue', + query: {'space': 's1'}, + options: {'sort': {'modifiedOn': -1}, 'limit': 50}, + )).thenAnswer( + (_) async => [ + { + '_id': 'i1', + '_class': 'tracker:class:Issue', + 'title': 'Issue 1', + 'priority': 1, + 'number': 1, + 'identifier': 'P1-1', + 'status': 'open', + 'space': 's1', + }, + ], + ); + + final issues = await container.read(issuesProvider('s1').future); + expect(issues.length, 1); + expect(issues[0].title, 'Issue 1'); + expect(issues[0].space, 's1'); + }); + + test('issuesProvider refreshes when dataVersion changes', () async { + final container = createContainer( + overrides: [ + restClientProvider.overrideWithValue(mockRestClient), + ], + ); + + int callCount = 0; + when(() => mockRestClient.findAll( + 'tracker:class:Issue', + query: {'space': 's1'}, + options: {'sort': {'modifiedOn': -1}, 'limit': 50}, + )).thenAnswer((_) async { + callCount++; + return [ + { + '_id': 'i$callCount', + '_class': 'tracker:class:Issue', + 'title': 'Issue $callCount', + 'priority': 1, + 'number': callCount, + 'identifier': 'P1-$callCount', + 'status': 'open', + 'space': 's1', + }, + ]; + }); + + // First fetch + var issues = await container.read(issuesProvider('s1').future); + expect(issues[0].title, 'Issue 1'); + expect(callCount, 1); + + // Bump data version + container.read(dataVersionProvider.notifier).state++; + + // Next fetch should trigger refresh because of ref.watch(dataVersionProvider) + issues = await container.read(issuesProvider('s1').future); + expect(issues[0].title, 'Issue 2'); + expect(callCount, 2); + }); + }); +} diff --git a/mobile/web/favicon.png b/mobile/web/favicon.png new file mode 100644 index 00000000000..8aaa46ac1ae Binary files /dev/null and b/mobile/web/favicon.png differ diff --git a/mobile/web/icons/Icon-192.png b/mobile/web/icons/Icon-192.png new file mode 100644 index 00000000000..b749bfef074 Binary files /dev/null and b/mobile/web/icons/Icon-192.png differ diff --git a/mobile/web/icons/Icon-512.png b/mobile/web/icons/Icon-512.png new file mode 100644 index 00000000000..88cfd48dff1 Binary files /dev/null and b/mobile/web/icons/Icon-512.png differ diff --git a/mobile/web/icons/Icon-maskable-192.png b/mobile/web/icons/Icon-maskable-192.png new file mode 100644 index 00000000000..eb9b4d76e52 Binary files /dev/null and b/mobile/web/icons/Icon-maskable-192.png differ diff --git a/mobile/web/icons/Icon-maskable-512.png b/mobile/web/icons/Icon-maskable-512.png new file mode 100644 index 00000000000..d69c56691fb Binary files /dev/null and b/mobile/web/icons/Icon-maskable-512.png differ diff --git a/mobile/web/index.html b/mobile/web/index.html new file mode 100644 index 00000000000..d1a74eec66a --- /dev/null +++ b/mobile/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + huly_mobile + + + + + + + diff --git a/mobile/web/manifest.json b/mobile/web/manifest.json new file mode 100644 index 00000000000..79e1abeaee9 --- /dev/null +++ b/mobile/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "huly_mobile", + "short_name": "huly_mobile", + "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/mobile/windows/.gitignore b/mobile/windows/.gitignore new file mode 100644 index 00000000000..d492d0d98c8 --- /dev/null +++ b/mobile/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/mobile/windows/CMakeLists.txt b/mobile/windows/CMakeLists.txt new file mode 100644 index 00000000000..2e9d4a5a561 --- /dev/null +++ b/mobile/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(huly_mobile 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 "huly_mobile") + +# 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/mobile/windows/flutter/CMakeLists.txt b/mobile/windows/flutter/CMakeLists.txt new file mode 100644 index 00000000000..903f4899d6f --- /dev/null +++ b/mobile/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/mobile/windows/flutter/ephemeral/.plugin_symlinks/desktop_webview_window b/mobile/windows/flutter/ephemeral/.plugin_symlinks/desktop_webview_window new file mode 120000 index 00000000000..7617f8de6e5 --- /dev/null +++ b/mobile/windows/flutter/ephemeral/.plugin_symlinks/desktop_webview_window @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/desktop_webview_window-0.2.3/ \ No newline at end of file diff --git a/mobile/windows/flutter/ephemeral/.plugin_symlinks/file_selector_windows b/mobile/windows/flutter/ephemeral/.plugin_symlinks/file_selector_windows new file mode 120000 index 00000000000..c380455951e --- /dev/null +++ b/mobile/windows/flutter/ephemeral/.plugin_symlinks/file_selector_windows @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/file_selector_windows-0.9.3+5/ \ No newline at end of file diff --git a/mobile/windows/flutter/ephemeral/.plugin_symlinks/firebase_core b/mobile/windows/flutter/ephemeral/.plugin_symlinks/firebase_core new file mode 120000 index 00000000000..0eed6acce82 --- /dev/null +++ b/mobile/windows/flutter/ephemeral/.plugin_symlinks/firebase_core @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/firebase_core-3.15.2/ \ No newline at end of file diff --git a/mobile/windows/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_windows b/mobile/windows/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_windows new file mode 120000 index 00000000000..c107663d66e --- /dev/null +++ b/mobile/windows/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_windows @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/flutter_secure_storage_windows-3.1.2/ \ No newline at end of file diff --git a/mobile/windows/flutter/ephemeral/.plugin_symlinks/flutter_web_auth_2 b/mobile/windows/flutter/ephemeral/.plugin_symlinks/flutter_web_auth_2 new file mode 120000 index 00000000000..58a85a34a7a --- /dev/null +++ b/mobile/windows/flutter/ephemeral/.plugin_symlinks/flutter_web_auth_2 @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/flutter_web_auth_2-4.1.0/ \ No newline at end of file diff --git a/mobile/windows/flutter/ephemeral/.plugin_symlinks/image_picker_windows b/mobile/windows/flutter/ephemeral/.plugin_symlinks/image_picker_windows new file mode 120000 index 00000000000..c7a399e59ee --- /dev/null +++ b/mobile/windows/flutter/ephemeral/.plugin_symlinks/image_picker_windows @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/image_picker_windows-0.2.2/ \ No newline at end of file diff --git a/mobile/windows/flutter/ephemeral/.plugin_symlinks/local_auth_windows b/mobile/windows/flutter/ephemeral/.plugin_symlinks/local_auth_windows new file mode 120000 index 00000000000..f66acc63bb4 --- /dev/null +++ b/mobile/windows/flutter/ephemeral/.plugin_symlinks/local_auth_windows @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/local_auth_windows-1.0.11/ \ No newline at end of file diff --git a/mobile/windows/flutter/ephemeral/.plugin_symlinks/path_provider_windows b/mobile/windows/flutter/ephemeral/.plugin_symlinks/path_provider_windows new file mode 120000 index 00000000000..4b6dbf1db84 --- /dev/null +++ b/mobile/windows/flutter/ephemeral/.plugin_symlinks/path_provider_windows @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/ \ No newline at end of file diff --git a/mobile/windows/flutter/ephemeral/.plugin_symlinks/url_launcher_windows b/mobile/windows/flutter/ephemeral/.plugin_symlinks/url_launcher_windows new file mode 120000 index 00000000000..b73fb768e79 --- /dev/null +++ b/mobile/windows/flutter/ephemeral/.plugin_symlinks/url_launcher_windows @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.5/ \ No newline at end of file diff --git a/mobile/windows/flutter/ephemeral/.plugin_symlinks/window_to_front b/mobile/windows/flutter/ephemeral/.plugin_symlinks/window_to_front new file mode 120000 index 00000000000..aaafe2063ab --- /dev/null +++ b/mobile/windows/flutter/ephemeral/.plugin_symlinks/window_to_front @@ -0,0 +1 @@ +/Users/dkendall/.pub-cache/hosted/pub.dev/window_to_front-0.0.3/ \ No newline at end of file diff --git a/mobile/windows/flutter/generated_plugin_registrant.cc b/mobile/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000000..5c7826af7fa --- /dev/null +++ b/mobile/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,32 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + DesktopWebviewWindowPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DesktopWebviewWindowPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + LocalAuthPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("LocalAuthPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WindowToFrontPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowToFrontPlugin")); +} diff --git a/mobile/windows/flutter/generated_plugin_registrant.h b/mobile/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000000..dc139d85a93 --- /dev/null +++ b/mobile/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/mobile/windows/flutter/generated_plugins.cmake b/mobile/windows/flutter/generated_plugins.cmake new file mode 100644 index 00000000000..01e87896d2c --- /dev/null +++ b/mobile/windows/flutter/generated_plugins.cmake @@ -0,0 +1,30 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + desktop_webview_window + file_selector_windows + firebase_core + flutter_secure_storage_windows + local_auth_windows + url_launcher_windows + window_to_front +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/mobile/windows/runner/CMakeLists.txt b/mobile/windows/runner/CMakeLists.txt new file mode 100644 index 00000000000..394917c053a --- /dev/null +++ b/mobile/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/mobile/windows/runner/Runner.rc b/mobile/windows/runner/Runner.rc new file mode 100644 index 00000000000..4df8d0e5d05 --- /dev/null +++ b/mobile/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.ledoweb" "\0" + VALUE "FileDescription", "huly_mobile" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "huly_mobile" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.ledoweb. All rights reserved." "\0" + VALUE "OriginalFilename", "huly_mobile.exe" "\0" + VALUE "ProductName", "huly_mobile" "\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/mobile/windows/runner/flutter_window.cpp b/mobile/windows/runner/flutter_window.cpp new file mode 100644 index 00000000000..955ee3038f9 --- /dev/null +++ b/mobile/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/mobile/windows/runner/flutter_window.h b/mobile/windows/runner/flutter_window.h new file mode 100644 index 00000000000..6da0652f05f --- /dev/null +++ b/mobile/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/mobile/windows/runner/main.cpp b/mobile/windows/runner/main.cpp new file mode 100644 index 00000000000..893627117db --- /dev/null +++ b/mobile/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"huly_mobile", 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/mobile/windows/runner/resource.h b/mobile/windows/runner/resource.h new file mode 100644 index 00000000000..66a65d1e4a7 --- /dev/null +++ b/mobile/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/mobile/windows/runner/resources/app_icon.ico b/mobile/windows/runner/resources/app_icon.ico new file mode 100644 index 00000000000..c04e20caf63 Binary files /dev/null and b/mobile/windows/runner/resources/app_icon.ico differ diff --git a/mobile/windows/runner/runner.exe.manifest b/mobile/windows/runner/runner.exe.manifest new file mode 100644 index 00000000000..153653e8d67 --- /dev/null +++ b/mobile/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/mobile/windows/runner/utils.cpp b/mobile/windows/runner/utils.cpp new file mode 100644 index 00000000000..3a0b46511a7 --- /dev/null +++ b/mobile/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/mobile/windows/runner/utils.h b/mobile/windows/runner/utils.h new file mode 100644 index 00000000000..3879d547557 --- /dev/null +++ b/mobile/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/mobile/windows/runner/win32_window.cpp b/mobile/windows/runner/win32_window.cpp new file mode 100644 index 00000000000..60608d0fe5b --- /dev/null +++ b/mobile/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/mobile/windows/runner/win32_window.h b/mobile/windows/runner/win32_window.h new file mode 100644 index 00000000000..e901dde684e --- /dev/null +++ b/mobile/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_ diff --git a/pods/authProviders/src/utils.ts b/pods/authProviders/src/utils.ts index 32f86c812c9..223e46d8f00 100644 --- a/pods/authProviders/src/utils.ts +++ b/pods/authProviders/src/utils.ts @@ -32,6 +32,7 @@ export interface AuthState { branding?: string autoJoin?: boolean navigateUrl?: string + mobileRedirect?: string } export function safeParseAuthState (rawState: string | undefined): AuthState { @@ -53,7 +54,8 @@ export function encodeState (ctx: any, brandings: BrandingMap): string { inviteId: ctx.query?.inviteId, branding, autoJoin: ctx.query?.autoJoin !== undefined, - navigateUrl: ctx.query?.navigateUrl + navigateUrl: ctx.query?.navigateUrl, + mobileRedirect: ctx.query?.mobileRedirect } return encodeURIComponent(JSON.stringify(state)) @@ -110,9 +112,15 @@ export async function handleProviderAuth ( type: providerType, user }) + if (state.mobileRedirect != null) { + return `${state.mobileRedirect}://login/auth?error=no_account` + } return concatLink(branding?.front ?? frontUrl, '/login') } else { - const origin = concatLink(branding?.front ?? frontUrl, '/login/auth') + const baseUrl = state.mobileRedirect != null + ? `${state.mobileRedirect}://login/auth` + : concatLink(branding?.front ?? frontUrl, '/login/auth') + const origin = baseUrl const queryObj: any = { token: loginInfo.token } if (state.autoJoin === true) { queryObj.autoJoin = state.autoJoin