diff --git a/.github/workflows/FlutterApp.yml b/.github/workflows/FlutterApp.yml index cfdc629..baef660 100644 --- a/.github/workflows/FlutterApp.yml +++ b/.github/workflows/FlutterApp.yml @@ -27,7 +27,7 @@ jobs: channel: 'stable' - name: Install dependencies - run: dart pub get + run: flutter pub get - name: Verify formatting run: dart format --output=none --set-exit-if-changed . @@ -66,10 +66,15 @@ jobs: channel: 'stable' - name: Install dependencies - run: dart pub get + run: flutter pub get - name: Run integration tests - run: xvfb-run flutter test integration_test + run: | + if [ -d integration_test ]; then + xvfb-run flutter test integration_test + else + echo "No integration_test directory, skipping." + fi - name: Dump docker logs on failure uses: jwalton/gh-docker-logs@v2 \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..0ad9a1b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,126 @@ +name: Build (Android, iOS, Windows) + +# Android and Windows: release. iOS: simulator only (release not supported). Android requires GitHub Secrets: +# ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD. + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: build-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + build-android: + name: Android + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Create keystore and key.properties + run: | + mkdir -p android/app + printf '%s' "$ANDROID_KEYSTORE_B64" | base64 -d > android/app/upload-keystore.jks + keytool -importkeystore \ + -srckeystore android/app/upload-keystore.jks \ + -destkeystore android/app/upload-keystore-legacy.jks \ + -deststoretype JKS \ + -srcalias "$KEY_ALIAS" \ + -destalias "$KEY_ALIAS" \ + -deststorepass "$STORE_PASS" \ + -destkeypass "$KEY_PASS" \ + -srcstorepass "$STORE_PASS" \ + -srckeypass "$KEY_PASS" \ + -noprompt + mv android/app/upload-keystore-legacy.jks android/app/upload-keystore.jks + cat > android/key.properties << EOF + storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }} + keyAlias=${{ secrets.ANDROID_KEY_ALIAS }} + storeFile=upload-keystore.jks + EOF + env: + ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + STORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_PASS: ${{ secrets.ANDROID_KEY_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + + - name: Install dependencies + run: flutter pub get + + - name: Build APK (release) + run: flutter build apk --release + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: android-release-apk + path: build/app/outputs/flutter-apk/app-release.apk + + build-ios: + name: iOS (simulator) + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Install CocoaPods dependencies + run: cd ios && pod install && cd .. + + - name: Build iOS (simulator) + run: flutter build ios --simulator + + - name: Upload iOS app + uses: actions/upload-artifact@v4 + with: + name: ios-simulator-app + path: build/ios/iphonesimulator/Runner.app + + build-windows: + name: Windows + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Build Windows (release) + run: flutter build windows --release + + - name: Upload Windows build + uses: actions/upload-artifact@v4 + with: + name: windows-build + path: build/windows/x64/runner/Release/ + diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..cd9dfe5 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,185 @@ +name: Deploy (Google Play & App Store) + +# Publishes AAB to Google Play and IPA to TestFlight. +# Trigger: tag v* or workflow_dispatch. +# +# Google Play – Secrets: GOOGLE_PLAY_CREDENTIALS (service account JSON), same as build for Android. +# App Store – Secrets: BUILD_CERTIFICATE_BASE64 (p12), P12_PASSWORD, BUILD_PROVISION_PROFILE_BASE64; +# Vars: APPSTORE_ISSUER_ID, APPSTORE_API_KEY_ID. Secret: APPSTORE_API_PRIVATE_KEY (.p8 contents). +# Var: IOS_PROFILE_NAME – exact provisioning profile name for eu.mobisync.syncClient. + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + play_track: + description: 'Google Play track' + required: false + default: 'internal' + type: choice + options: + - internal + - alpha + - beta + - production + deploy_android: + description: 'Deploy to Google Play' + required: false + default: true + type: boolean + deploy_ios: + description: 'Deploy to App Store (TestFlight)' + required: false + default: true + type: boolean + +concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: false + +jobs: + deploy-android: + name: Deploy Android (Google Play) + if: github.event_name != 'workflow_dispatch' || github.event.inputs.deploy_android != 'false' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Create keystore and key.properties + run: | + mkdir -p android/app + printf '%s' "$ANDROID_KEYSTORE_B64" | base64 -d > android/app/upload-keystore.jks + keytool -importkeystore \ + -srckeystore android/app/upload-keystore.jks \ + -destkeystore android/app/upload-keystore-legacy.jks \ + -deststoretype JKS \ + -srcalias "$KEY_ALIAS" \ + -destalias "$KEY_ALIAS" \ + -deststorepass "$STORE_PASS" \ + -destkeypass "$KEY_PASS" \ + -srcstorepass "$STORE_PASS" \ + -srckeypass "$KEY_PASS" \ + -noprompt + mv android/app/upload-keystore-legacy.jks android/app/upload-keystore.jks + cat > android/key.properties << EOF + storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }} + keyAlias=${{ secrets.ANDROID_KEY_ALIAS }} + storeFile=upload-keystore.jks + EOF + env: + ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + STORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_PASS: ${{ secrets.ANDROID_KEY_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + + - name: Install dependencies + run: flutter pub get + + - name: Build App Bundle (release) + run: flutter build appbundle --release + + - name: Upload to Google Play + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_CREDENTIALS }} + packageName: eu.mobisync.sync_client + releaseFiles: build/app/outputs/bundle/release/app-release.aab + track: ${{ github.event.inputs.play_track || 'internal' }} + status: completed + mappingFile: android/app/build/outputs/mapping/release/mapping.txt + + deploy-ios: + name: Deploy iOS (TestFlight) + if: github.event_name != 'workflow_dispatch' || github.event.inputs.deploy_ios != 'false' + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Import code signing certificate + uses: apple-actions/import-codesign-certs@v3 + with: + p12-file-base64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} + p12-password: ${{ secrets.P12_PASSWORD }} + + - name: Install provisioning profile + run: | + mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" + printf '%s' "$BUILD_PROVISION_PROFILE_B64" | base64 -d > "$HOME/Library/MobileDevice/Provisioning Profiles/ci.mobileprovision" + env: + BUILD_PROVISION_PROFILE_B64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }} + + - name: Create ExportOptions.plist + run: | + PROFILE="${{ vars.IOS_PROFILE_NAME }}" + if [ -z "$PROFILE" ]; then + echo "::error::Repository variable IOS_PROFILE_NAME is required for iOS deploy." + exit 1 + fi + cat > ios/ExportOptions.plist << EOF + + + + + method + app-store + teamID + R25XLT6Z87 + signingStyle + manual + signingCertificate + iPhone Distribution + provisioningProfiles + + eu.mobisync.syncClient + ${PROFILE} + + uploadSymbols + + + + EOF + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Install CocoaPods + run: cd ios && pod install && cd .. + + - name: Build IPA + run: flutter build ipa --export-options-plist=ios/ExportOptions.plist + + - name: Locate IPA + id: ipa + run: | + F=$(ls build/ios/ipa/*.ipa 2>/dev/null | head -1) + if [ -z "$F" ]; then echo "::error::No IPA found"; exit 1; fi + echo "path=$F" >> "$GITHUB_OUTPUT" + + - name: Upload to TestFlight + uses: apple-actions/upload-testflight-build@v3 + with: + app-path: ${{ steps.ipa.outputs.path }} + issuer-id: ${{ vars.APPSTORE_ISSUER_ID }} + api-key-id: ${{ vars.APPSTORE_API_KEY_ID }} + api-private-key: ${{ secrets.APPSTORE_API_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f92e19..7afe56a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,26 +22,21 @@ jobs: steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Get version number id: version - run: | - echo "number=$(echo '${{ github.ref }}' | cut -d '/' -f 3)" >>${GITHUB_OUTPUT} - - - name: Show version number - run: | - echo ${{ steps.version.outputs.number }} + run: echo "number=${{ github.ref_name }}" >>$GITHUB_OUTPUT - name: Create Release id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: softprops/action-gh-release@v2 with: - tag_name: ${{ github.ref }} - release_name: ${{ github.ref }} + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} body_path: CHANGELOG.md draft: false prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.metadata b/.metadata index bad0587..e2ff7d7 100644 --- a/.metadata +++ b/.metadata @@ -15,7 +15,7 @@ migration: - platform: root create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 - - platform: ios + - platform: web create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 diff --git a/CHANGELOG.md b/CHANGELOG.md index ed772f8..c52a8dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ An App for wireless syncing of photos and videos from devices to home server. (https://mobisync.eu) +## 1.0.10 Release notes (2026-01-29) + +### Enhancements +* iOS: deployment target set to 13.0 for pod compatibility (wakelock_plus, url_launcher_ios) +* Android: AGP 8.9.1 and Gradle 8.11.1 for androidx deps; keystore conversion to JKS in CI +* Added url_launcher for mobisync.eu link; branding strip below nav bar + +### Fixes +* CI: Android keystore conversion with srcalias/destalias; Gradle daemon disabled in CI + ## 1.0.9 Release notes (2024-09-02) ### Enhancements diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 89ce047..9114cd3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,3 +11,29 @@ `git tag v0.0.2` `git push --tags` `flutter build appbundle --release` + +## CI builds (GitHub Actions) +Workflow **Build (Android, iOS, Windows)** (`.github/workflows/build.yml`): +- **Trigger:** push/PR to `main`, or manually (`workflow_dispatch`). +- **Release builds:** Android APK, Windows exe+dll. iOS is simulator only (no release for simulator). +- **Android** requires GitHub Secrets on every run: + - `ANDROID_KEYSTORE_BASE64` – keystore (.jks) as base64 (on Linux: `base64 -w0 keystore.jks`) + - `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` + +## Deploy (Google Play & App Store) +Workflow **Deploy** (`.github/workflows/deploy.yml`): +- **Trigger:** tag `v*` (`git tag v1.0.0` → `git push --tags`) or manually (`workflow_dispatch`). +- On manual run: choose track (internal / alpha / beta / production), whether to deploy Android and/or iOS. + +### Google Play +- **Secrets:** same as build + `GOOGLE_PLAY_CREDENTIALS` – JSON key from Google Play service account (Cloud Console → Service Accounts → Create Key → JSON). App must exist in Play Console and service account must have access. + +### App Store (TestFlight) +- **Secrets:** + - `BUILD_CERTIFICATE_BASE64` – Apple Distribution certificate (.p12) as base64 + - `P12_PASSWORD` – .p12 password + - `BUILD_PROVISION_PROFILE_BASE64` – App Store provisioning profile (.mobileprovision) as base64 + - `APPSTORE_API_PRIVATE_KEY` – contents of .p8 key from App Store Connect API +- **Variables:** + - `APPSTORE_ISSUER_ID`, `APPSTORE_API_KEY_ID` – from App Store Connect → Users and Access → Keys + - `IOS_PROFILE_NAME` – exact provisioning profile name for `eu.mobisync.home` (as in Developer Portal) diff --git a/analysis_options.yaml b/analysis_options.yaml index 4cb157c..5c58cfb 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -2,7 +2,7 @@ include: package:flutter_lints/flutter.yaml linter: rules: - avoid_print: false # Uncomment to disable the `avoid_print` rule + avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule analyzer: diff --git a/android/app/build.gradle b/android/app/build.gradle index 47eec7c..5a47912 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -14,7 +14,7 @@ if (localPropertiesFile.exists()) { def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { - flutterVersionCode = '110' + flutterVersionCode = '111' } def flutterVersionName = localProperties.getProperty('flutter.versionName') @@ -30,17 +30,21 @@ if (keystorePropertiesFile.exists()) { android { - namespace "eu.mobisync.sync_client" + namespace "eu.mobisync.home" compileSdkVersion flutter.compileSdkVersion ndkVersion flutter.ndkVersion + buildFeatures { + buildConfig = true + } + compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '17' } sourceSets { @@ -49,7 +53,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "eu.mobisync.sync_client" + applicationId "eu.mobisync.home" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. minSdkVersion flutter.minSdkVersion diff --git a/android/app/release/app-release.aab b/android/app/release/app-release.aab index 0940b64..b593cea 100644 Binary files a/android/app/release/app-release.aab and b/android/app/release/app-release.aab differ diff --git a/android/app/release/app-release.apk b/android/app/release/app-release.apk new file mode 100644 index 0000000..2e81e6e Binary files /dev/null and b/android/app/release/app-release.apk differ diff --git a/android/app/release/baselineProfiles/0/app-release.dm b/android/app/release/baselineProfiles/0/app-release.dm new file mode 100644 index 0000000..2aa1e5c Binary files /dev/null and b/android/app/release/baselineProfiles/0/app-release.dm differ diff --git a/android/app/release/baselineProfiles/1/app-release.dm b/android/app/release/baselineProfiles/1/app-release.dm new file mode 100644 index 0000000..b463063 Binary files /dev/null and b/android/app/release/baselineProfiles/1/app-release.dm differ diff --git a/android/app/release/output-metadata.json b/android/app/release/output-metadata.json new file mode 100644 index 0000000..f883663 --- /dev/null +++ b/android/app/release/output-metadata.json @@ -0,0 +1,37 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "eu.mobisync.sync_client", + "variantName": "release", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 111, + "versionName": "1.0.9", + "outputFile": "app-release.apk" + } + ], + "elementType": "File", + "baselineProfiles": [ + { + "minApi": 28, + "maxApi": 30, + "baselineProfiles": [ + "baselineProfiles/1/app-release.dm" + ] + }, + { + "minApi": 31, + "maxApi": 2147483647, + "baselineProfiles": [ + "baselineProfiles/0/app-release.dm" + ] + } + ], + "minSdkVersionForDexing": 21 +} \ No newline at end of file diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index ae11ff3..7856257 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -4,13 +4,13 @@ - - + + - + android:requestLegacyExternalStorage="true"> + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index eb22c8e..0a1a1d3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ @@ -30,9 +30,8 @@ - - - + + diff --git a/android/app/src/main/kotlin/com/example/sync_client/MainActivity.kt b/android/app/src/main/kotlin/eu/mobisync/home/MainActivity.kt similarity index 75% rename from android/app/src/main/kotlin/com/example/sync_client/MainActivity.kt rename to android/app/src/main/kotlin/eu/mobisync/home/MainActivity.kt index e577b15..63306e2 100644 --- a/android/app/src/main/kotlin/com/example/sync_client/MainActivity.kt +++ b/android/app/src/main/kotlin/eu/mobisync/home/MainActivity.kt @@ -1,4 +1,4 @@ -package eu.mobisync.sync_client +package eu.mobisync.home import io.flutter.embedding.android.FlutterActivity diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml index 76034cc..f629628 100644 --- a/android/app/src/profile/AndroidManifest.xml +++ b/android/app/src/profile/AndroidManifest.xml @@ -4,14 +4,14 @@ - - + + - + android:requestLegacyExternalStorage="true"> + diff --git a/android/build.gradle b/android/build.gradle index 1e73c4f..e7c733a 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -14,6 +14,35 @@ subprojects { project.evaluationDependsOn(':app') } +subprojects { subproject -> + subproject.plugins.withId('com.android.library') { + subproject.android { + if (namespace == null) { + def manifest = file("${subproject.projectDir}/src/main/AndroidManifest.xml") + if (manifest.exists()) { + def parsed = new XmlParser().parse(manifest) + if (parsed.@package) { + namespace = parsed.@package + } + } + } + } + } + + // Force Java 17 on all Java compile tasks + subproject.tasks.withType(JavaCompile).configureEach { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + // Force Kotlin JVM 17 on all Kotlin compile tasks + subproject.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } +} + tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/android/gradle.properties b/android/gradle.properties index b9a9a24..0ed393d 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,6 +1,8 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4096M -XX:+HeapDumpOnOutOfMemoryError +org.gradle.daemon=false +org.gradle.caching=true android.useAndroidX=true -android.enableJetifier=true -android.defaults.buildfeatures.buildconfig=true +android.enableJetifier=false android.nonTransitiveRClass=false android.nonFinalResIds=false +kotlin.jvm.target.validation.mode=IGNORE diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 3c85cfe..efdcc4a 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 76f86c8..034d0e0 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -14,12 +14,19 @@ pluginManagement { mavenCentral() gradlePluginPortal() } + resolutionStrategy { + eachPlugin { details -> + if (details.requested.id.namespace == "com.android" && details.requested.id.name == "application") { + details.useVersion("8.9.1") + } + } + } } plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version '8.5.0' apply false - id "org.jetbrains.kotlin.android" version "1.9.23" apply false + id "com.android.application" version '8.9.1' apply false + id "org.jetbrains.kotlin.android" version "2.2.0" apply false } include ":app" \ No newline at end of file diff --git a/docs/AUTH_DESIGN.md b/docs/AUTH_DESIGN.md new file mode 100644 index 0000000..faa1019 --- /dev/null +++ b/docs/AUTH_DESIGN.md @@ -0,0 +1,60 @@ +# Design: Login and security in the app + +## Requirements + +1. **App login** – one or both of: + - **Username + password** – server validates against local SQLite DB. + - **Google account** – sign in with Google Sign-In; server accepts Google ID token and creates/links user. + +2. **Device password** – stored **securely** on device (not in plain JSON): + - Use **flutter_secure_storage** (or similar) for session token / credentials. + - On app launch – if a valid token exists in secure storage, user stays "logged in" without re-entering password. + +3. **On delete / dangerous actions** – additional confirmation required: + - **Biometrics** (fingerprint, face) – via **local_auth**. + - Or **PIN** – set by user in settings; for "Delete all", "Empty trash", bulk "Move to Trash" – show biometric or PIN entry screen first. + +--- + +## Components + +### Server (sync_server) + +- **SQLite DB** – `users` table (username, password_hash). +- **POST /auth/login** – `{ "User": "", "Password": "" }` → on success returns `{ "Token": "..." }` (JWT or random token stored in DB). +- **POST /auth/register** – creates user (optional, if registration is enabled). +- **Dangerous endpoints** (`/delete-all`, `/empty-trash`) – require `Authorization: Bearer ` header or password in body; server verifies token/password before execution. +- **POST /auth/google** (phase 2) – accepts Google ID token, validates it, creates/returns session token. + +### Client (sync_client) + +- **Secure storage** – `flutter_secure_storage` for: + - `auth_token` – after successful login/Google sign-in. + - Optionally: do not store password in plain text in DeviceSettings; token only. +- **Login screen** – current (email + password) + "Sign in with Google" button (phase 2). + - On successful login – store token in secure storage and state (currentUser + token). +- **On startup** – read token from secure storage; if valid token → auto login (no login screen). +- **API requests** – all requests to server include `Authorization: Bearer ` (when token exists). +- **Dangerous actions** – before calling `apiDeleteAllFiles` / `apiEmptyTrash` / bulk "Move to Trash": + - Show dialog: "Confirm with biometrics or PIN". + - **local_auth**: `authenticate()` – on success proceed with request. + - If biometrics unavailable or user chooses PIN – show PIN entry screen (PIN stored in secure storage when first set in settings). + +### PIN + +- In **Settings / Account** – option "Set PIN for delete confirmation". +- PIN stored only in **secure storage** (hashed or encrypted). +- On first "Delete all" / "Empty trash" – if no PIN set, prompt user to set PIN or use biometrics only. + +--- + +## Implementation phases + +| Phase | Description | +|-------|-------------| +| **1** | Server: SQLite auth DB + `/auth/login` + protect `/delete-all` and `/empty-trash` with password or token. Client: store token in secure storage, send in requests; before delete/empty-trash – require biometrics (local_auth). | +| **2** | Client: PIN option in settings; for dangerous actions – biometrics or PIN entry. | +| **3** | Google sign-in: button on login screen, `google_sign_in`, server endpoint `/auth/google`. | +| **4** | Remove password from DeviceSettings (plain text); token only in secure storage. | + +This document describes the overall design; concrete implementation follows these phases. diff --git a/docs/icon-arrow-alternatives.md b/docs/icon-arrow-alternatives.md new file mode 100644 index 0000000..4a541f1 --- /dev/null +++ b/docs/icon-arrow-alternatives.md @@ -0,0 +1,41 @@ +# Rounded arrow alternatives for the app icon + +The current icon uses **rounded ends** (small circles at the arrow tips) for a softer “round arrow” look. Here are other options you could use: + +--- + +## 1. **Current: Round ends (circles)** ✓ +- Thick curved arcs (orange + blue) with **small circles** at the tips instead of sharp arrowheads. +- Soft, friendly, clearly “round arrows”. + +--- + +## 2. **Sharp arrowheads (original)** +- Same arcs with **triangular arrowheads** at the ends. +- More classic sync/transfer look. To revert: replace the circle paths with triangles (e.g. `M88,73 L82,82 L92,76 Z` for orange, `M20,35 L26,26 L16,32 Z` for blue). + +--- + +## 3. **Single circular refresh arrow** +- One continuous **ring** with two arrowheads 180° apart (half orange, half blue). +- Very “sync/refresh” style, minimal. + +--- + +## 4. **Oval / softer arc** +- Same two arrows but drawn along a **more oval path** (e.g. wider horizontally). +- Feels rounder and a bit more organic. + +--- + +## 5. **Rounded (blunt) arrowheads** +- Keep the arcs, but use **short, blunt triangles** (wider, less pointy) at the ends. +- Still reads as arrows but softer than sharp points. + +--- + +## 6. **Double-line (outline) arrows** +- **Stroked** circular arcs instead of filled thick bands, with round line caps. +- Lighter, more “line art” look; needs stroke in the vector/SVG. + +If you want to try one of these, say which number (or combination) and we can adapt the icon files. diff --git a/integration_test/app_test.dart b/integration_test/app_test.dart deleted file mode 100644 index 8085ec1..0000000 --- a/integration_test/app_test.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - group('end-to-end test', () { - testWidgets('Login form', (WidgetTester tester) async { - //app.main(); - // await tester.pumpAndSettle(); - // expect(find.byKey(Key('username')), findsOneWidget); - // expect(find.byKey(Key('password')), findsOneWidget); - - // await tester.tap(find.text("Login")); - await tester.pump(); - }); - }); -} diff --git a/ios/.gitignore b/ios/.gitignore index 7a7f987..a056504 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -26,6 +26,7 @@ Flutter/flutter_assets/ Flutter/flutter_export_environment.sh ServiceDefinitions.json Runner/GeneratedPluginRegistrant.* +ExportOptions.plist # Exceptions to above rules. !default.mode1v3 diff --git a/ios/Podfile b/ios/Podfile index e549ee2..73d040c 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +# Minimum iOS version for url_launcher_ios, wakelock_plus and other plugins +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -29,6 +29,7 @@ flutter_ios_podfile_setup target 'Runner' do use_frameworks! + platform :ios, '13.0' flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) target 'RunnerTests' do @@ -39,5 +40,8 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' + end end end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 9215cdc..e3b7e0f 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -34,8 +34,6 @@ PODS: - DKImagePickerController/PhotoGallery - Flutter - Flutter (1.0.0) - - integration_test (0.0.1): - - Flutter - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -44,14 +42,24 @@ PODS: - SDWebImage (5.19.4): - SDWebImage/Core (= 5.19.4) - SDWebImage/Core (5.19.4) + - share_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS - SwiftyGif (5.4.5) DEPENDENCIES: - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - - integration_test (from `.symlinks/plugins/integration_test/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) SPEC REPOS: trunk: @@ -65,22 +73,28 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter - integration_test: - :path: ".symlinks/plugins/integration_test/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" SPEC CHECKSUMS: DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d + share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index cc658ca..43ec839 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -316,10 +316,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -333,10 +337,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -494,11 +502,12 @@ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = R25XLT6Z87; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.syncClient; + PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.home; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = MobiSync_App_Store_Profile2026; @@ -517,7 +526,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.syncClient.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.home.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -535,7 +544,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.syncClient.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.home.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -551,7 +560,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.syncClient.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.home.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -679,11 +688,12 @@ DEVELOPMENT_TEAM = R25XLT6Z87; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.syncClient; + PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.home; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -702,11 +712,12 @@ DEVELOPMENT_TEAM = R25XLT6Z87; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 11; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.syncClient; + PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.home; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7655d80..934aa6e 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Sync Client + SpaceItMobiSync CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - sync_client + SpaceItMobiSync CFBundlePackageType APPL CFBundleShortVersionString @@ -30,6 +30,8 @@ NSPhotoLibraryUsageDescription Syncing photos and videos to your home server requires permissions to your files. + NSPhotoLibraryAddUsageDescription + Save downloaded photos to your photo library. UIApplicationSupportsIndirectInputEvents UILaunchScreen @@ -38,6 +40,15 @@ LaunchScreen.storyboard UIRequiresFullScreen + + UISupportsDocumentBrowser + + LSSupportsOpeningDocumentsInPlace + + + + UIFileSharingEnabled + UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/ios_backup/.gitignore b/ios_backup/.gitignore deleted file mode 100644 index 7a7f987..0000000 --- a/ios_backup/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -**/dgph -*.mode1v3 -*.mode2v3 -*.moved-aside -*.pbxuser -*.perspectivev3 -**/*sync/ -.sconsign.dblite -.tags* -**/.vagrant/ -**/DerivedData/ -Icon? -**/Pods/ -**/.symlinks/ -profile -xcuserdata -**/.generated/ -Flutter/App.framework -Flutter/Flutter.framework -Flutter/Flutter.podspec -Flutter/Generated.xcconfig -Flutter/ephemeral/ -Flutter/app.flx -Flutter/app.zip -Flutter/flutter_assets/ -Flutter/flutter_export_environment.sh -ServiceDefinitions.json -Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!default.mode1v3 -!default.mode2v3 -!default.pbxuser -!default.perspectivev3 diff --git a/ios_backup/Flutter/AppFrameworkInfo.plist b/ios_backup/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 7c56964..0000000 --- a/ios_backup/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,26 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - MinimumOSVersion - 12.0 - - diff --git a/ios_backup/Flutter/Debug.xcconfig b/ios_backup/Flutter/Debug.xcconfig deleted file mode 100644 index ec97fc6..0000000 --- a/ios_backup/Flutter/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" -#include "Generated.xcconfig" diff --git a/ios_backup/Flutter/Release.xcconfig b/ios_backup/Flutter/Release.xcconfig deleted file mode 100644 index c4855bf..0000000 --- a/ios_backup/Flutter/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" -#include "Generated.xcconfig" diff --git a/ios_backup/Podfile b/ios_backup/Podfile deleted file mode 100644 index d97f17e..0000000 --- a/ios_backup/Podfile +++ /dev/null @@ -1,44 +0,0 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '12.0' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_ios_podfile_setup - -target 'Runner' do - use_frameworks! - use_modular_headers! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerTests' do - inherit! :search_paths - end -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_ios_build_settings(target) - end -end diff --git a/ios_backup/Runner.xcodeproj/project.pbxproj b/ios_backup/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 016765c..0000000 --- a/ios_backup/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,784 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 77; - objects = { - -/* Begin PBXBuildFile section */ - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 79C02782CC4C4658ED480B0F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 88F75A3CFE75ABA6A65DA9A0 /* Pods_Runner.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 */; }; - FF0FECB669E94892BF1B1C02 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B2F25154FEEB43DE15049B14 /* Pods_RunnerTests.framework */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 97C146E61CF9000F007C117D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 97C146ED1CF9000F007C117D; - remoteInfo = Runner; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 5AAF8B26ADDCBAC42F0F9702 /* 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 = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 88F75A3CFE75ABA6A65DA9A0 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 93C3C8DDEBA7505777ACE106 /* 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 = ""; }; - 95DE71F53682F9F89A3AFCEE /* 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 = ""; }; - 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 = ""; }; - A07ECB11F51219881AD6652F /* 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 = ""; }; - B2F25154FEEB43DE15049B14 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - CD447BE506F8D948D0CE3D6E /* 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 = ""; }; - DF24105E66062229FAC6EFEA /* 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 = ""; }; - F2416B472C59970B00197D8A /* RunnerRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerRelease.entitlements; sourceTree = ""; }; - F2416B482C59971600197D8A /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 577EE3DDA5F715628AEC013D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - FF0FECB669E94892BF1B1C02 /* Pods_RunnerTests.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 79C02782CC4C4658ED480B0F /* Pods_Runner.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C8082294A63A400263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C807B294A618700263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - 331C8082294A63A400263BE5 /* RunnerTests */, - C458B3ABB921182D55CCE54F /* Pods */, - E5475E869E537D6A55AA694C /* Frameworks */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - 331C8081294A63A400263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - F2416B482C59971600197D8A /* RunnerProfile.entitlements */, - F2416B472C59970B00197D8A /* RunnerRelease.entitlements */, - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; - C458B3ABB921182D55CCE54F /* Pods */ = { - isa = PBXGroup; - children = ( - CD447BE506F8D948D0CE3D6E /* Pods-Runner.debug.xcconfig */, - A07ECB11F51219881AD6652F /* Pods-Runner.release.xcconfig */, - 93C3C8DDEBA7505777ACE106 /* Pods-Runner.profile.xcconfig */, - 5AAF8B26ADDCBAC42F0F9702 /* Pods-RunnerTests.debug.xcconfig */, - 95DE71F53682F9F89A3AFCEE /* Pods-RunnerTests.release.xcconfig */, - DF24105E66062229FAC6EFEA /* Pods-RunnerTests.profile.xcconfig */, - ); - path = Pods; - sourceTree = ""; - }; - E5475E869E537D6A55AA694C /* Frameworks */ = { - isa = PBXGroup; - children = ( - 88F75A3CFE75ABA6A65DA9A0 /* Pods_Runner.framework */, - B2F25154FEEB43DE15049B14 /* 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 = ( - 90F19B15B8F6D0876EF9527D /* [CP] Check Pods Manifest.lock */, - 331C807D294A63A400263BE5 /* Sources */, - 331C807F294A63A400263BE5 /* Resources */, - 577EE3DDA5F715628AEC013D /* 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 = ( - AE6492A4AA1B5F56C209CAE8 /* [CP] Check Pods Manifest.lock */, - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - AB4A1C9D17F53530E63C820A /* [CP] Embed Pods Frameworks */, - 09B0034E148CBA82C4B6B4B9 /* [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" */; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - preferredProjectObjectVersion = 77; - 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 */ - 09B0034E148CBA82C4B6B4B9 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - inputPaths = ( - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - outputPaths = ( - ); - 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\n"; - }; - 90F19B15B8F6D0876EF9527D /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - 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\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\n"; - }; - AB4A1C9D17F53530E63C820A /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - inputPaths = ( - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n\n"; - showEnvVarsInLog = 0; - }; - AE6492A4AA1B5F56C209CAE8 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - 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\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C807D294A63A400263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 97C146ED1CF9000F007C117D /* Runner */; - targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = R25XLT6Z87; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Mobi Sync Client"; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0.4; - PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.client; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = MobiSync_App_Store_Profile2026; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 331C8088294A63A400263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 5AAF8B26ADDCBAC42F0F9702 /* 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 = eu.mobisync.client.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 = 95DE71F53682F9F89A3AFCEE /* 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 = eu.mobisync.client.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 = DF24105E66062229FAC6EFEA /* 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 = eu.mobisync.client.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; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = R25XLT6Z87; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Mobi Sync Client"; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0.4; - PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.client; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = MobiSync_App_Store_Profile2026; - 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; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/RunnerRelease.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = R25XLT6Z87; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Mobi Sync Client"; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0.4; - PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.client; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = MobiSync_App_Store_Profile2026; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C8088294A63A400263BE5 /* Debug */, - 331C8089294A63A400263BE5 /* Release */, - 331C808A294A63A400263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - 97C147041CF9000F007C117D /* Release */, - 249021D3217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - 97C147071CF9000F007C117D /* Release */, - 249021D4217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} diff --git a/ios_backup/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios_backup/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index e3773d4..0000000 --- a/ios_backup/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios_backup/Runner.xcworkspace/contents.xcworkspacedata b/ios_backup/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 21a3cc1..0000000 --- a/ios_backup/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/ios_backup/Runner/AppDelegate.swift b/ios_backup/Runner/AppDelegate.swift deleted file mode 100644 index b636303..0000000 --- a/ios_backup/Runner/AppDelegate.swift +++ /dev/null @@ -1,13 +0,0 @@ -import UIKit -import Flutter - -@main -@objc class AppDelegate: FlutterAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } -} diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d36b1fa..0000000 --- a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index 1fa9c26..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 3cb85b2..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index b30be03..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index 3096457..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index d61b1f9..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index b6924e5..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index a76b2c4..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index b30be03..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index ad70724..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index a27b0bb..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png deleted file mode 100644 index 9dce117..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png deleted file mode 100644 index aa9cfec..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png deleted file mode 100644 index 2422216..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png deleted file mode 100644 index a11a98d..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index a27b0bb..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index dae3672..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png deleted file mode 100644 index d5ab121..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png deleted file mode 100644 index f698d80..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index 8d1979e..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 1b58725..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index 18a3983..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios_backup/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 0bedcf2..0000000 --- a/ios_backup/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/ios_backup/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios_backup/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19ea..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios_backup/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19ea..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios_backup/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19ea..0000000 Binary files a/ios_backup/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/ios_backup/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios_backup/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725..0000000 --- a/ios_backup/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios_backup/Runner/Base.lproj/LaunchScreen.storyboard b/ios_backup/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index f2e259c..0000000 --- a/ios_backup/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios_backup/Runner/Base.lproj/Main.storyboard b/ios_backup/Runner/Base.lproj/Main.storyboard deleted file mode 100644 index f3c2851..0000000 --- a/ios_backup/Runner/Base.lproj/Main.storyboard +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios_backup/Runner/Info.plist b/ios_backup/Runner/Info.plist deleted file mode 100644 index 7655d80..0000000 --- a/ios_backup/Runner/Info.plist +++ /dev/null @@ -1,55 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Sync Client - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - sync_client - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - dst.stefanova@gmail.com - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSApplicationCategoryType - public.app-category.photography - LSRequiresIPhoneOS - - NSPhotoLibraryUsageDescription - Syncing photos and videos to your home server requires permissions to your files. - UIApplicationSupportsIndirectInputEvents - - UILaunchScreen - LaunchScreen - UILaunchStoryboardName - LaunchScreen.storyboard - UIRequiresFullScreen - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/ios_backup/Runner/Runner-Bridging-Header.h b/ios_backup/Runner/Runner-Bridging-Header.h deleted file mode 100644 index 308a2a5..0000000 --- a/ios_backup/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1 +0,0 @@ -#import "GeneratedPluginRegistrant.h" diff --git a/ios_backup/Runner/RunnerProfile.entitlements b/ios_backup/Runner/RunnerProfile.entitlements deleted file mode 100644 index 903def2..0000000 --- a/ios_backup/Runner/RunnerProfile.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - aps-environment - development - - diff --git a/ios_backup/Runner/RunnerRelease.entitlements b/ios_backup/Runner/RunnerRelease.entitlements deleted file mode 100644 index 903def2..0000000 --- a/ios_backup/Runner/RunnerRelease.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - aps-environment - development - - diff --git a/ios_backup/RunnerTests/RunnerTests.swift b/ios_backup/RunnerTests/RunnerTests.swift deleted file mode 100644 index 86a7c3b..0000000 --- a/ios_backup/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Flutter -import UIKit -import XCTest - -class RunnerTests: XCTestCase { - - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } - -} diff --git a/lib/config/config.dart b/lib/config/config.dart index 92cc866..fb96266 100644 --- a/lib/config/config.dart +++ b/lib/config/config.dart @@ -13,6 +13,8 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +export 'gallery_refresh_cubit.dart'; +export 'gallery_refresh_state.dart'; export 'router/app_router.dart'; export 'theme/app_theme.dart'; export 'theme/app_bar.dart'; diff --git a/lib/config/gallery_refresh_cubit.dart b/lib/config/gallery_refresh_cubit.dart new file mode 100644 index 0000000..9df29a4 --- /dev/null +++ b/lib/config/gallery_refresh_cubit.dart @@ -0,0 +1,30 @@ +/* + Copyright 2023 Take Control - Software & Infrastructure +*/ + +import 'package:bloc/bloc.dart'; +import 'package:sync_client/config/gallery_refresh_state.dart'; + +class GalleryRefreshCubit extends Cubit { + GalleryRefreshCubit() : super(const GalleryRefreshState()); + + void requestHomeRefresh() { + emit(state.copyWith(homeNeedsRefresh: true)); + } + + void requestTrashRefresh() { + emit(state.copyWith(trashNeedsRefresh: true)); + } + + void clearHomeRefresh() { + if (state.homeNeedsRefresh) { + emit(state.copyWith(homeNeedsRefresh: false)); + } + } + + void clearTrashRefresh() { + if (state.trashNeedsRefresh) { + emit(state.copyWith(trashNeedsRefresh: false)); + } + } +} diff --git a/lib/config/gallery_refresh_state.dart b/lib/config/gallery_refresh_state.dart new file mode 100644 index 0000000..967cde0 --- /dev/null +++ b/lib/config/gallery_refresh_state.dart @@ -0,0 +1,28 @@ +/* + Copyright 2023 Take Control - Software & Infrastructure +*/ + +import 'package:equatable/equatable.dart'; + +class GalleryRefreshState extends Equatable { + const GalleryRefreshState({ + this.homeNeedsRefresh = false, + this.trashNeedsRefresh = false, + }); + + final bool homeNeedsRefresh; + final bool trashNeedsRefresh; + + GalleryRefreshState copyWith({ + bool? homeNeedsRefresh, + bool? trashNeedsRefresh, + }) { + return GalleryRefreshState( + homeNeedsRefresh: homeNeedsRefresh ?? this.homeNeedsRefresh, + trashNeedsRefresh: trashNeedsRefresh ?? this.trashNeedsRefresh, + ); + } + + @override + List get props => [homeNeedsRefresh, trashNeedsRefresh]; +} diff --git a/lib/config/router/app_router.dart b/lib/config/router/app_router.dart index 5bde55e..6e98b30 100644 --- a/lib/config/router/app_router.dart +++ b/lib/config/router/app_router.dart @@ -3,50 +3,67 @@ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. */ import 'package:go_router/go_router.dart'; +import 'package:sync_client/config/router/main_shell.dart'; import 'package:sync_client/screens/screens.dart'; GoRouter getAppRouter() { - return GoRouter(initialLocation: '/', routes: [ - GoRoute( - path: '/', - builder: (context, state) => const HomeScreen(), - ), - GoRoute( - path: '/sync', - builder: (context, state) => const SyncScreen(), - ), - GoRoute( - path: '/account', - builder: (context, state) => const AccountScreen(), - ), - GoRoute( - path: '/servers', - builder: (context, state) => const ServersListScreen(), - ), - GoRoute( - path: '/folders', - builder: (context, state) => const FoldersListScreen(), - ), - GoRoute( - path: '/deleteOption', - builder: (context, state) => const DeletingEnabledScreen(), - ), - GoRoute( - path: '/login', - builder: (context, state) => - const NicknameScreen(), // const LogInScreen(), - ), - ]); + return GoRouter( + initialLocation: '/', + routes: [ + StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) { + return MainShell(navigationShell: navigationShell); + }, + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const HomeScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/trash', + builder: (context, state) => const TrashScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/sync', + builder: (context, state) => const SyncScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/account', + builder: (context, state) => const AccountScreen(), + ), + ], + ), + ], + ), + GoRoute( + path: '/servers', + builder: (context, state) => const ServersListScreen(), + ), + GoRoute( + path: '/folders', + builder: (context, state) => const FoldersListScreen(), + ), + GoRoute( + path: '/login', + builder: (context, state) => const LogInScreen(), + ), + ], + ); } diff --git a/lib/config/router/main_shell.dart b/lib/config/router/main_shell.dart new file mode 100644 index 0000000..d45e9d4 --- /dev/null +++ b/lib/config/router/main_shell.dart @@ -0,0 +1,191 @@ +/* + Copyright 2023 Take Control - Software & Infrastructure + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +*/ + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:sync_client/config/theme/app_bar.dart'; + +/// Shell that shows bottom navigation (Home, Trash, Sync, Account) and menu (theme, log out) on the right. +class MainShell extends StatefulWidget { + const MainShell({ + super.key, + required this.navigationShell, + }); + + final StatefulNavigationShell navigationShell; + + @override + State createState() => _MainShellState(); +} + +class _MainShellState extends State { + final GlobalKey _menuButtonKey = GlobalKey(); + + static const List<_NavItem> _items = [ + _NavItem(icon: Icons.home_rounded, label: 'Home'), + _NavItem(icon: Icons.delete_outline_rounded, label: 'Trash'), + _NavItem(icon: Icons.sync_rounded, label: 'Sync'), + _NavItem(icon: Icons.person_rounded, label: 'Account'), + ]; + + void _onTap(int index) { + widget.navigationShell.goBranch( + index, + initialLocation: index == widget.navigationShell.currentIndex, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final navTheme = theme.navigationBarTheme; + + final bottomInset = MediaQuery.paddingOf(context).bottom; + return Scaffold( + body: widget.navigationShell, + bottomNavigationBar: Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: navTheme.height ?? 64, + decoration: BoxDecoration( + color: navTheme.backgroundColor ?? + colorScheme.surfaceContainerHighest, + ), + child: Row( + children: [ + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(_items.length, (index) { + final item = _items[index]; + final selected = + index == widget.navigationShell.currentIndex; + return Expanded( + child: InkWell( + onTap: () => _onTap(index), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + item.icon, + size: 24, + color: selected + ? colorScheme.primary + : colorScheme.onSurface + .withValues(alpha: 0.6), + ), + const SizedBox(height: 4), + Text( + item.label, + style: TextStyle( + fontSize: 12, + color: selected + ? colorScheme.primary + : colorScheme.onSurface + .withValues(alpha: 0.6), + fontWeight: selected + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + ], + ), + ), + ); + }), + ), + ), + IconButton( + key: _menuButtonKey, + icon: const Icon(Icons.more_vert_rounded), + tooltip: 'Menu', + onPressed: () => + MainAppBar.showAppMenu(context, _menuButtonKey), + color: colorScheme.onSurface.withValues(alpha: 0.8), + ), + ], + ), + ), + _BottomBrandStrip(colorScheme: colorScheme), + ], + ), + ), + ); + } +} + +class _NavItem { + const _NavItem({required this.icon, required this.label}); + final IconData icon; + final String label; +} + +/// Thin strip at the very bottom: app icon, name, and link to mobisync.eu. +class _BottomBrandStrip extends StatelessWidget { + const _BottomBrandStrip({required this.colorScheme}); + + final ColorScheme colorScheme; + + static const _mobisyncUrl = 'https://mobisync.eu'; + + Future _openMobisync() async { + final uri = Uri.parse(_mobisyncUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + @override + Widget build(BuildContext context) { + return Material( + color: colorScheme.surface, + child: SizedBox( + height: 32, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'images/mobi-sync.png', + height: 20, + width: 20, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => Icon( + Icons.cloud_sync_rounded, + size: 20, + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + const SizedBox(width: 6), + Text( + 'SpaceIt Mobi Sync', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + const SizedBox(width: 4), + InkWell( + onTap: _openMobisync, + child: Text( + 'mobisync.eu', + style: TextStyle( + fontSize: 12, + color: colorScheme.primary, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/config/theme/app_bar.dart b/lib/config/theme/app_bar.dart index 577567a..73fa963 100644 --- a/lib/config/theme/app_bar.dart +++ b/lib/config/theme/app_bar.dart @@ -20,84 +20,143 @@ import 'package:popup_menu/popup_menu.dart'; import 'package:sync_client/config/config.dart'; import 'package:sync_client/services/device_services.dart'; -enum AppMenuOption { home, sync, theme, account, logout } +enum AppMenuOption { home, trash, sync, theme, account, logout } + +const Color _headerOrange = Color(0xFFE85D04); class MainAppBar { - static AppBar appBar(BuildContext context) { - final ThemeCubit theme = context.watch(); + /// [actionsBeforeMenu] are shown in the AppBar before the menu (e.g. refresh on Trash). + static AppBar appBar(BuildContext context, + {List? actionsBeforeMenu}) { + final colorScheme = Theme.of(context).colorScheme; + + return AppBar( + leading: Padding( + padding: const EdgeInsets.only(left: 12), + child: Image.asset( + 'images/mobi-sync.png', + height: 36, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => Icon( + Icons.cloud_sync_rounded, + color: colorScheme.onSurface, + size: 28, + ), + ), + ), + leadingWidth: 48, + title: const SizedBox.shrink(), + backgroundColor: colorScheme.surfaceContainerHighest, + foregroundColor: colorScheme.onSurface, + elevation: 0, + scrolledUnderElevation: 8, + surfaceTintColor: Colors.transparent, + iconTheme: IconThemeData(color: colorScheme.onSurface), + actionsIconTheme: IconThemeData(color: colorScheme.onSurface), + actions: actionsBeforeMenu != null ? [...actionsBeforeMenu] : null, + ); + } + + /// AppBar for sub-screens (e.g. Servers, Folders) with Back and optional "Done" to return to Sync. + static AppBar appBarWithBack( + BuildContext context, { + required String title, + bool showDoneButton = true, + }) { + final actions = []; + if (showDoneButton) { + actions.add( + Padding( + padding: const EdgeInsets.only(right: 8), + child: TextButton.icon( + onPressed: () => context.go("/sync"), + icon: const Icon(Icons.check_rounded, size: 20), + label: const Text("Done"), + style: TextButton.styleFrom( + foregroundColor: Colors.white, + ), + ), + ), + ); + } + return AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded), + tooltip: 'Back', + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go("/sync"); + } + }, + ), + title: Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + backgroundColor: _headerOrange, + foregroundColor: Colors.white, + elevation: 0, + surfaceTintColor: Colors.transparent, + iconTheme: IconThemeData(color: Colors.white.withValues(alpha: 0.95)), + actionsIconTheme: + IconThemeData(color: Colors.white.withValues(alpha: 0.95)), + actions: actions, + ); + } + + /// Shows the app menu (theme, account, log out). Call from bottom bar or elsewhere. + /// [menuButtonKey] should be the GlobalKey of the button that opens the menu (for positioning). + static void showAppMenu(BuildContext context, GlobalKey menuButtonKey) { + final ThemeCubit theme = context.read(); final DeviceServicesCubit deviceService = context.read(); - GlobalKey btnKey = GlobalKey(); void onClickMenu(MenuItemProvider item) async { - print('Click menu -> ${item.menuTitle}'); final option = item.menuUserInfo as AppMenuOption; switch (option) { case AppMenuOption.home: - if (context.canPop()) { - context.pop(); - } - context.push("/"); + context.go("/"); + case AppMenuOption.trash: + context.go("/trash"); case AppMenuOption.sync: - if (context.canPop()) { - context.pop(); - } - context.push("/sync"); + context.go("/sync"); case AppMenuOption.theme: theme.toggleTheme(); case AppMenuOption.account: - if (context.canPop()) { - context.pop(); - } - context.push("/account"); + context.go("/account"); case AppMenuOption.logout: await logOut(context, deviceService); } } - void getMenu(BuildContext context) { - PopupMenu menu = PopupMenu( - context: context, - config: MenuConfig( - maxColumn: 4, - backgroundColor: - theme.state.isDarkMode ? Colors.white : Colors.black, - lineColor: Theme.of(context).listTileTheme.iconColor!), - items: [ - mainMenuItem(context, AppMenuOption.home, "Home", Icons.home), - mainMenuItem(context, AppMenuOption.sync, "Sync", Icons.sync), - mainMenuItem( - context, - AppMenuOption.theme, - theme.state.isDarkMode ? "Light" : "Dark", - theme.state.isDarkMode - ? Icons.light_mode_outlined - : Icons.dark_mode_outlined), - mainMenuItem( - context, AppMenuOption.account, "Account", Icons.person), - mainMenuItem(context, AppMenuOption.logout, "LogOut", Icons.logout), - ], - onClickMenu: onClickMenu); - menu.show(widgetKey: btnKey); - } - - return AppBar( - title: const Text("Mobi Sync Client"), - actions: [ - IconButton( - key: btnKey, - icon: const Icon(Icons.menu_rounded), - tooltip: 'Show menu', - onPressed: () => getMenu(context)), - IconButton( - icon: const Icon(Icons.add_alert), - tooltip: 'Notifications', - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text('System notifications list is empty.'))); - }), + final PopupMenu menu = PopupMenu( + context: context, + config: MenuConfig( + maxColumn: 2, + backgroundColor: theme.state.isDarkMode ? Colors.white : Colors.black, + lineColor: Theme.of(context).listTileTheme.iconColor!, + ), + items: [ + mainMenuItem( + context, + AppMenuOption.theme, + theme.state.isDarkMode ? "Light" : "Dark", + theme.state.isDarkMode + ? Icons.light_mode_outlined + : Icons.dark_mode_outlined), + mainMenuItem(context, AppMenuOption.account, "Account", Icons.person), + mainMenuItem(context, AppMenuOption.logout, "Log out", Icons.logout), ], + onClickMenu: onClickMenu, ); + menu.show(widgetKey: menuButtonKey); } static Future logOut( diff --git a/lib/config/theme/app_theme.dart b/lib/config/theme/app_theme.dart index c40d9ac..7c34b93 100644 --- a/lib/config/theme/app_theme.dart +++ b/lib/config/theme/app_theme.dart @@ -14,13 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:popup_menu/popup_menu.dart'; import '../config.dart'; -const seedColor = Color.fromARGB(255, 246, 113, 31); +// Primary: orange. Secondary / surfaces: dark brown. +const Color _orange = Color(0xFFE85D04); +const Color _orangeLight = Color(0xFFFF8534); +const Color _darkBrown = Color(0xFF3D2914); +const Color _darkBrownLight = Color(0xFF5C3D1E); + +const seedColor = _orange; class AppTheme { static ThemeData getTheme(BuildContext context) { @@ -29,31 +37,585 @@ class AppTheme { } static ThemeData themeData(BuildContext context, ThemeCubit theme) { + final isDark = theme.state.isDarkMode; + final colorScheme = isDark + ? ColorScheme.dark( + primary: _orangeLight, + onPrimary: Colors.white, + secondary: _darkBrownLight, + onSecondary: Colors.white, + surface: const Color(0xFF1A1209), + onSurface: Colors.white, + surfaceContainerHighest: _darkBrown, + ) + : ColorScheme.light( + primary: _orange, + onPrimary: Colors.white, + secondary: _darkBrown, + onSecondary: Colors.white, + surface: Colors.white, + onSurface: _darkBrown, + surfaceContainerHighest: const Color(0xFFF5E6D3), + ); + + final buttonStyle = ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return null; + return colorScheme.primary; + }), + foregroundColor: WidgetStateProperty.all(colorScheme.onPrimary), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + elevation: WidgetStateProperty.resolveWith((_) => 0), + ); + + final outlinedStyle = ButtonStyle( + foregroundColor: WidgetStateProperty.all(colorScheme.primary), + side: WidgetStateProperty.all( + BorderSide(color: colorScheme.primary, width: 1.5), + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ); + return ThemeData( useMaterial3: true, - colorSchemeSeed: seedColor, - brightness: theme.state.isDarkMode ? Brightness.dark : Brightness.light, - listTileTheme: const ListTileThemeData( - iconColor: seedColor, + colorScheme: colorScheme, + brightness: isDark ? Brightness.dark : Brightness.light, + listTileTheme: ListTileThemeData( + iconColor: colorScheme.primary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + appBarTheme: AppBarTheme( + backgroundColor: colorScheme.surface, + foregroundColor: colorScheme.onSurface, + elevation: 0, + centerTitle: true, + titleTextStyle: TextStyle( + color: colorScheme.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: colorScheme.surfaceContainerHighest, + indicatorColor: colorScheme.primary.withValues(alpha: 0.28), + iconTheme: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return IconThemeData(color: colorScheme.primary, size: 24); + } + return IconThemeData( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + size: 24, + ); + }), + labelTextStyle: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: colorScheme.primary, + ); + } + return TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: colorScheme.onSurfaceVariant, + ); + }), + height: 64, + elevation: 0, ), + filledButtonTheme: FilledButtonThemeData(style: buttonStyle), + elevatedButtonTheme: ElevatedButtonThemeData(style: buttonStyle), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( - foregroundColor: Colors.blue, + foregroundColor: colorScheme.primary, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData(style: outlinedStyle), + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom( + foregroundColor: colorScheme.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), ), checkboxTheme: CheckboxThemeData( checkColor: WidgetStateProperty.all(Colors.white), - fillColor: WidgetStateProperty.all(objectColor), + fillColor: WidgetStateProperty.all(colorScheme.primary), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + ), + ); + } +} + +// Folder List Screen Styles +class FolderListStyles { + // Container margins + static const EdgeInsets containerMargin = EdgeInsets.all(20); + + // Empty state styles + static const double emptyStateIconSize = 80; + static const double emptyStateSpacing = 20; + + // Card styles + static const EdgeInsets cardMargin = EdgeInsets.symmetric(vertical: 4); + static const BorderRadius cardBorderRadius = + BorderRadius.all(Radius.circular(8)); + + // Loading dialog styles + static const EdgeInsets loadingDialogPadding = EdgeInsets.all(20); + + // Info box styles + static BoxDecoration infoContainerDecoration(BuildContext context) { + final theme = Theme.of(context); + return BoxDecoration( + color: theme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.primaryColor.withOpacity(0.3), ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ButtonStyle( - foregroundColor: WidgetStateProperty.all(Colors.white), - textStyle: WidgetStateProperty.all( - const TextStyle(color: Colors.white)), + ); + } + + // Folder icon container + static BoxDecoration folderIconDecoration(BuildContext context) { + final theme = Theme.of(context); + return BoxDecoration( + color: theme.primaryColor.withOpacity(0.1), + shape: BoxShape.circle, + ); + } + + // Text styles for folder list + static TextStyle titleTextStyle(BuildContext context) { + return Theme.of(context).textTheme.headlineSmall!; + } + + static TextStyle subtitleTextStyle(BuildContext context) { + return Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Colors.grey[600], + ); + } + + static TextStyle emptyStateTitleStyle(BuildContext context) { + return Theme.of(context).textTheme.titleLarge!.copyWith( + color: Colors.grey[600], + ); + } + + static TextStyle emptyStateSubtitleStyle(BuildContext context) { + return Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Colors.grey[500], + ); + } + + static TextStyle helperTextStyle(BuildContext context) { + return TextStyle( + fontSize: 12, + color: Colors.grey[500], + fontStyle: FontStyle.italic, + ); + } + + static TextStyle folderPathStyle() { + return const TextStyle(fontSize: 12); + } + + static TextStyle folderNameStyle() { + return const TextStyle(fontWeight: FontWeight.w500); + } + + // Colors + static Color emptyStateIconColor = Colors.grey[300]!; + + // Platform-specific help text + static String getPlatformHelpText() { + if (Platform.isAndroid) { + return 'You\'ll select a folder and grant access.\nNo special permissions required!'; + } else if (Platform.isIOS) { + return 'Select folders from Files app.\nPhotos sync is handled separately.'; + } else if (Platform.isMacOS) { + return 'Select any folder on your Mac.\nThe app only accesses folders you choose.'; + } else if (Platform.isWindows) { + return 'Select any folder on your PC.\nWorks with all Windows folders.'; + } else { + return 'Select any folder on your system.\nThe app only accesses folders you choose.'; + } + } +} + +// Dialog styles +class DialogStyles { + static const EdgeInsets contentPadding = EdgeInsets.all(16); + static const double contentSpacing = 16; + static const double optionVerticalPadding = 8.0; + static const double optionIconSize = 32; + static const double optionSpacing = 16; + + static TextStyle optionTitleStyle() { + return const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ); + } + + static TextStyle optionSubtitleStyle(BuildContext context) { + return TextStyle( + fontSize: 12, + color: Colors.grey[600], + ); + } + + static TextStyle errorDialogContentStyle() { + return const TextStyle(fontSize: 12, fontStyle: FontStyle.italic); + } +} + +// Snackbar styles +class SnackbarStyles { + static SnackBar successSnackbar({ + required String message, + VoidCallback? onAction, + String actionLabel = 'Undo', + }) { + return SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle, color: Colors.white), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + backgroundColor: Colors.green, + action: onAction != null + ? SnackBarAction( + label: actionLabel, + onPressed: onAction, + ) + : null, + ); + } + + static SnackBar warningSnackbar({required String message}) { + return SnackBar( + content: Text(message), + backgroundColor: Colors.orange, + ); + } + + static SnackBar errorSnackbar({required String message}) { + return SnackBar( + content: Text(message), + backgroundColor: Colors.red, + ); + } + + static SnackBar infoSnackbar({ + required String message, + VoidCallback? onAction, + String actionLabel = 'Retry', + }) { + return SnackBar( + content: Text(message), + backgroundColor: Colors.orange, + action: onAction != null + ? SnackBarAction( + label: actionLabel, + onPressed: onAction, + ) + : null, + ); + } +} + +class GalleryStyles { + // Container styles + static const EdgeInsets galleryPadding = EdgeInsets.all(4); + static const double photoSpacing = 4; + static const double borderRadius = 4; + + // Photo tile styles + static BoxDecoration photoTileDecoration(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return BoxDecoration( + color: isDark ? Colors.grey[800] : Colors.grey[300], + borderRadius: BorderRadius.circular(borderRadius), + ); + } + + static BoxDecoration loadingPhotoDecoration(BuildContext context) { + return photoTileDecoration(context); + } + + // Month header styles + static BoxDecoration monthHeaderDecoration(BuildContext context) { + final theme = Theme.of(context); + return BoxDecoration( + color: theme.scaffoldBackgroundColor.withOpacity(0.95), + border: Border( + bottom: BorderSide( + color: theme.dividerColor.withOpacity(0.2), + width: 0.5, ), ), ); } + + static TextStyle monthHeaderTextStyle(BuildContext context) { + return Theme.of(context).textTheme.titleMedium!.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Colors.black87, + ); + } + + static const EdgeInsets monthHeaderPadding = + EdgeInsets.symmetric(horizontal: 16, vertical: 8); + + // Empty state styles + static const double emptyStateIconSize = 80; + static Color emptyStateIconColor(BuildContext context) { + return Theme.of(context).brightness == Brightness.dark + ? Colors.grey[600]! + : Colors.grey[400]!; + } + + static TextStyle emptyStateTitleStyle(BuildContext context) { + return Theme.of(context).textTheme.titleLarge!.copyWith( + fontWeight: FontWeight.bold, + ); + } + + static TextStyle emptyStateSubtitleStyle(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Theme.of(context).textTheme.bodyMedium!.copyWith( + color: isDark ? Colors.grey[400] : Colors.grey[600], + ); + } + + // Loading state styles + static const double loadingIndicatorSize = 20; + static const double loadingIndicatorStrokeWidth = 2; + + static Color loadingIndicatorColor(BuildContext context) { + return Theme.of(context).primaryColor; + } + + // Error widget styles + static const double errorIconSize = 30; + + static Color errorIconColor(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return isDark ? Colors.grey[600]! : Colors.grey[500]!; + } + + // List view styles + static const double listTileThumbnailSize = 60; + static const EdgeInsets listTilePadding = + EdgeInsets.symmetric(horizontal: 16); + + static TextStyle listTileSubtitleStyle(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return TextStyle( + color: isDark ? Colors.grey[400] : Colors.grey[600], + fontSize: 14, + ); + } + + // Floating action button styles + static const double fabSpacing = 10; + + // Photo viewer overlay gradient + static const List overlayGradientColors = [ + Colors.black54, + Colors.transparent, + Colors.transparent, + Colors.black54, + ]; + + static const List overlayGradientStops = [0, 0.2, 0.8, 1]; + + static BoxDecoration videoPlayButtonDecoration() { + return BoxDecoration( + color: Colors.black.withOpacity(0.7), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withOpacity(0.8), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 8, + spreadRadius: 2, + ), + ], + ); + } + + static const double videoPlayButtonSize = 48; + static const double videoPlayIconSize = 32; + + static BoxDecoration videoBadgeDecoration() { + return BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(4), + ); + } + + static const EdgeInsets videoBadgePadding = + EdgeInsets.symmetric(horizontal: 6, vertical: 2); + + static const TextStyle videoBadgeTextStyle = TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w500, + ); +} + +// Photo Viewer Styles +class PhotoViewerStyles { + // Photo viewer overlay gradient + static const List overlayGradientColors = [ + Colors.black54, + Colors.transparent, + Colors.transparent, + Colors.black54, + ]; + + static const List overlayGradientStops = [0, 0.2, 0.8, 1]; + + // Navigation arrow styles + static BoxDecoration navigationArrowDecoration() { + return BoxDecoration( + color: Colors.black.withOpacity(0.5), + shape: BoxShape.circle, + ); + } + + static const double navigationArrowSize = 32; + static const EdgeInsets navigationArrowPadding = EdgeInsets.all(16); + + // Top bar styles + static const EdgeInsets topBarPadding = EdgeInsets.all(8); + + static TextStyle pageIndicatorStyle() { + return const TextStyle( + color: Colors.white, + fontSize: 16, + ); + } + + // Quality indicator styles + static BoxDecoration qualityIndicatorDecoration() { + return BoxDecoration( + color: Colors.green.withOpacity(0.8), + borderRadius: BorderRadius.circular(12), + ); + } + + static const EdgeInsets qualityIndicatorPadding = + EdgeInsets.symmetric(horizontal: 8, vertical: 4); + + static const TextStyle qualityIndicatorTextStyle = TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ); + + // Bottom info styles + static const EdgeInsets bottomInfoPadding = EdgeInsets.all(16); + + static const TextStyle photoTitleStyle = TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ); + + static TextStyle photoSubtitleStyle() { + return TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 14, + ); + } + + // Loading state + static const TextStyle loadingTextStyle = TextStyle( + color: Colors.white, + fontSize: 14, + ); + + // Page dots indicator + static const double pageDotSize = 8; + static const EdgeInsets pageDotSpacing = EdgeInsets.symmetric(horizontal: 2); + + static Color activeDotColor = Colors.white; + static Color inactiveDotColor = Colors.white.withValues(alpha: 0.4); + + // Info sheet styles + static BoxDecoration infoSheetDecoration(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return BoxDecoration( + color: isDark ? Colors.grey[900] : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ); + } + + static const EdgeInsets infoSheetPadding = EdgeInsets.all(16); + + static const TextStyle infoSheetTitleStyle = TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ); + + static const double infoRowLabelWidth = 80; + + static TextStyle infoRowLabelStyle(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return TextStyle( + fontWeight: FontWeight.w500, + color: isDark ? Colors.grey[400] : Colors.grey[600], + fontSize: 14, + ); + } +} + +// Common animation durations +class GalleryAnimations { + static const Duration overlayFade = Duration(milliseconds: 200); + static const Duration pageTransition = Duration(milliseconds: 300); + static const Duration scrollToTop = Duration(milliseconds: 500); + static const Curve defaultCurve = Curves.easeInOut; } BoxDecoration headerFooterBoxDecoration(BuildContext context, bool isHeader) { @@ -129,18 +691,18 @@ MenuItem mainMenuItem(BuildContext context, AppMenuOption menuOption, } MaterialColor objectColor = MaterialColor( - const Color.fromRGBO(246, 113, 31, 1).value, + 0xFFE85D04, const { - 50: Color.fromRGBO(246, 113, 31, 0.1), - 100: Color.fromRGBO(246, 113, 31, 0.2), - 200: Color.fromRGBO(246, 113, 31, 0.3), - 300: Color.fromRGBO(246, 113, 31, 0.4), - 400: Color.fromRGBO(246, 113, 31, 0.5), - 500: Color.fromRGBO(246, 113, 31, 0.6), - 600: Color.fromRGBO(246, 113, 31, 0.7), - 700: Color.fromRGBO(246, 113, 31, 0.8), - 800: Color.fromRGBO(246, 113, 31, 0.9), - 900: Color.fromRGBO(246, 113, 31, 1), + 50: Color(0x1AE85D04), + 100: Color(0x33E85D04), + 200: Color(0x4DE85D04), + 300: Color(0x66E85D04), + 400: Color(0x80E85D04), + 500: Color(0x99E85D04), + 600: Color(0xB3E85D04), + 700: Color(0xCCE85D04), + 800: Color(0xE6E85D04), + 900: Color(0xFFE85D04), }, ); diff --git a/lib/core/auth_storage.dart b/lib/core/auth_storage.dart new file mode 100644 index 0000000..040ead7 --- /dev/null +++ b/lib/core/auth_storage.dart @@ -0,0 +1,23 @@ +/* + Copyright 2024 Take Control - Software & Infrastructure + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +*/ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +const _keyToken = 'auth_token'; + +const _storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), +); + +/// Reads the auth token from secure storage (null if none). +Future getAuthToken() => _storage.read(key: _keyToken); + +/// Saves the auth token to secure storage. +Future setAuthToken(String token) => + _storage.write(key: _keyToken, value: token); + +/// Removes the auth token (e.g. on logout). +Future clearAuthToken() => _storage.delete(key: _keyToken); diff --git a/lib/core/core.dart b/lib/core/core.dart index 3218475..8de2d9b 100644 --- a/lib/core/core.dart +++ b/lib/core/core.dart @@ -13,8 +13,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +export 'auth_storage.dart'; export 'impl/background.dart'; export 'impl/transfers.dart'; export 'impl/server_api.dart'; +export 'impl/request_utils.dart'; export 'errors/custom_errors.dart'; export 'utils/json_utils.dart'; diff --git a/lib/core/errors/custom_errors.dart b/lib/core/errors/custom_errors.dart index ea898ca..120154c 100644 --- a/lib/core/errors/custom_errors.dart +++ b/lib/core/errors/custom_errors.dart @@ -22,3 +22,8 @@ final class SyncError extends CustomError { final class GetFoldersError extends CustomError { GetFoldersError() : super("Failed to get folders from server."); } + +final class ServerUrlNotSetError extends CustomError { + ServerUrlNotSetError() + : super("Server URL is not set. Add server in Account."); +} diff --git a/lib/core/impl/background.dart b/lib/core/impl/background.dart index dcb5494..e5ae0b9 100644 --- a/lib/core/impl/background.dart +++ b/lib/core/impl/background.dart @@ -1,5 +1,5 @@ /* - Copyright 2023 Take Control - Software & Infrastructure + Copyright 2026 Take Control - Software & Infrastructure Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -61,40 +61,45 @@ class BackgroundAction implements IAction { List files, String userName) async { final reversedFiles = files.reversed; for (FileSystemEntity file in reversedFiles) { - if (!FileSystemEntity.isDirectorySync(file.path)) { - DateTime lastFileDate = await File(file.path).lastModified(); - String dateClassifier = "${lastFileDate.year}-${lastFileDate.month}"; - - final fileHadBeenSynced = currentDeviceSettings.syncedFiles.any((f) => - f.filename.toLowerCase() == file.path.toLowerCase() && - (f.errorMessage ?? "").trim() == "" || - f.failedAttempts > 3); - if (fileHadBeenSynced) { - if (!syncFileController.isClosed) { - syncFileController.add(SyncedFile(file.path)); - } - if (currentDeviceSettings.deleteLocalFilesEnabled ?? false) { - await File(file.path).delete(); - currentDeviceSettings.syncedFiles.removeWhere( - (f) => f.filename.toLowerCase() == file.path.toLowerCase()); + try { + if (!FileSystemEntity.isDirectorySync(file.path)) { + DateTime lastFileDate = await File(file.path).lastModified(); + // Avoid future or bogus dates: clamp to today if lastModified is in future + // or before 2000 (e.g. missing date on device) + final now = DateTime.now(); + if (lastFileDate.isAfter(now) || lastFileDate.year < 2000) { + lastFileDate = now; } - } else { - if (file is File && p.extension(file.path).isNotEmpty) { - var syncedFile = await _transfers.sendFile( - syncFileController, file.path, userName, dateClassifier); + String dateClassifier = "${lastFileDate.year}-${lastFileDate.month}"; - if (syncedFile != null) { - if ((currentDeviceSettings.deleteLocalFilesEnabled ?? false) && - syncedFile.errorMessage == null) { - await File(syncedFile.filename).delete(); - currentDeviceSettings.syncedFiles.remove(syncedFile); - } else { + final fileHadBeenSynced = currentDeviceSettings.syncedFiles.any((f) => + f.filename.toLowerCase() == file.path.toLowerCase() && + (f.errorMessage ?? "").trim() == "" || + f.failedAttempts > 3); + if (fileHadBeenSynced) { + if (!syncFileController.isClosed) { + syncFileController.add(SyncedFile(file.path)); + } + } else { + if (file is File && p.extension(file.path).isNotEmpty) { + // Skip system/metadata files that the server rejects (e.g. desktop.ini, Thumbs.db) + final base = p.basename(file.path).toLowerCase(); + if (base == 'desktop.ini' || base == 'thumbs.db') continue; + SyncedFile? syncedFile; + try { + syncedFile = await _transfers.sendFile( + syncFileController, file.path, userName, dateClassifier); + } catch (e) { + syncedFile = SyncedFile(file.path, + errorMessage: 'Upload failed: ${e.toString()}'); + } + if (syncedFile != null) { SyncedFile? fileFromList = currentDeviceSettings.syncedFiles .firstWhere( (f) => f.filename.toLowerCase() == file.path.toLowerCase(), orElse: () { - currentDeviceSettings.syncedFiles.add(syncedFile); + currentDeviceSettings.syncedFiles.add(syncedFile!); return syncedFile; }); fileFromList.errorMessage = syncedFile.errorMessage; @@ -103,6 +108,16 @@ class BackgroundAction implements IAction { } } } + } catch (e) { + // Continue with next file on any error (e.g. lastModified, permission, etc.) + print('Upload skip ${file.path}: $e'); + if (file is File && !syncFileController.isClosed) { + final failed = SyncedFile(file.path, errorMessage: e.toString()); + if (!currentDeviceSettings.syncedFiles.any( + (f) => f.filename.toLowerCase() == file.path.toLowerCase())) { + currentDeviceSettings.syncedFiles.add(failed); + } + } } } currentDeviceSettings.lastErrorMessage = null; diff --git a/lib/core/impl/request_utils.dart b/lib/core/impl/request_utils.dart index c1350f1..cd8b1ad 100644 --- a/lib/core/impl/request_utils.dart +++ b/lib/core/impl/request_utils.dart @@ -29,3 +29,12 @@ Uri getUrl(String relPath) { } return Uri.parse("${currentDeviceSettings.serverUrl!}/$relPath"); } + +/// Builds the stream URL for video/audio playback (GET with Range support). +Uri getStreamUrl(String serverUrl, String user, String deviceId, String file) { + final uri = Uri.parse(serverUrl.trim()); + return uri.replace( + path: uri.path.isEmpty || uri.path == '/' ? 'stream' : '${uri.path}/stream', + queryParameters: {'User': user, 'DeviceId': deviceId, 'File': file}, + ); +} diff --git a/lib/core/impl/server_api.dart b/lib/core/impl/server_api.dart index d5bdc3b..5606877 100644 --- a/lib/core/impl/server_api.dart +++ b/lib/core/impl/server_api.dart @@ -17,15 +17,85 @@ import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:http/http.dart'; -import 'package:sync_client/core/core.dart'; +import 'package:sync_client/core/auth_storage.dart'; +import 'package:sync_client/core/errors/custom_errors.dart'; +import 'package:sync_client/core/impl/request_utils.dart'; import 'package:sync_client/storage/storage.dart'; -import 'request_utils.dart'; +/// API version sent to the server so it can use compatible behavior from the start. +const String apiVersion = '1.0.8'; + +/// Server expects forward slashes; on Windows paths may use backslash. +String _normalizePath(String path) => path.replaceAll(r'\', '/'); + +/// Returns headers for JSON API requests, including Authorization when a token exists. +Future> _authHeaders() async { + final token = await getAuthToken(); + return { + 'Content-Type': 'application/json; charset=UTF-8', + 'X-Api-Version': apiVersion, + if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token', + }; +} + +/// POST /auth/login. Returns token and userId on success; throws InvalidCredentialError on 401. +Future apiLogin(String email, String password) async { + var request = Request('POST', getUrl('auth/login')); + request.headers.addAll(await _authHeaders()); + request.body = + jsonEncode({'User': email, 'Password': password}); + try { + var streamedResponse = await request.send(); + var response = await Response.fromStream(streamedResponse); + if (response.statusCode == 200) { + final data = json.decode(response.body) as Map; + return AuthResult( + token: data['Token'] as String? ?? '', + userId: data['UserId'] as String? ?? '', + ); + } + if (response.statusCode == 401) throw InvalidCredentialError(); + throw GetFoldersError(); + } catch (e) { + if (e is InvalidCredentialError) rethrow; + throw GetFoldersError(); + } +} + +/// POST /auth/register. Returns token and userId on success. +Future apiRegister(String email, String password) async { + var request = Request('POST', getUrl('auth/register')); + request.headers.addAll(await _authHeaders()); + request.body = + jsonEncode({'User': email, 'Password': password}); + try { + var streamedResponse = await request.send(); + var response = await Response.fromStream(streamedResponse); + if (response.statusCode == 200) { + final data = json.decode(response.body) as Map; + return AuthResult( + token: data['Token'] as String? ?? '', + userId: data['UserId'] as String? ?? '', + ); + } + if (response.statusCode == 401) throw InvalidCredentialError(); + throw GetFoldersError(); + } catch (e) { + if (e is InvalidCredentialError) rethrow; + throw GetFoldersError(); + } +} + +/// Result of successful login or register. +class AuthResult { + AuthResult({required this.token, required this.userId}); + final String token; + final String userId; +} Future?> apiGetFolders(String userName, String deviceId) async { var request = Request('POST', getUrl("folders")); - request.headers.addAll( - {'Content-Type': 'application/json; charset=UTF-8'}); + request.headers.addAll(await _authHeaders()); request.body = jsonEncode({ 'User': userName, @@ -46,6 +116,10 @@ Future?> apiGetFolders(String userName, String deviceId) async { .toList(); return folders; } + // No folder for this device yet (e.g. sync not run) → empty list + if (response.statusCode == 404) { + return []; + } } catch (err) { throw GetFoldersError(); } @@ -55,15 +129,14 @@ Future?> apiGetFolders(String userName, String deviceId) async { Future> apiGetFiles( String userName, String deviceId, String folder) async { var request = Request('POST', getUrl("files")); - request.headers.addAll( - {'Content-Type': 'application/json; charset=UTF-8'}); + request.headers.addAll(await _authHeaders()); request.body = jsonEncode({ 'UserData': { 'User': userName, 'DeviceId': deviceId, }, - "Folder": folder + "Folder": _normalizePath(folder) }); try { var streamedResponse = await request.send(); @@ -81,38 +154,152 @@ Future> apiGetFiles( return []; } -Future apiDeleteAllFiles(String userName, String deviceId) async { - var request = Request('POST', getUrl("delete-all")); - request.headers.addAll( - {'Content-Type': 'application/json; charset=UTF-8'}); +/// Returns all file paths currently on the server (Home + Trash). Used to prune orphan thumbnail cache. +Future> apiGetAllFilePaths( + String userName, String deviceId) async { + final folders = await apiGetFolders(userName, deviceId); + final paths = []; + + Future visit(NetFolder? f, String prefix) async { + if (f == null) return; + final folderPath = prefix.isEmpty ? f.name : '$prefix/${f.name}'; + final normalized = _normalizePath(folderPath); + final files = await apiGetFiles(userName, deviceId, normalized); + for (final name in files) { + paths.add('$normalized/$name'); + } + for (final sub in f.subFolders ?? []) { + await visit(sub, normalized); + } + } + + for (final f in folders ?? []) { + await visit(f, ''); + } + return paths; +} +Future apiMoveToTrash( + String userName, + String deviceId, + List files, +) async { + if (files.isEmpty) return true; + var request = Request('POST', getUrl("move-to-trash")); + request.headers.addAll(await _authHeaders()); request.body = jsonEncode({ - 'User': userName, - 'DeviceId': deviceId, + 'UserData': {'User': userName, 'DeviceId': deviceId}, + 'Files': files.map(_normalizePath).toList(), }); try { - var response = await request.send(); - if (response.statusCode == 200) { - return true; - } + var streamedResponse = await request.send(); + var response = await Response.fromStream(streamedResponse); + return response.statusCode == 200; + } catch (err) { + throw GetFoldersError(); + } +} + +/// Restore files from Trash back to Home. [files] must be paths like "Trash/2024/01/photo.jpg". +Future apiRestoreFromTrash( + String userName, + String deviceId, + List files, +) async { + if (files.isEmpty) return true; + var request = Request('POST', getUrl("restore")); + request.headers.addAll(await _authHeaders()); + request.body = jsonEncode({ + 'UserData': {'User': userName, 'DeviceId': deviceId}, + 'Files': files.map(_normalizePath).toList(), + }); + try { + var streamedResponse = await request.send(); + var response = await Response.fromStream(streamedResponse); + return response.statusCode == 200; + } catch (err) { + throw GetFoldersError(); + } +} + +/// Asks the server to regenerate thumbnails for all (or missing) media files. Server must implement POST "regenerate-thumbnails". +Future apiRegenerateThumbnails(String userName, String deviceId) async { + var request = Request('POST', getUrl("regenerate-thumbnails")); + request.headers.addAll(await _authHeaders()); + request.body = jsonEncode({ + 'UserData': {'User': userName, 'DeviceId': deviceId}, + }); + try { + var streamedResponse = await request.send(); + var response = await Response.fromStream(streamedResponse); + return response.statusCode == 200; + } catch (err) { + throw GetFoldersError(); + } +} + +/// Asks the server to delete thumbnail files that have no corresponding source file (orphans). Server must implement POST "clean-orphan-thumbnails". +Future apiCleanOrphanThumbnails(String userName, String deviceId) async { + var request = Request('POST', getUrl("clean-orphan-thumbnails")); + request.headers.addAll(await _authHeaders()); + request.body = jsonEncode({ + 'UserData': {'User': userName, 'DeviceId': deviceId}, + }); + try { + var streamedResponse = await request.send(); + var response = await Response.fromStream(streamedResponse); + return response.statusCode == 200; + } catch (err) { + throw GetFoldersError(); + } +} + +/// Asks the server to run document detection on existing files (server uses its own logic, e.g. Python function): find documents, clean their thumbnails and metadata, and move them to Trash. Server must implement POST "run-document-detection". +/// Returns the number of documents moved, or -1 on failure. +Future apiRunDocumentDetection(String userName, String deviceId) async { + var request = Request('POST', getUrl("run-document-detection")); + request.headers.addAll(await _authHeaders()); + request.body = jsonEncode({ + 'UserData': {'User': userName, 'DeviceId': deviceId}, + }); + try { + var streamedResponse = await request.send(); + var response = await Response.fromStream(streamedResponse); + if (response.statusCode != 200) return -1; + final body = response.body.trim(); + if (body.isEmpty) return 0; + try { + final data = json.decode(body) as Map; + final moved = + data['Moved'] ?? data['Count'] ?? data['moved'] ?? data['count']; + if (moved is int) return moved; + if (moved is num) return moved.toInt(); + } catch (_) {} + return 0; } catch (err) { throw GetFoldersError(); } - return false; } +/// Quality for image requests: "" or "thumb" = thumbnail, "high" = compressed preview (max 1920px, JPEG 85%), "full" = full res (JPEG 92%). Future apiGetImageBytes( - String userName, String deviceId, String file) async { + String userName, + String deviceId, + String file, { + bool fullQuality = false, + String? quality, +}) async { var request = Request('POST', getUrl("img")); - request.headers.addAll( - {'Content-Type': 'application/json; charset=UTF-8'}); + request.headers.addAll(await _authHeaders()); + String qualityParam = quality ?? (fullQuality ? "full" : ""); request.body = jsonEncode({ 'UserData': { 'User': userName, 'DeviceId': deviceId, }, - "File": file + "File": _normalizePath(file), + "Quality": qualityParam, }); try { @@ -121,7 +308,11 @@ Future apiGetImageBytes( if (response.statusCode == 200) { return response.bodyBytes; } + // Log so we can see 404 etc. (e.g. wrong path on server) + print( + 'apiGetImageBytes: server returned ${response.statusCode} for file: $file'); } catch (err) { + print('apiGetImageBytes error: $err'); throw GetFoldersError(); } return null; diff --git a/lib/core/impl/transfers.dart b/lib/core/impl/transfers.dart index dc8fe8f..e39fad9 100644 --- a/lib/core/impl/transfers.dart +++ b/lib/core/impl/transfers.dart @@ -1,5 +1,5 @@ /* - Copyright 2023 Take Control - Software & Infrastructure + Copyright 2026 Take Control - Software & Infrastructure Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -27,6 +27,15 @@ import 'request_utils.dart'; class Transfers { Transfers(); + /// True if the file path indicates a non-camera source (Viber, WhatsApp, Screenshots) + /// so the server should store it in Trash. + static bool shouldSaveToTrash(String filePath) { + final lower = filePath.toLowerCase(); + return lower.contains('viber') || + lower.contains('whatsapp') || + lower.contains('screenshots'); + } + Future sendFile(StreamController syncFileController, String filename, String userName, String dateClassifier) async { SyncedFile? result; @@ -35,6 +44,9 @@ class Transfers { "user": utf8.encode(userName).toString(), "date": dateClassifier }; + if (Transfers.shouldSaveToTrash(filename)) { + hdr["X-Save-To-Trash"] = "true"; + } request.headers.addEntries(hdr.entries); final file = File(filename); @@ -57,22 +69,14 @@ class Transfers { } else { currentDeviceSettings.lastErrorMessage = "ERROR: $filename response statusCode: ${response.statusCode} ${response.body}"; - - if (!syncFileController.isClosed) { - syncFileController - .addError(SyncError(currentDeviceSettings.lastErrorMessage!)); - } + // Do not addError – record failure and let the upload loop continue with other files return SyncedFile(filename, errorMessage: currentDeviceSettings.lastErrorMessage!); } - } on Exception catch (ex) { + } catch (e) { currentDeviceSettings.lastErrorMessage = - "ERROR: $filename [${ex.toString()}]"; - if (!syncFileController.isClosed) { - syncFileController - .addError(SyncError(currentDeviceSettings.lastErrorMessage!)); - } - + "ERROR: $filename [${e.toString()}]"; + // Do not addError – record failure and let the upload loop continue return SyncedFile(filename, errorMessage: currentDeviceSettings.lastErrorMessage!); } diff --git a/lib/main.dart b/lib/main.dart index 16b5918..d637113 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,17 +16,22 @@ limitations under the License. import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:sync_client/config/config.dart'; import 'package:sync_client/services/services.dart'; import 'package:sync_client/storage/storage.dart'; +import 'package:media_kit/media_kit.dart'; import 'package:media_store_plus/media_store_plus.dart'; import 'package:permission_handler/permission_handler.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + MediaKit.ensureInitialized(); await loadDeviceSettings(); if (Platform.isAndroid) { + // Hide system nav bar; swipe from bottom reveals it (immersive sticky) + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); final mediaStorePlugin = MediaStore(); await mediaStorePlugin.getPlatformSDKInt(); await MediaStore.ensureInitialized(); @@ -41,7 +46,6 @@ Future requestPermissions() async { List permissions = [ Permission.storage, ]; - permissions.add(Permission.manageExternalStorage); permissions.add(Permission.storage); permissions.add(Permission.photos); permissions.add(Permission.audio); @@ -65,6 +69,7 @@ class BlocProviders extends StatelessWidget { BlocProvider(create: (context) => DeviceServicesCubit()), BlocProvider(create: (context) => SyncServicesCubit()), BlocProvider(create: (context) => ThemeCubit()), + BlocProvider(create: (context) => GalleryRefreshCubit()), ], child: const MyApp(), ); diff --git a/lib/models/photo_item.dart b/lib/models/photo_item.dart new file mode 100644 index 0000000..4ac5c56 --- /dev/null +++ b/lib/models/photo_item.dart @@ -0,0 +1,145 @@ +import 'package:intl/intl.dart'; + +class PhotoItem { + final String path; + final String folder; + final DateTime? date; + final String? month; + final bool isVideo; + /// When set (e.g. from "all devices" view), use this device for img/stream requests. + final String? deviceIdOverride; + + PhotoItem( + {required this.path, + required this.folder, + this.date, + this.month, + required this.isVideo, + this.deviceIdOverride}); + + /// Full path for API and cache. Avoid duplicating folder when path already + /// contains it (e.g. server returns "2026/1/file.mp4" for folder "2026/1"). + /// Always uses forward slashes for server/cache. + String get fullPath { + final pathSlash = path.replaceAll(r'\', '/'); + final folderSlash = folder.replaceAll(r'\', '/'); + if (folderSlash.isEmpty) return pathSlash; + // Path may already be full (e.g. "2026/1/file.mp4") – don't prepend folder again + if (pathSlash == folderSlash || pathSlash.startsWith('$folderSlash/')) { + return pathSlash; + } + return '$folderSlash/$pathSlash'; + } + + /// File extensions treated as documents (for "move documents to trash"). + static const List documentExtensions = [ + '.pdf', + '.doc', + '.docx', + '.xls', + '.xlsx', + '.ppt', + '.pptx', + '.txt', + '.rtf', + '.odt', + '.ods', + '.odp', + '.pages', + '.numbers', + '.key', + ]; + + static bool isDocumentPath(String path) { + final lower = path.toLowerCase(); + return documentExtensions.any((ext) => lower.endsWith(ext)); + } + + /// Parse server file entry: when from "all devices" the entry is "deviceId/path". + /// Returns [deviceId, path] or [null, path] when no deviceId prefix. + static List parseDeviceIdPath(String entry) { + final idx = entry.indexOf('/'); + if (idx <= 0) return [null, entry]; + return [entry.substring(0, idx), entry.substring(idx + 1)]; + } + + factory PhotoItem.fromPath(String path, String folder, {String? deviceIdOverride}) { + // Normalize to forward slashes (server/cache expect /; Windows may give \) + final pathNorm = path.replaceAll(r'\', '/'); + final folderNorm = folder.replaceAll(r'\', '/'); + + DateTime? extractedDate; + String? monthKey; + + // Detect video files + final videoExtensions = [ + '.mp4', + '.mov', + '.avi', + '.mkv', + '.webm', + '.m4v', + '.3gp' + ]; + final lowerPath = pathNorm.toLowerCase(); + final isVideo = videoExtensions.any((ext) => lowerPath.endsWith(ext)); + + final filename = pathNorm.split('/').last; + + // 1) Try date from filename (capture/taken date patterns) + // YYYY-MM-DD, YYYY_MM_DD, YYYYMMDD, IMG_20240115_*, 20240115_*, etc. + final dateRegex = RegExp(r'(\d{4})[-_]?(\d{2})[-_]?(\d{2})'); + final match = dateRegex.firstMatch(filename); + if (match != null) { + try { + extractedDate = DateTime( + int.parse(match.group(1)!), + int.parse(match.group(2)!), + int.parse(match.group(3)!), + ); + monthKey = DateFormat('MMMM yyyy').format(extractedDate); + } catch (e) { + // fall through to folder or Recent + } + } + + // 2) If no date in filename, use folder (server often stores as Year/Month = date taken) + if (extractedDate == null && folderNorm.isNotEmpty) { + final parts = folderNorm.split('/').where((s) => s.isNotEmpty).toList(); + if (parts.length >= 1) { + final year = int.tryParse(parts[0]); + if (year != null && year >= 1900 && year <= 2100) { + final month = parts.length >= 2 ? int.tryParse(parts[1]) : 1; + if (month != null && month >= 1 && month <= 12) { + try { + extractedDate = DateTime(year, month, 1); + monthKey = DateFormat('MMMM yyyy').format(extractedDate); + } catch (e) { + // use year only + extractedDate = DateTime(year, 1, 1); + monthKey = DateFormat('MMMM yyyy').format(extractedDate); + } + } else { + extractedDate = DateTime(year, 1, 1); + monthKey = DateFormat('MMMM yyyy').format(extractedDate); + } + } + } + } + + // 3) Only use "Recent" when we truly have no date (unknown capture date) + if (extractedDate == null) { + extractedDate = DateTime.now(); + monthKey = 'Recent'; + } + + return PhotoItem( + path: pathNorm, + folder: folderNorm, + date: extractedDate, + month: monthKey, + isVideo: isVideo, + deviceIdOverride: deviceIdOverride, + ); + } +} diff --git a/lib/screens/account_screen.dart b/lib/screens/account_screen.dart index a040a74..265448c 100644 --- a/lib/screens/account_screen.dart +++ b/lib/screens/account_screen.dart @@ -22,9 +22,297 @@ import 'package:go_router/go_router.dart'; import 'package:sync_client/config/config.dart'; import 'package:sync_client/core/core.dart'; import 'package:sync_client/screens/components/components.dart'; +import 'package:sync_client/services/device_services.dart'; import 'package:sync_client/services/services.dart'; import 'package:sync_client/storage/storage.dart'; +Future _confirmDeleteCachedImages(BuildContext context) async { + final confirm = await showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('Delete cached images'), + content: const Text( + 'This will clear all cached thumbnails and images from this device. ' + 'Images will be re-downloaded when you browse again.', + textAlign: TextAlign.center, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirm != true || !context.mounted) return; + try { + await ThumbnailCacheService.clear(); + await CacheService.clearCache(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Cached images deleted')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } +} + +/// Asks the server to clean orphan thumbnails (delete thumbnails with no source file), then prunes local cache to match. +Future _confirmCleanOrphanThumbnails( + BuildContext context, DeviceServicesCubit deviceService) async { + final confirm = await showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('Clean orphan thumbnails'), + content: const Text( + 'This will ask the server to delete thumbnail files that have no ' + 'corresponding photo/video (orphans). Local cache will be pruned to match.', + textAlign: TextAlign.center, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Clean'), + ), + ], + ), + ); + if (confirm != true || !context.mounted) return; + final user = deviceService.state.currentUser?.email; + final deviceId = deviceService.state.id; + if (user == null || user.isEmpty || deviceId.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Not signed in')), + ); + } + return; + } + try { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Cleaning on server…')), + ); + } + final ok = await apiCleanOrphanThumbnails(user, deviceId); + if (!context.mounted) return; + if (!ok) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Server did not accept clean request or endpoint not implemented')), + ); + return; + } + final serverPaths = await apiGetAllFilePaths(user, deviceId); + final validSet = serverPaths.map((s) => s.replaceAll(r'\', '/')).toSet(); + final cached = await ThumbnailCacheService.listCachedPaths(); + for (final path in cached) { + final normalized = path.replaceAll(r'\', '/'); + if (!validSet.contains(normalized)) { + await ThumbnailCacheService.delete(path); + } + } + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text('Server cleaned orphan thumbnails. Local cache pruned.')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } +} + +/// Asks the server to regenerate thumbnails for media files, then clears local cache so they re-download. +Future _confirmRegenerateThumbnails( + BuildContext context, DeviceServicesCubit deviceService) async { + final confirm = await showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('Regenerate thumbnails'), + content: const Text( + 'This will ask the server to regenerate thumbnails for your media. ' + 'Local cache will be cleared so thumbnails re-download when you browse.', + textAlign: TextAlign.center, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Regenerate'), + ), + ], + ), + ); + if (confirm != true || !context.mounted) return; + final user = deviceService.state.currentUser?.email; + final deviceId = deviceService.state.id; + if (user == null || user.isEmpty || deviceId.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Not signed in')), + ); + } + return; + } + try { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Regenerating on server…')), + ); + } + final ok = await apiRegenerateThumbnails(user, deviceId); + if (!context.mounted) return; + if (!ok) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Server did not accept request or endpoint not implemented')), + ); + return; + } + await ThumbnailCacheService.clear(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text('Server regenerating thumbnails. Local cache cleared.')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } +} + +Future _confirmDeleteSyncMetadata( + BuildContext context, DeviceServicesCubit deviceService) async { + final confirm = await showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('Delete Sync Metadata'), + content: const Text( + 'This will clear sync metadata (synced file list, last sync time) from ' + 'device settings (deviceSettings.json). Your account and selected folders ' + 'to sync are kept.', + textAlign: TextAlign.center, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirm != true || !context.mounted) return; + try { + await deviceService.clearSyncMetadata(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Sync metadata deleted')), + ); + } + } catch (e) { + if (context.mounted) { + deviceService.edit((state) { + state.lastErrorMessage = 'Failed to delete sync metadata: $e'; + state.successMessage = null; + }); + } + } +} + +Widget _accountActionRow({ + required BuildContext context, + required String title, + required String description, + required String buttonLabel, + required ColorScheme colorScheme, + required VoidCallback onPressed, +}) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + description, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: SizedBox( + width: 120, + child: FilledButton.tonal( + onPressed: onPressed, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 10), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text(buttonLabel), + ), + ), + ), + ], + ), + ), + ), + ); +} + class AccountScreen extends StatelessWidget { const AccountScreen({super.key}); @@ -52,143 +340,188 @@ class AccountScreenView extends StatelessWidget { return Container(); } + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; + return Container( margin: const EdgeInsets.only( - left: 10.0, right: 10.0, top: 30.0, bottom: 30.0), + left: 16.0, right: 16.0, top: 24.0, bottom: 24.0), child: SingleChildScrollView( - child: Column(children: [ - ListView( - shrinkWrap: true, - children: [ - Padding( - padding: const EdgeInsets.only(left: 25), - child: Text( - "Nickname: ${deviceService.state.currentUser?.email ?? ""}", - style: const TextStyle( - fontSize: 25, fontWeight: FontWeight.bold), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "${deviceService.state.currentUser?.email ?? ""}", + style: + textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 24), + Text( + "Gallery", + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.primary, + ), + ), + const SizedBox(height: 12), + reactiveBuilder( + buildWhen: (previous, current) => + previous.showAllDevices != current.showAllDevices, + child: (context, state) { + final showAll = state.showAllDevices; + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: SwitchListTile( + title: Text( + showAll + ? "All photos for this account" + : "Only this device", + style: textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + showAll + ? "Gallery shows photos from all your devices." + : "Gallery shows only photos from this device.", + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + value: showAll, + onChanged: (value) => + deviceService.updateShowAllDevices(value), + ), + ); + }, + ), + const SizedBox(height: 24), + Text( + "Data & cache", + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.primary, + ), + ), + const SizedBox(height: 12), + _accountActionRow( + context: context, + title: "Delete cached images", + description: + "Clear thumbnails from this device. They will re-download when you browse.", + buttonLabel: "Delete", + colorScheme: colorScheme, + onPressed: () => _confirmDeleteCachedImages(context), + ), + _accountActionRow( + context: context, + title: "Delete Sync Metadata", + description: + "Clear synced file list and last sync time. Account and folders are kept.", + buttonLabel: "Delete", + colorScheme: colorScheme, + onPressed: () => + _confirmDeleteSyncMetadata(context, deviceService), + ), + _accountActionRow( + context: context, + title: "Clean orphan thumbnails", + description: + "Server deletes thumbnails with no source file. Local cache is pruned to match.", + buttonLabel: "Clean", + colorScheme: colorScheme, + onPressed: () => + _confirmCleanOrphanThumbnails(context, deviceService), + ), + _accountActionRow( + context: context, + title: "Regenerate thumbnails", + description: + "Server regenerates thumbnails for your media. Local cache is cleared.", + buttonLabel: "Regenerate", + colorScheme: colorScheme, + onPressed: () => + _confirmRegenerateThumbnails(context, deviceService), + ), + const SizedBox(height: 24), + Text( + "Account", + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.primary, + ), + ), + const SizedBox(height: 12), + _accountActionRow( + context: context, + title: "Delete my local settings", + description: + "Remove account from this device. Photos stay on the server. Use same nickname to sign in again.", + buttonLabel: "Delete", + colorScheme: colorScheme, + onPressed: () => showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('Delete my local settings'), + content: const Wrap( + spacing: 20, + runSpacing: 20, + children: [ + Text( + 'WARNING: This operation will delete your settings for this application.', + textAlign: TextAlign.center, + ), + Text( + 'After this operation your account will be removed from this device, but your photos/videos will still exist on the server.', + textAlign: TextAlign.center, + ), + Text( + 'Use the same nickname next time to access your synced files from the server.', + textAlign: TextAlign.center, + ), + ], + ), + actions: [ + okButton(context, "Confirm", onPressed: () async { + deleteDeviceSettings(context, deviceService); + if (context.mounted) Navigator.pop(context); + }), + cancelButton(context), + ], ), ), - ], - ), - SizedBox( - width: double.maxFinite, - child: okButton(context, "Delete my server files", - onPressed: () => showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: const Text('Delete my server files'), - content: const Wrap( - spacing: 20, - runSpacing: 20, - children: [ - Text( - 'WARNING: This operation will cause deleting all the files synced by this device from the server.', - textAlign: TextAlign.center, - ), - Text( - 'Make sure they still exist on your device. Check option Deleting:ON/OFF.', - textAlign: TextAlign.center, - ), - Text( - 'If you confirm your files will be permanently removed from the server for you Nickname and device.', - textAlign: TextAlign.center, - ), - ]), - actions: [ - okButton(context, "Confirm", onPressed: () async { - deleteServerFiles(deviceService); - Navigator.pop(context); - }), - cancelButton(context) - ], - ))), - ), - SizedBox( - width: double.maxFinite, - child: okButton(context, "Delete my local settings", - onPressed: () => showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: const Text('Delete my local settings'), - content: const Wrap( - spacing: 20, - runSpacing: 20, - children: [ - Text( - 'WARNING: This operation will delete you settings for this application.', - textAlign: TextAlign.center, - ), - Text( - 'After this operation your account will be removed, but your photos/videos will still exist on the server.', - textAlign: TextAlign.center, - ), - Text( - 'Make sure next time you enter the application with the same Nickname if you want to access your synced files from the server.', - textAlign: TextAlign.center, - ), - ]), - actions: [ - okButton(context, "Confirm", onPressed: () async { - deleteDeviceSettings(context, deviceService); - Navigator.pop(context); - }), - cancelButton(context) - ], - )))), - Padding( - padding: const EdgeInsets.only(left: 25, right: 25), + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), child: reactiveBuilder( - buildWhen: (previous, current) => - current.lastErrorMessage == null || - previous.lastErrorMessage != current.lastErrorMessage, - child: (context, state) => Text( - deviceService.state.lastErrorMessage ?? - deviceService.state.successMessage ?? - "", - style: deviceService.state.lastErrorMessage == null - ? successTextStyle(context) - : errorTextStyle(context), - textAlign: TextAlign.center))), - ]), + buildWhen: (previous, current) => + current.lastErrorMessage == null || + previous.lastErrorMessage != current.lastErrorMessage, + child: (context, state) => Text( + deviceService.state.lastErrorMessage ?? + deviceService.state.successMessage ?? + "", + style: deviceService.state.lastErrorMessage == null + ? successTextStyle(context) + : errorTextStyle(context), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), ), ); } - void deleteServerFiles(DeviceServicesCubit deviceService) async { - String errorText = ""; - if (!deviceService.isAuthenticated()) { - errorText = "No logged in user."; - } - - if (deviceService.state.currentUser!.email.isEmpty) { - errorText = "Missing user name."; - } - if (deviceService.state.serverUrl == null) { - errorText = "Please select server address."; - } - if (errorText.isEmpty) { - bool deleted = await apiDeleteAllFiles( - deviceService.state.currentUser!.email, deviceService.state.id); - if (!deleted) { - errorText = - "An error ocurred while deleting your files from the server."; - } - } - if (errorText.isNotEmpty) { - await deviceService.edit((state) { - state.lastErrorMessage = errorText; - }); - return; - } - - await deviceService.edit((state) { - state.lastErrorMessage = null; - state.lastSyncDateTime = null; - state.syncedFiles.clear(); - state.successMessage = "Files deleted successfully from the server."; - }); - } - Future deleteDeviceSettings( BuildContext context, DeviceServicesCubit deviceService) async { try { diff --git a/lib/screens/components/components.dart b/lib/screens/components/components.dart index 2e9ad06..7772f58 100644 --- a/lib/screens/components/components.dart +++ b/lib/screens/components/components.dart @@ -4,3 +4,6 @@ export 'widgets.dart'; export 'bloc_widgets.dart'; export 'status_widgets.dart'; export 'grid_component.dart'; +export 'gallery_photo_tile.dart'; +export 'photo_viewer_screen.dart'; +export 'video_player_screen.dart'; diff --git a/lib/screens/components/edit_server.dart b/lib/screens/components/edit_server.dart index 182286c..62f6560 100644 --- a/lib/screens/components/edit_server.dart +++ b/lib/screens/components/edit_server.dart @@ -71,7 +71,6 @@ class EditServerFormState extends State { state.serverUrl = newServer; state.lastErrorMessage = null; state.lastSyncDateTime = null; - state.deleteLocalFilesEnabled = false; }); // ignore: use_build_context_synchronously context.pop(); diff --git a/lib/screens/components/gallery_app_bar.dart b/lib/screens/components/gallery_app_bar.dart new file mode 100644 index 0000000..05a0bd2 --- /dev/null +++ b/lib/screens/components/gallery_app_bar.dart @@ -0,0 +1,70 @@ +// lib/screens/components/gallery_app_bar.dart + +import 'package:flutter/material.dart'; +import 'package:sync_client/config/theme/app_bar.dart'; + +class GalleryAppBar { + static AppBar appBar( + BuildContext context, { + required int crossAxisCount, + required bool isGridView, + required void Function(int) onGridSizeChanged, + required VoidCallback onViewModeToggle, + VoidCallback? onSelectPressed, + VoidCallback? onMoveDocumentsToTrashPressed, + }) { + // Get the base app bar + final baseAppBar = MainAppBar.appBar(context); + + // Add gallery-specific actions to the existing actions + final List galleryActions = [ + if (onMoveDocumentsToTrashPressed != null) + IconButton( + icon: const Icon(Icons.description), + onPressed: onMoveDocumentsToTrashPressed, + tooltip: 'Move documents to Trash', + ), + if (onSelectPressed != null) + IconButton( + icon: const Icon(Icons.checklist_rtl), + onPressed: onSelectPressed, + tooltip: 'Select', + ), + // Grid size selector + PopupMenuButton( + icon: const Icon(Icons.grid_view), + onSelected: onGridSizeChanged, + itemBuilder: (context) => [ + const PopupMenuItem(value: 2, child: Text('2 columns')), + const PopupMenuItem(value: 3, child: Text('3 columns')), + const PopupMenuItem(value: 4, child: Text('4 columns')), + const PopupMenuItem(value: 5, child: Text('5 columns')), + ], + ), + // View mode toggle + IconButton( + icon: Icon(isGridView ? Icons.view_list : Icons.grid_view), + onPressed: onViewModeToggle, + tooltip: isGridView ? 'List view' : 'Grid view', + ), + // Keep existing actions if any + if (baseAppBar.actions != null) ...baseAppBar.actions!, + ]; + + return AppBar( + title: baseAppBar.title, + leading: baseAppBar.leading, + leadingWidth: baseAppBar.leadingWidth, + actions: galleryActions, + backgroundColor: baseAppBar.backgroundColor, + foregroundColor: baseAppBar.foregroundColor, + elevation: baseAppBar.elevation, + scrolledUnderElevation: baseAppBar.scrolledUnderElevation, + surfaceTintColor: baseAppBar.surfaceTintColor, + iconTheme: baseAppBar.iconTheme, + actionsIconTheme: baseAppBar.actionsIconTheme, + centerTitle: baseAppBar.centerTitle, + flexibleSpace: baseAppBar.flexibleSpace, + ); + } +} diff --git a/lib/screens/components/gallery_photo_tile.dart b/lib/screens/components/gallery_photo_tile.dart new file mode 100644 index 0000000..71212cd --- /dev/null +++ b/lib/screens/components/gallery_photo_tile.dart @@ -0,0 +1,279 @@ +// lib/screens/components/gallery_photo_tile.dart + +import 'dart:async'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:sync_client/config/theme/app_theme.dart'; +import 'package:sync_client/models/photo_item.dart'; +import 'package:visibility_detector/visibility_detector.dart'; +import 'package:sync_client/services/services.dart'; +import 'package:sync_client/core/core.dart'; + +class MonthHeaderDelegate extends SliverPersistentHeaderDelegate { + final String month; + + MonthHeaderDelegate({required this.month}); + + @override + Widget build( + BuildContext context, double shrinkOffset, bool overlapsContent) { + return Container( + decoration: GalleryStyles.monthHeaderDecoration(context), + padding: GalleryStyles.monthHeaderPadding, + child: Text( + month, + style: GalleryStyles.monthHeaderTextStyle(context), + ), + ); + } + + @override + double get maxExtent => 48; + + @override + double get minExtent => 48; + + @override + bool shouldRebuild(covariant MonthHeaderDelegate oldDelegate) { + return month != oldDelegate.month; + } +} + +// Enhanced photo tile with lazy loading +class GalleryPhotoTile extends StatefulWidget { + final PhotoItem photo; + final VoidCallback onTap; + final bool isSelectionMode; + final bool isSelected; + + const GalleryPhotoTile({ + Key? key, + required this.photo, + required this.onTap, + this.isSelectionMode = false, + this.isSelected = false, + }) : super(key: key); + + @override + State createState() => _GalleryPhotoTileState(); +} + +class _GalleryPhotoTileState extends State + with AutomaticKeepAliveClientMixin { + bool _shouldLoad = false; + Future? _imageFuture; + + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + + return VisibilityDetector( + key: Key(widget.photo.path), + onVisibilityChanged: (info) { + if (info.visibleFraction > 0 && !_shouldLoad) { + setState(() { + _shouldLoad = true; + final deviceService = context.read(); + _imageFuture = _loadImage(deviceService); + }); + } + }, + child: GestureDetector( + onTap: widget.onTap, + child: Container( + decoration: GalleryStyles.photoTileDecoration(context), + child: Stack( + fit: StackFit.expand, + children: [ + // Thumbnail image + _shouldLoad + ? FutureBuilder( + future: _imageFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.waiting) { + return Center( + child: SizedBox( + width: GalleryStyles.loadingIndicatorSize, + height: GalleryStyles.loadingIndicatorSize, + child: CircularProgressIndicator( + strokeWidth: + GalleryStyles.loadingIndicatorStrokeWidth, + valueColor: AlwaysStoppedAnimation( + GalleryStyles.loadingIndicatorColor(context), + ), + ), + ), + ); + } else if (snapshot.hasData && snapshot.data != null) { + return ClipRRect( + borderRadius: BorderRadius.circular( + GalleryStyles.borderRadius), + child: Image.memory( + snapshot.data!, + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (context, error, stackTrace) { + return _buildErrorWidget(); + }, + ), + ); + } else { + return _buildErrorWidget(); + } + }, + ) + : Center( + child: Icon( + Icons.image, + color: GalleryStyles.errorIconColor(context), + size: GalleryStyles.errorIconSize, + ), + ), + + // Video play button overlay + if (widget.photo.isVideo) + Center( + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withOpacity(0.8), + width: 2, + ), + ), + child: const Icon( + Icons.play_arrow, + color: Colors.white, + size: 32, + ), + ), + ), + + // Selection overlay (checkbox) + if (widget.isSelectionMode) + Positioned( + top: 4, + left: 4, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: widget.isSelected + ? Theme.of(context).colorScheme.primary + : Colors.white.withOpacity(0.8), + shape: BoxShape.circle, + border: Border.all( + color: widget.isSelected + ? Theme.of(context).colorScheme.primary + : Colors.grey, + width: 2, + ), + ), + child: widget.isSelected + ? const Icon(Icons.check, color: Colors.white, size: 20) + : null, + ), + ), + + // Video duration badge (optional) + if (widget.photo.isVideo) + Positioned( + bottom: 4, + right: 4, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon( + Icons.videocam, + color: Colors.white, + size: 12, + ), + SizedBox(width: 2), + Text( + 'Video', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildErrorWidget() { + return Center( + child: Icon( + Icons.broken_image, + color: GalleryStyles.errorIconColor(context), + size: GalleryStyles.errorIconSize, + ), + ); + } + + static const Duration _loadTimeout = Duration(seconds: 20); + + Future _loadImage(DeviceServicesCubit deviceService) async { + final fullPath = widget.photo.fullPath; + if (fullPath.isEmpty) { + debugPrint('Thumbnail load: empty path, skipping'); + return null; + } + try { + // 1) Cache first (memory then disk), then server + final cachedThumb = + await EnhancedCacheService.getCachedThumbnail(fullPath); + if (cachedThumb != null && cachedThumb.isNotEmpty) { + return cachedThumb; + } + + // 2) Load from server – always when not in cache + debugPrint('Thumbnail load from server: $fullPath'); + final deviceId = + widget.photo.deviceIdOverride ?? deviceService.state.id; + final data = await apiGetImageBytes( + deviceService.state.currentUser!.email, + deviceId, + fullPath, + ).timeout(_loadTimeout, onTimeout: () { + debugPrint('Thumbnail load timeout: $fullPath'); + return null; + }); + + if (data != null && data.isNotEmpty) { + debugPrint( + 'Thumbnail loaded from server (${data.length} bytes): $fullPath'); + await EnhancedCacheService.cacheThumbnail(fullPath, data); + return data; + } + + debugPrint('Thumbnail load: server returned no data: $fullPath'); + return null; + } catch (e) { + debugPrint('Error loading image $fullPath: $e'); + return null; + } + } +} diff --git a/lib/screens/components/photo_viewer_screen.dart b/lib/screens/components/photo_viewer_screen.dart new file mode 100644 index 0000000..3c155c9 --- /dev/null +++ b/lib/screens/components/photo_viewer_screen.dart @@ -0,0 +1,1213 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:saver_gallery/saver_gallery.dart'; +import 'package:intl/intl.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:photo_view/photo_view_gallery.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:sync_client/config/config.dart'; +import 'package:sync_client/config/theme/app_theme.dart'; +import 'package:sync_client/core/core.dart'; +import 'package:sync_client/models/photo_item.dart'; +import 'package:sync_client/screens/components/video_player_screen.dart'; +import 'package:path/path.dart' as p; +import 'package:sync_client/services/services.dart'; + +class PhotoViewerScreen extends StatefulWidget { + final List photos; + final int initialIndex; + + /// When true (e.g. opened from Trash), hide "Move to Trash" option. + final bool isFromTrash; + + const PhotoViewerScreen({ + Key? key, + required this.photos, + required this.initialIndex, + this.isFromTrash = false, + }) : super(key: key); + + @override + State createState() => _PhotoViewerScreenState(); +} + +class _PhotoViewerScreenState extends State { + late PageController _pageController; + late int _currentIndex; + bool _showOverlay = true; + final Map _thumbnailCache = {}; + final Map _highImageCache = {}; + final Map _fullImageCache = {}; + final Map _loadingStates = {}; + final Map _highImageLoadingStates = {}; + final Map _fullImageLoadingStates = {}; + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _currentIndex = widget.initialIndex; + _pageController = PageController(initialPage: widget.initialIndex); + + // Thumbnail first, then "high" (compressed, fast), then "full" in background + _loadThumbnail(_currentIndex); + Future.delayed(const Duration(milliseconds: 150), () { + if (!mounted) return; + _loadHighThenFullQualityImage(_currentIndex); + }); + _preloadAdjacentImages(); + + // Hide status bar for immersive experience + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + + // Request focus for keyboard events + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + _pageController.dispose(); + _focusNode.dispose(); + // Restore same mode as main shell (immersive sticky on Android) + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + super.dispose(); + } + + void _goToPrevious() { + if (_currentIndex > 0) { + _pageController.previousPage( + duration: GalleryAnimations.pageTransition, + curve: GalleryAnimations.defaultCurve, + ); + } + } + + void _goToNext() { + if (_currentIndex < widget.photos.length - 1) { + _pageController.nextPage( + duration: GalleryAnimations.pageTransition, + curve: GalleryAnimations.defaultCurve, + ); + } + } + + void _preloadAdjacentImages() { + // Load full quality for current and adjacent images + final indices = [ + _currentIndex - 1, + _currentIndex, + _currentIndex + 1, + ]; + + for (final index in indices) { + if (index >= 0 && index < widget.photos.length) { + // Load thumbnail first for quick display + _loadThumbnail(index); + + // Then load high (fast) then full for current and adjacent + if (index == _currentIndex || (index - _currentIndex).abs() <= 1) { + _loadHighThenFullQualityImage(index); + } + } + } + } + + Future _loadThumbnail(int index) async { + if (_thumbnailCache.containsKey(index) || _loadingStates[index] == true) { + return; + } + + setState(() { + _loadingStates[index] = true; + }); + + try { + final photo = widget.photos[index]; + final deviceService = context.read(); + + // Check cache for thumbnail (disk + memory) + final fullPath = photo.fullPath; + final cachedImage = + await EnhancedCacheService.getCachedThumbnail(fullPath); + if (cachedImage != null) { + if (mounted) { + setState(() { + _thumbnailCache[index] = cachedImage; + _loadingStates[index] = false; + }); + } + return; + } + + // Load thumbnail from server + final deviceId = + photo.deviceIdOverride ?? deviceService.state.id; + final data = await apiGetImageBytes( + deviceService.state.currentUser!.email, + deviceId, + fullPath, + fullQuality: false, + ); + + if (data != null && mounted) { + setState(() { + _thumbnailCache[index] = data; + _loadingStates[index] = false; + }); + + // Cache the thumbnail (disk + memory) + await EnhancedCacheService.cacheThumbnail(fullPath, data); + } + } catch (e) { + debugPrint('Error loading thumbnail: $e'); + if (mounted) { + setState(() { + _loadingStates[index] = false; + }); + } + } + } + + /// Load "high" (compressed, fast) first, then "full" in background. Display: full > high > thumbnail. + Future _loadHighThenFullQualityImage(int index) async { + final photo = widget.photos[index]; + if (photo.isVideo) return; + final deviceService = context.read(); + final fullPath = photo.fullPath; + + // 1) If we have full in cache, use it + final cachedFull = await EnhancedCacheService.getCachedImage(fullPath); + if (cachedFull != null && mounted) { + setState(() { + _fullImageCache[index] = cachedFull; + _fullImageLoadingStates[index] = false; + _highImageLoadingStates[index] = false; + }); + return; + } + + // 2) Load "high" first (server: max 1920px, JPEG 85% — fast) + if (!_highImageCache.containsKey(index) && + _highImageLoadingStates[index] != true) { + if (mounted) { + setState(() => _highImageLoadingStates[index] = true); + } + try { + final deviceId = + photo.deviceIdOverride ?? deviceService.state.id; + final highData = await apiGetImageBytes( + deviceService.state.currentUser!.email, + deviceId, + fullPath, + quality: 'high', + ); + if (highData != null && mounted) { + setState(() { + _highImageCache[index] = highData; + _highImageLoadingStates[index] = false; + }); + } else if (mounted) { + setState(() => _highImageLoadingStates[index] = false); + } + } catch (e) { + debugPrint('Error loading high quality: $e'); + if (mounted) { + setState(() => _highImageLoadingStates[index] = false); + } + } + } + + // 3) Load "full" in background (server: JPEG 92%) + if (_fullImageCache.containsKey(index) || + _fullImageLoadingStates[index] == true) { + return; + } + if (mounted) { + setState(() => _fullImageLoadingStates[index] = true); + } + try { + final deviceId = + photo.deviceIdOverride ?? deviceService.state.id; + final fullData = await apiGetImageBytes( + deviceService.state.currentUser!.email, + deviceId, + fullPath, + quality: 'full', + ); + if (fullData != null && mounted) { + setState(() { + _fullImageCache[index] = fullData; + _fullImageLoadingStates[index] = false; + }); + EnhancedCacheService.cacheImage(fullPath, fullData) + .catchError((Object e) { + debugPrint('Failed to cache image: $e'); + }); + } else if (mounted) { + setState(() => _fullImageLoadingStates[index] = false); + } + } catch (e) { + debugPrint('Error loading full quality: $e'); + if (mounted) { + setState(() => _fullImageLoadingStates[index] = false); + } + } + } + + void _onPageChanged(int index) { + setState(() { + _currentIndex = index; + // When swiping to a video, hide overlay so play button is tappable and video is visible + if (widget.photos[index].isVideo) _showOverlay = false; + }); + + _loadHighThenFullQualityImage(index); + _preloadAdjacentImages(); + } + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.sizeOf(context).width; + final sideTapWidth = (screenWidth * 0.15).clamp(56.0, 120.0); + + return Scaffold( + backgroundColor: Colors.black, + body: Focus( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: (FocusNode node, KeyEvent event) { + if (event is KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + _goToPrevious(); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + _goToNext(); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.escape) { + Navigator.of(context).pop(); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.space) { + setState(() { + _showOverlay = !_showOverlay; + }); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + }, + child: GestureDetector( + onTap: () { + setState(() { + _showOverlay = !_showOverlay; + }); + }, + child: Stack( + children: [ + // Photo gallery (horizontal swipe already supported by PageView) + PhotoViewGallery.builder( + pageController: _pageController, + itemCount: widget.photos.length, + onPageChanged: _onPageChanged, + scrollPhysics: const BouncingScrollPhysics(), + backgroundDecoration: const BoxDecoration(color: Colors.black), + builder: (context, index) { + final photo = widget.photos[index]; + final fullImage = _fullImageCache[index]; + final highImage = _highImageCache[index]; + final thumbnail = _thumbnailCache[index]; + final bestDisplay = fullImage ?? highImage; + final isFullQuality = fullImage != null; + final isLoadingFull = _fullImageLoadingStates[index] == true; + + if (thumbnail != null || bestDisplay != null) { + return PhotoViewGalleryPageOptions.customChild( + child: Stack( + fit: StackFit.expand, + children: [ + AnimatedCrossFade( + // When thumbnail-only: disable zoom/pan so horizontal swipe goes to gallery (next/prev) + firstChild: thumbnail != null + ? PhotoView( + imageProvider: MemoryImage(thumbnail), + minScale: PhotoViewComputedScale.contained, + maxScale: + PhotoViewComputedScale.covered * 3, + backgroundDecoration: const BoxDecoration( + color: Colors.black), + heroAttributes: PhotoViewHeroAttributes( + tag: + '${widget.photos[index].path}_$index', + ), + gaplessPlayback: true, + disableGestures: !isFullQuality, + ) + : const SizedBox.shrink(), + secondChild: bestDisplay != null + ? PhotoView( + imageProvider: MemoryImage(bestDisplay), + minScale: PhotoViewComputedScale.contained, + maxScale: + PhotoViewComputedScale.covered * 3, + backgroundDecoration: const BoxDecoration( + color: Colors.black), + heroAttributes: PhotoViewHeroAttributes( + tag: + '${widget.photos[index].path}_$index', + ), + gaplessPlayback: true, + disableGestures: !isFullQuality, + ) + : const SizedBox.shrink(), + crossFadeState: bestDisplay != null + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 1000), + layoutBuilder: (Widget topChild, Key topChildKey, + Widget bottomChild, Key bottomChildKey) { + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + key: bottomChildKey, + child: bottomChild, + ), + Positioned.fill( + key: topChildKey, + child: topChild, + ), + ], + ); + }, + ), + + // Video play button overlay + if (photo.isVideo) + Center( + child: GestureDetector( + onTap: () => _playVideo(photo), + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 3, + ), + boxShadow: [ + BoxShadow( + color: + Colors.black.withValues(alpha: 0.5), + blurRadius: 20, + spreadRadius: 5, + ), + ], + ), + child: const Icon( + Icons.play_arrow, + color: Colors.white, + size: 50, + ), + ), + ), + ), + + // HD loading indicator - Fixed positioning + if (isLoadingFull && !isFullQuality) + Positioned( + bottom: 120, + left: 0, + right: 0, + child: AnimatedOpacity( + opacity: 1.0, + duration: const Duration(milliseconds: 300), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + decoration: BoxDecoration( + color: + Colors.black.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: + Colors.white.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: + AlwaysStoppedAnimation( + Colors.white + .withValues(alpha: 0.9), + ), + ), + ), + const SizedBox(width: 10), + const Text( + 'Upgrading to HD', + style: TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } + + // No image at all yet - show loading + return PhotoViewGalleryPageOptions.customChild( + child: Container( + color: Colors.black, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + const SizedBox(height: 20), + Text( + photo.isVideo + ? 'Loading video preview...' + : 'Loading photo...', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.8), + fontSize: 14, + ), + ), + ], + ), + ), + ), + ); + }, + ), + + // When overlay hidden: tap center shows controls — but NOT when current is video (so play button receives tap) + if (!_showOverlay && !widget.photos[_currentIndex].isVideo) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => setState(() => _showOverlay = true), + ), + ), + + // Always-active left/right tap zones for previous/next (work even when overlay hidden) + if (widget.photos.length > 1 && _currentIndex > 0) + Positioned( + left: 0, + top: 0, + bottom: 0, + width: sideTapWidth, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _goToPrevious, + ), + ), + if (widget.photos.length > 1 && + _currentIndex < widget.photos.length - 1) + Positioned( + right: 0, + top: 0, + bottom: 0, + width: sideTapWidth, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _goToNext, + ), + ), + + // Navigation arrows - left and right sides of photo (clear of top bar) + if (widget.photos.length > 1 && _currentIndex > 0) + Positioned( + left: 20, + top: 0, + bottom: 0, + child: Center( + child: AnimatedOpacity( + opacity: _showOverlay ? 1.0 : 0.0, + duration: GalleryAnimations.overlayFade, + child: IgnorePointer( + ignoring: !_showOverlay, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: _goToPrevious, + borderRadius: BorderRadius.circular(28), + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withOpacity(0.2), + width: 1, + ), + ), + child: const Icon( + Icons.arrow_back_ios_new, + color: Colors.white, + size: 24, + ), + ), + ), + ), + ), + ), + ), + ), + + if (widget.photos.length > 1 && + _currentIndex < widget.photos.length - 1) + Positioned( + right: 20, + top: 0, + bottom: 0, + child: Center( + child: AnimatedOpacity( + opacity: _showOverlay ? 1.0 : 0.0, + duration: GalleryAnimations.overlayFade, + child: IgnorePointer( + ignoring: !_showOverlay, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: _goToNext, + borderRadius: BorderRadius.circular(28), + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withOpacity(0.2), + width: 1, + ), + ), + child: const Icon( + Icons.arrow_forward_ios, + color: Colors.white, + size: 24, + ), + ), + ), + ), + ), + ), + ), + ), + + // Overlay with controls; center ignores pointer so video play button receives taps + AnimatedOpacity( + opacity: _showOverlay ? 1.0 : 0.0, + duration: GalleryAnimations.overlayFade, + child: IgnorePointer( + ignoring: !_showOverlay, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: PhotoViewerStyles.overlayGradientColors, + stops: PhotoViewerStyles.overlayGradientStops, + ), + ), + child: Column( + children: [ + // Top bar + SafeArea( + child: Padding( + padding: PhotoViewerStyles.topBarPadding, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.close, + color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + Expanded( + child: Text( + '${_currentIndex + 1} / ${widget.photos.length}', + style: + PhotoViewerStyles.pageIndicatorStyle(), + textAlign: TextAlign.center, + ), + ), + // Quality indicator + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: + _fullImageCache.containsKey(_currentIndex) + ? Container( + key: const ValueKey('hd-badge'), + padding: PhotoViewerStyles + .qualityIndicatorPadding, + decoration: PhotoViewerStyles + .qualityIndicatorDecoration(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon( + Icons.hd, + color: Colors.white, + size: 16, + ), + SizedBox(width: 4), + Text( + 'HD', + style: PhotoViewerStyles + .qualityIndicatorTextStyle, + ), + ], + ), + ) + : const SizedBox( + key: ValueKey('empty'), + width: 50), + ), + const SizedBox(width: 8), + PopupMenuButton( + icon: const Icon(Icons.more_vert, + color: Colors.white), + onSelected: (value) { + switch (value) { + case 'share': + _shareImage(); + break; + case 'download': + _downloadImage(); + break; + case 'trash': + _moveToTrash(); + break; + case 'info': + _showImageInfo(); + break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'share', + child: ListTile( + leading: Icon(Icons.share), + title: Text('Share'), + contentPadding: EdgeInsets.zero, + ), + ), + const PopupMenuItem( + value: 'download', + child: ListTile( + leading: Icon(Icons.download), + title: Text('Download (full quality)'), + contentPadding: EdgeInsets.zero, + ), + ), + if (!widget.isFromTrash) + const PopupMenuItem( + value: 'trash', + child: ListTile( + leading: Icon(Icons.delete_outline), + title: Text('Move to Trash'), + contentPadding: EdgeInsets.zero, + ), + ), + const PopupMenuItem( + value: 'info', + child: ListTile( + leading: Icon(Icons.info), + title: Text('Info'), + contentPadding: EdgeInsets.zero, + ), + ), + ], + ), + ], + ), + ), + ), + + // Center: ignore pointer so taps reach gallery (e.g. video play button) + Expanded( + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: + PhotoViewerStyles.overlayGradientColors, + stops: PhotoViewerStyles.overlayGradientStops, + ), + ), + ), + ), + ), + + // Bottom info + SafeArea( + child: Padding( + padding: PhotoViewerStyles.bottomInfoPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (widget + .photos[_currentIndex].isVideo) ...[ + const Icon( + Icons.videocam, + color: Colors.white, + size: 20, + ), + const SizedBox(width: 8), + ], + Expanded( + child: Text( + widget.photos[_currentIndex].path + .split('/') + .last, + style: + PhotoViewerStyles.photoTitleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + widget.photos[_currentIndex].folder, + style: PhotoViewerStyles.photoSubtitleStyle(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (widget.photos[_currentIndex].date != + null) ...[ + const SizedBox(height: 4), + Text( + DateFormat('MMM d, yyyy • h:mm a').format( + widget.photos[_currentIndex].date!, + ), + style: + PhotoViewerStyles.photoSubtitleStyle(), + ), + ], + ], + ), + ), + ), + ], + ), + ), + ), + ), + + // Page indicator dots (only show for reasonable number of photos) + if (widget.photos.length > 1 && + widget.photos.length <= 10 && + _showOverlay) + Positioned( + bottom: 90, + left: 0, + right: 0, + child: AnimatedOpacity( + opacity: _showOverlay ? 1.0 : 0.0, + duration: GalleryAnimations.overlayFade, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + widget.photos.length, + (index) => AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: index == _currentIndex ? 24 : 8, + height: 8, + margin: PhotoViewerStyles.pageDotSpacing, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: index == _currentIndex + ? PhotoViewerStyles.activeDotColor + : PhotoViewerStyles.inactiveDotColor, + ), + ), + ), + ), + ), + ), + + // When overlay hidden: always-visible back button so user can always exit + if (!_showOverlay) + Positioned( + left: 0, + top: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.only(left: 8, top: 8), + child: Material( + color: Colors.black.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(28), + child: InkWell( + onTap: () => Navigator.of(context).pop(), + borderRadius: BorderRadius.circular(28), + child: Container( + width: 56, + height: 56, + alignment: Alignment.center, + child: const Icon( + Icons.close, + color: Colors.white, + size: 28, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + void _playVideo(PhotoItem video) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + video: video, + photos: widget.photos, + initialIndex: _currentIndex, + isFromTrash: widget.isFromTrash, + ), + ), + ); + } + + Future _shareImage() async { + final imageData = _fullImageCache[_currentIndex] ?? + _highImageCache[_currentIndex] ?? + _thumbnailCache[_currentIndex]; + if (imageData == null) return; + + try { + // Save image to temporary file + final tempDir = await getTemporaryDirectory(); + final fileName = widget.photos[_currentIndex].path.split('/').last; + final file = File('${tempDir.path}/$fileName'); + await file.writeAsBytes(imageData); + + // Create ShareParams with the image file + final params = ShareParams( + text: 'Shared from Sync Client', + files: [XFile(file.path)], + ); + + // Share using SharePlus.instance + final result = await SharePlus.instance.share(params); + + if (result.status == ShareResultStatus.success) { + debugPrint('Share successful'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Image shared successfully!'), + backgroundColor: Colors.green, + ), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error sharing image: $e')), + ); + } + } + } + + Future _downloadImage() async { + final photo = widget.photos[_currentIndex]; + if (photo.isVideo) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Download is for photos only')), + ); + } + return; + } + + // Prefer full quality; load from server if not cached + Uint8List? bytes = _fullImageCache[_currentIndex]; + if (bytes == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Loading full quality…')), + ); + } + final deviceService = context.read(); + final user = deviceService.state.currentUser?.email; + final deviceId = photo.deviceIdOverride ?? deviceService.state.id; + if (user == null || deviceId.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Not signed in')), + ); + } + return; + } + try { + bytes = await apiGetImageBytes( + user, + deviceId, + photo.fullPath, + quality: 'full', + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to load: $e')), + ); + } + return; + } + if (bytes == null || !mounted) return; + } + + final fileName = photo.path.split('/').last; + try { + if (kIsWeb) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Download is not supported on web')), + ); + } + return; + } + if (Platform.isAndroid || Platform.isIOS) { + final result = await SaverGallery.saveImage( + bytes, + fileName: fileName, + skipIfExists: false, + ); + if (!mounted) return; + if (result.isSuccess) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Saved to gallery'), + backgroundColor: Colors.green, + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to save: ${result.errorMessage}')), + ); + } + } else { + // Desktop: save to Pictures + String picturesPath; + if (Platform.isWindows) { + final userProfile = Platform.environment['USERPROFILE'] ?? ''; + picturesPath = '$userProfile\\Pictures'; + } else { + final home = Platform.environment['HOME'] ?? ''; + picturesPath = '$home/Pictures'; + } + final dir = Directory(picturesPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + final file = File(p.join(picturesPath, fileName)); + await file.writeAsBytes(bytes); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Saved to $picturesPath'), + backgroundColor: Colors.green, + ), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error saving: $e')), + ); + } + } + } + + Future _moveToTrash() async { + final photo = widget.photos[_currentIndex]; + final deviceService = context.read(); + final user = deviceService.state.currentUser?.email; + final deviceId = photo.deviceIdOverride ?? deviceService.state.id; + if (user == null || user.isEmpty || deviceId.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Not signed in')), + ); + } + return; + } + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Move to Trash'), + content: Text( + 'Move ${photo.path.split('/').last} to Trash? You can restore it later from Trash.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Move to Trash'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + try { + final ok = await apiMoveToTrash(user, deviceId, [photo.path]); + if (!mounted) return; + if (ok) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Moved to Trash')), + ); + context.read().requestTrashRefresh(); + Navigator.of(context).pop(photo.path); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to move to Trash')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } + } + + void _showImageInfo() { + final photo = widget.photos[_currentIndex]; + final imageData = _fullImageCache[_currentIndex] ?? + _highImageCache[_currentIndex] ?? + _thumbnailCache[_currentIndex]; + final isFullQuality = _fullImageCache.containsKey(_currentIndex); + + showModalBottomSheet( + context: context, + backgroundColor: PhotoViewerStyles.infoSheetDecoration(context).color, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => Container( + padding: PhotoViewerStyles.infoSheetPadding, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + photo.isVideo ? 'Video Information' : 'Image Information', + style: PhotoViewerStyles.infoSheetTitleStyle, + ), + if (isFullQuality && !photo.isVideo) + Container( + padding: PhotoViewerStyles.qualityIndicatorPadding, + decoration: PhotoViewerStyles.qualityIndicatorDecoration(), + child: const Text( + 'Full Quality', + style: PhotoViewerStyles.qualityIndicatorTextStyle, + ), + ), + ], + ), + const SizedBox(height: 16), + _InfoRow('Name', photo.path.split('/').last), + _InfoRow('Type', photo.isVideo ? 'Video' : 'Photo'), + _InfoRow('Folder', photo.folder), + if (photo.date != null) + _InfoRow( + 'Date', DateFormat('MMM d, yyyy h:mm a').format(photo.date!)), + if (imageData != null && !photo.isVideo) + _InfoRow('Size', + '${(imageData.length / 1024 / 1024).toStringAsFixed(2)} MB'), + _InfoRow('Path', photo.path), + if (!photo.isVideo) + _InfoRow( + 'Quality', isFullQuality ? 'Full Resolution' : 'Thumbnail'), + const SizedBox(height: 16), + ], + ), + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + + const _InfoRow(this.label, this.value); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: PhotoViewerStyles.infoRowLabelWidth, + child: Text( + label, + style: PhotoViewerStyles.infoRowLabelStyle(context), + ), + ), + Expanded( + child: Text(value), + ), + ], + ), + ); + } +} diff --git a/lib/screens/components/video_player_screen.dart b/lib/screens/components/video_player_screen.dart new file mode 100644 index 0000000..cc073cd --- /dev/null +++ b/lib/screens/components/video_player_screen.dart @@ -0,0 +1,411 @@ +// lib/screens/components/video_player_screen.dart +/* + Copyright 2024 Take Control - Software & Infrastructure + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:sync_client/config/config.dart'; +import 'package:sync_client/config/theme/app_theme.dart'; +import 'package:sync_client/core/core.dart'; +import 'package:sync_client/models/photo_item.dart'; +import 'package:sync_client/screens/components/photo_viewer_screen.dart'; +import 'package:sync_client/services/services.dart'; + +class VideoPlayerScreen extends StatefulWidget { + const VideoPlayerScreen({ + super.key, + required this.video, + this.photos, + this.initialIndex, + this.isFromTrash = false, + }); + + final PhotoItem video; + + /// When set, app bar shows prev/next and move-to-trash (unless isFromTrash). + final List? photos; + final int? initialIndex; + + /// When true (e.g. opened from Trash), hide move-to-trash button. + final bool isFromTrash; + + @override + State createState() => _VideoPlayerScreenState(); +} + +class _VideoPlayerScreenState extends State { + Player? _player; + VideoController? _videoController; + String? _errorMessage; + bool _ready = false; + Uint8List? _posterBytes; + + @override + void initState() { + super.initState(); + _loadPoster(); + _initPlayer(); + } + + Future _loadPoster() async { + final fullPath = widget.video.fullPath; + final cached = await EnhancedCacheService.getCachedThumbnail(fullPath); + if (cached != null && mounted) { + setState(() => _posterBytes = cached); + return; + } + final deviceService = context.read(); + final user = deviceService.state.currentUser?.email; + final deviceId = + widget.video.deviceIdOverride ?? deviceService.state.id; + if (user == null || deviceId.isEmpty) return; + try { + final data = + await apiGetImageBytes(user, deviceId, fullPath, fullQuality: false); + if (data != null && mounted) { + setState(() => _posterBytes = data); + EnhancedCacheService.cacheThumbnail(fullPath, data); + } + } catch (_) {} + } + + Future _initPlayer() async { + final deviceService = context.read(); + final serverUrl = deviceService.state.serverUrl; + final user = deviceService.state.currentUser?.email; + final deviceId = + widget.video.deviceIdOverride ?? deviceService.state.id; + + if (serverUrl == null || + serverUrl.isEmpty || + user == null || + user.isEmpty || + deviceId.isEmpty) { + if (mounted) { + setState(() { + _errorMessage = 'Server or account not configured.'; + }); + } + return; + } + + final streamUrl = getStreamUrl( + serverUrl, + user, + deviceId, + widget.video.fullPath, + ); + + try { + _player = Player(); + _videoController = VideoController(_player!); + await _player!.open(Media(streamUrl.toString())); + await _player!.play(); + if (mounted) { + setState(() => _ready = true); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = 'Failed to load video: $e'; + }); + } + } + } + + @override + void dispose() { + _player?.dispose(); + super.dispose(); + } + + bool get _hasGallery => + widget.photos != null && + widget.photos!.isNotEmpty && + widget.initialIndex != null && + widget.initialIndex! >= 0 && + widget.initialIndex! < widget.photos!.length; + + bool get _canGoPrevious => _hasGallery && widget.initialIndex! > 0; + + bool get _canGoNext => + _hasGallery && widget.initialIndex! < widget.photos!.length - 1; + + void _goToPrevious() { + if (!_canGoPrevious) return; + final idx = widget.initialIndex! - 1; + final item = widget.photos![idx]; + Navigator.of(context).pop(); + if (item.isVideo) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + video: item, + photos: widget.photos, + initialIndex: idx, + isFromTrash: widget.isFromTrash, + ), + ), + ); + } else { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => PhotoViewerScreen( + photos: widget.photos!, + initialIndex: idx, + isFromTrash: widget.isFromTrash, + ), + ), + ); + } + } + + void _goToNext() { + if (!_canGoNext) return; + final idx = widget.initialIndex! + 1; + final item = widget.photos![idx]; + Navigator.of(context).pop(); + if (item.isVideo) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + video: item, + photos: widget.photos, + initialIndex: idx, + isFromTrash: widget.isFromTrash, + ), + ), + ); + } else { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => PhotoViewerScreen( + photos: widget.photos!, + initialIndex: idx, + isFromTrash: widget.isFromTrash, + ), + ), + ); + } + } + + Future _moveToTrash() async { + final deviceService = context.read(); + final user = deviceService.state.currentUser?.email; + final deviceId = + widget.video.deviceIdOverride ?? deviceService.state.id; + if (user == null || user.isEmpty || deviceId.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Not signed in')), + ); + } + return; + } + try { + final ok = await apiMoveToTrash(user, deviceId, [widget.video.path]); + if (!mounted) return; + if (ok) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Moved to Trash')), + ); + context.read().requestTrashRefresh(); + Navigator.of(context).pop(widget.video.path); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to move to Trash')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black87, + foregroundColor: Colors.white, + title: Text( + widget.video.path.split('/').last, + style: const TextStyle(color: Colors.white, fontSize: 16), + overflow: TextOverflow.ellipsis, + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + ), + actions: [ + if (!widget.isFromTrash) + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: _moveToTrash, + tooltip: 'Move to Trash', + ), + ], + ), + body: Stack( + children: [ + _buildBody(), + // Prev/next arrows on both sides of video (not in app bar) + if (_canGoPrevious) + Positioned( + left: 20, + top: 0, + bottom: 0, + child: Center( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: _goToPrevious, + borderRadius: BorderRadius.circular(28), + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withOpacity(0.2), + width: 1, + ), + ), + child: const Icon( + Icons.arrow_back_ios_new, + color: Colors.white, + size: 24, + ), + ), + ), + ), + ), + ), + if (_canGoNext) + Positioned( + right: 20, + top: 0, + bottom: 0, + child: Center( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: _goToNext, + borderRadius: BorderRadius.circular(28), + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withOpacity(0.2), + width: 1, + ), + ), + child: const Icon( + Icons.arrow_forward_ios, + color: Colors.white, + size: 24, + ), + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildBody() { + if (_errorMessage != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: GalleryStyles.errorIconColor(context), + ), + const SizedBox(height: 16), + Text( + _errorMessage!, + textAlign: TextAlign.center, + style: TextStyle( + color: GalleryStyles.errorIconColor(context), + fontSize: 16, + ), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + ); + } + + if (_videoController == null || !_ready) { + return Stack( + fit: StackFit.expand, + children: [ + if (_posterBytes != null) + Positioned.fill( + child: Image.memory( + _posterBytes!, + fit: BoxFit.contain, + ), + ), + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(color: Colors.white), + const SizedBox(height: 16), + Text( + 'Loading video...', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 14, + ), + ), + ], + ), + ), + ], + ); + } + + return Center( + child: Video(controller: _videoController!), + ); + } +} diff --git a/lib/screens/components/widgets.dart b/lib/screens/components/widgets.dart index f7c7145..f58b258 100644 --- a/lib/screens/components/widgets.dart +++ b/lib/screens/components/widgets.dart @@ -12,36 +12,71 @@ Widget formLayout(BuildContext context, Widget? contentWidget) { ))); } -Widget loginField(TextEditingController controller, +Widget loginField(BuildContext context, TextEditingController controller, {String? labelText, String? hintText, bool? obscure}) { return Padding( - padding: const EdgeInsets.all(15), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: TextField( - obscureText: obscure ?? false, - controller: controller, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: labelText, - hintText: hintText)), + obscureText: obscure ?? false, + controller: controller, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + decoration: InputDecoration( + labelText: labelText, + hintText: hintText, + floatingLabelBehavior: FloatingLabelBehavior.auto, + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide( + color: Colors.grey.shade300, + width: 1, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + ), + ), ); } Widget loginButton(BuildContext context, {void Function()? onPressed, Widget? child}) { - return Container( - height: 50, - width: 250, - margin: const EdgeInsets.symmetric(vertical: 25), - child: ElevatedButton( - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all(objectColor), - textStyle: WidgetStateProperty.all( - const TextStyle(color: Colors.white, fontSize: 20)), - shape: WidgetStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20.0)))), - onPressed: onPressed, - child: child, + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24), + child: SizedBox( + width: double.infinity, + height: 56, + child: FilledButton( + onPressed: onPressed, + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + elevation: 2, + shadowColor: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + textStyle: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + child: child, + ), ), ); } diff --git a/lib/screens/deleting_enabled_screen.dart b/lib/screens/deleting_enabled_screen.dart deleted file mode 100644 index 19cde80..0000000 --- a/lib/screens/deleting_enabled_screen.dart +++ /dev/null @@ -1,136 +0,0 @@ -/* - Copyright 2023 Take Control - Software & Infrastructure - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:sync_client/config/config.dart'; -import 'package:sync_client/screens/components/components.dart'; -import 'package:sync_client/services/services.dart'; -import 'package:sync_client/storage/storage.dart'; - -enum DeletingEnabledMenuOption { enableDeleting } - -class DeletingEnabledScreen extends StatelessWidget { - const DeletingEnabledScreen({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: MainAppBar.appBar(context), - body: const _DeletingEnabledScreenView()); - } -} - -class _DeletingEnabledScreenView extends StatelessWidget { - const _DeletingEnabledScreenView(); - - @override - Widget build(BuildContext context) { - final deviceService = context.read(); - return Container( - margin: const EdgeInsets.only( - left: 10.0, right: 10.0, top: 30.0, bottom: 30.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - const Text( - 'Delete synced files from this device?', - ), - ListTile( - leading: const Icon(Icons.clear), - title: reactiveBuilder( - buildWhen: (previous, current) => - previous.deleteLocalFilesEnabled != - current.deleteLocalFilesEnabled, - child: (context, state) { - return Text( - 'Deleting: ${state.deleteLocalFilesEnabled ?? false ? "ON" : "OFF"}'); - }), - trailing: SizedBox( - width: 25, - child: PopupMenuButton( - onSelected: (menuItem) => - handleMenuClick(context, deviceService, menuItem), - itemBuilder: (context) => [ - PopupMenuItem( - value: DeletingEnabledMenuOption.enableDeleting, - child: ListTile( - leading: const Icon(Icons.reset_tv), - title: Text( - 'Switched: ${deviceService.state.deleteLocalFilesEnabled ?? false ? "OFF" : "ON"}')), - ), - ], - ), - ), - shape: const Border(bottom: BorderSide())) - ], - ), - ); - } - - void handleMenuClick(BuildContext context, DeviceServicesCubit deviceService, - DeletingEnabledMenuOption menuItem) async { - if (menuItem == DeletingEnabledMenuOption.enableDeleting) { - if ((deviceService.state.deleteLocalFilesEnabled ?? false)) { - await deviceService.edit((state) { - state.deleteLocalFilesEnabled = - !(state.deleteLocalFilesEnabled ?? false); - state.lastErrorMessage = null; - }); - } else { - showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: const Text('Local files deleting: ON'), - content: const Wrap( - spacing: 20, - runSpacing: 20, - children: [ - Text( - 'WARNING: Switching this option to ON will cause DELETING synced files from this device.', - textAlign: TextAlign.center, - ), - Text( - 'The files are deleted only if they are successfully send to the server.', - textAlign: TextAlign.center, - ), - Text( - 'If you confirm the files will be deleted from the device after the next sync operation.', - textAlign: TextAlign.center, - ), - ]), - actions: [ - okButton(context, "Confirm", onPressed: () async { - final mapPermissions = - await Permission.manageExternalStorage.request(); - if (mapPermissions.isPermanentlyDenied) { - await openAppSettings(); - } - await deviceService.edit((state) { - state.deleteLocalFilesEnabled = - !(state.deleteLocalFilesEnabled ?? false); - state.lastErrorMessage = null; - }); - if (!context.mounted) return; - Navigator.of(context).pop(); - }), - cancelButton(context) - ], - )); - } - } - } -} diff --git a/lib/screens/folders_list_screen.dart b/lib/screens/folders_list_screen.dart index b3047d4..4cee06f 100644 --- a/lib/screens/folders_list_screen.dart +++ b/lib/screens/folders_list_screen.dart @@ -13,6 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -28,7 +29,7 @@ class FoldersListScreen extends StatelessWidget { Widget build(BuildContext context) { final deviceService = context.read(); return Scaffold( - appBar: MainAppBar.appBar(context), + appBar: MainAppBar.appBarWithBack(context, title: "Folders to sync"), body: const _FoldersListScreenView(), floatingActionButton: FloatingActionButton( onPressed: () => selectSourceDir(context, deviceService), @@ -38,16 +39,253 @@ class FoldersListScreen extends StatelessWidget { ); } - void selectSourceDir( + Future selectSourceDir( BuildContext context, DeviceServicesCubit deviceService) async { - String? selectedDirectory = await FilePicker.platform.getDirectoryPath(); + try { + // Show loading while picker opens + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return const Center( + child: Card( + child: Padding( + padding: FolderListStyles.loadingDialogPadding, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Opening folder picker...'), + ], + ), + ), + ), + ); + }, + ); - await deviceService.edit((state) { + String? selectedDirectory; + + try { + // This works on all platforms with proper configuration: + // - Android: Uses Storage Access Framework (no manifest permissions needed) + // - iOS: Uses document picker (add NSDocumentsFolderUsageDescription to Info.plist) + // - macOS: Uses native dialog (needs entitlements as configured) + // - Windows/Linux: Works out of the box + selectedDirectory = await FilePicker.platform.getDirectoryPath( + dialogTitle: 'Select folder to sync', + lockParentWindow: true, + ); + } catch (e) { + // Close loading dialog + if (context.mounted) Navigator.of(context).pop(); + + debugPrint('FilePicker error: $e'); + + // Show error with helpful message + if (context.mounted) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Unable to open folder picker'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Error: ${e.toString()}'), + const SizedBox(height: 16), + if (Platform.isMacOS && e.toString().contains('ENTITLEMENT')) + const Text( + 'This appears to be a development configuration issue. ' + 'Please ensure the macOS entitlements are properly set.', + style: + TextStyle(fontSize: 12, fontStyle: FontStyle.italic), + ) + else + const Text( + 'Would you like to enter a folder path manually instead?', + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + if (!e.toString().contains('ENTITLEMENT')) + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + final manualPath = await _showManualPathDialog(context); + if (manualPath != null) { + selectedDirectory = manualPath; + } + }, + child: const Text('Enter Manually'), + ), + ], + ), + ); + } + + // Don't process further if there was an error + if (selectedDirectory == null) return; + } + + // Close loading dialog + if (context.mounted && Navigator.canPop(context)) { + Navigator.of(context).pop(); + } + + // Process the selected directory if (selectedDirectory != null) { - state.mediaDirectories.add(selectedDirectory); + // Verify the directory exists and is accessible + final directory = Directory(selectedDirectory!); + bool canAccess = false; + + try { + canAccess = await directory.exists(); + } catch (e) { + canAccess = false; + debugPrint('Cannot access directory: $e'); + } + + if (!canAccess && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Cannot access folder: ${directory.path}'), + backgroundColor: Colors.orange, + action: SnackBarAction( + label: 'Try Again', + onPressed: () => selectSourceDir(context, deviceService), + ), + ), + ); + return; + } + + // Add the directory to the list + await deviceService.edit((state) { + if (!state.mediaDirectories.contains(selectedDirectory)) { + state.mediaDirectories.add(selectedDirectory!); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackbarStyles.successSnackbar( + message: + 'Added: ${selectedDirectory!.split(Platform.pathSeparator).last}', + ), + ); + } + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackbarStyles.warningSnackbar( + message: 'This folder is already in your sync list', + ), + ); + } + } + state.lastErrorMessage = null; + }); } - state.lastErrorMessage = null; - }); + } catch (e) { + // Close any open dialogs + if (context.mounted && Navigator.canPop(context)) { + Navigator.of(context).pop(); + } + + debugPrint('Error in selectSourceDir: $e'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackbarStyles.errorSnackbar( + message: 'Unexpected error: ${e.toString()}', + ), + ); + } + } + } + + Future _showManualPathDialog(BuildContext context) async { + final controller = TextEditingController(); + String hintText; + String helperText; + + if (Platform.isAndroid) { + hintText = '/storage/emulated/0/DCIM'; + helperText = 'Example: /storage/emulated/0/Pictures'; + } else if (Platform.isIOS) { + hintText = 'Documents/Photos'; + helperText = 'Relative to app container'; + } else if (Platform.isMacOS) { + final home = Platform.environment['HOME'] ?? + '/Users/${Platform.environment['USER']}'; + hintText = '$home/Pictures'; + helperText = 'Example: $home/Documents'; + } else if (Platform.isWindows) { + final username = Platform.environment['USERNAME'] ?? 'User'; + hintText = 'C:\\Users\\$username\\Pictures'; + helperText = 'Example: C:\\Users\\$username\\Documents'; + } else { + final home = Platform.environment['HOME'] ?? '/home/user'; + hintText = '$home/Pictures'; + helperText = 'Example: $home/Documents'; + } + + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Enter Folder Path'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Enter the full path to the folder you want to sync:'), + const SizedBox(height: 16), + TextField( + controller: controller, + decoration: InputDecoration( + hintText: hintText, + helperText: helperText, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.folder), + ), + autofocus: true, + onSubmitted: (value) { + if (value.trim().isNotEmpty) { + Navigator.of(context).pop(value.trim()); + } + }, + ), + const SizedBox(height: 8), + Text( + 'Make sure the path exists and is accessible', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontStyle: FontStyle.italic, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + final path = controller.text.trim(); + if (path.isNotEmpty) { + Navigator.of(context).pop(path); + } + }, + child: const Text('Add'), + ), + ], + ), + ); } } @@ -57,29 +295,279 @@ class _FoldersListScreenView extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - margin: const EdgeInsets.only( - left: 10.0, right: 10.0, top: 30.0, bottom: 30.0), + margin: FolderListStyles.containerMargin, child: Column( - mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Selected directories to sync:', + Text( + 'Folders to Sync', + style: FolderListStyles.titleTextStyle(context), ), - reactiveBuilder( + const SizedBox(height: 8), + Text( + 'These folders will be synchronized with your server', + style: FolderListStyles.subtitleTextStyle(context), + ), + const SizedBox(height: 20), + Expanded( + child: reactiveBuilder( buildWhen: (previous, current) => previous.mediaDirectories.length != current.mediaDirectories.length, - child: (context, state) => ListView.builder( - shrinkWrap: true, - itemCount: state.mediaDirectories.length, - itemBuilder: (context, index) => state.mediaDirectories - .elementAt(index) - .isNotEmpty - ? FolderItem(state.mediaDirectories.elementAt(index)) - : Container(), - )) + child: (context, state) { + if (state.mediaDirectories.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.folder_open, + size: FolderListStyles.emptyStateIconSize, + color: FolderListStyles.emptyStateIconColor, + ), + const SizedBox( + height: FolderListStyles.emptyStateSpacing), + Text( + 'No folders selected', + style: FolderListStyles.emptyStateTitleStyle(context), + ), + const SizedBox(height: 8), + Text( + 'Tap the + button to add a folder', + style: + FolderListStyles.emptyStateSubtitleStyle(context), + ), + const SizedBox(height: 32), + // Platform-specific help + Container( + padding: const EdgeInsets.all(16), + decoration: + FolderListStyles.infoContainerDecoration(context), + child: Column( + children: [ + Icon( + Icons.info_outline, + color: Theme.of(context).primaryColor, + ), + const SizedBox(height: 8), + Text( + FolderListStyles.getPlatformHelpText(), + style: TextStyle( + fontSize: 13, + color: Theme.of(context).primaryColor, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ], + ), + ); + } + + return ListView.builder( + itemCount: state.mediaDirectories.length, + itemBuilder: (context, index) { + final directory = state.mediaDirectories.toList()[index]; + if (directory.isEmpty) return const SizedBox.shrink(); + + return Dismissible( + key: Key(directory), + background: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Icon( + Icons.delete, + color: Colors.white, + size: 28, + ), + ), + direction: DismissDirection.endToStart, + confirmDismiss: (direction) async { + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove Folder?'), + content: Text( + 'Stop syncing "${directory.split(Platform.pathSeparator).last}"?', + ), + actions: [ + TextButton( + onPressed: () => + Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => + Navigator.of(context).pop(true), + style: TextButton.styleFrom( + foregroundColor: Colors.red, + ), + child: const Text('Remove'), + ), + ], + ), + ); + }, + onDismissed: (direction) { + final folderName = + directory.split(Platform.pathSeparator).last; + context.read().edit((state) { + state.mediaDirectories.remove(directory); + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Removed: $folderName'), + action: SnackBarAction( + label: 'Undo', + onPressed: () { + context + .read() + .edit((state) { + state.mediaDirectories.add(directory); + }); + }, + ), + ), + ); + }, + child: Card( + margin: FolderListStyles.cardMargin, + child: ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: + FolderListStyles.folderIconDecoration(context), + child: Icon( + Icons.folder, + color: Theme.of(context).primaryColor, + ), + ), + title: Text( + directory.split(Platform.pathSeparator).last, + style: FolderListStyles.folderNameStyle(), + ), + subtitle: Text( + directory, + style: FolderListStyles.folderPathStyle(), + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + icon: const Icon(Icons.more_vert), + onPressed: () => + _showFolderOptions(context, directory), + ), + ), + ), + ); + }, + ); + }, + ), + ), + if (context + .watch() + .state + .mediaDirectories + .isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Swipe left to remove • Tap folder for options', + style: FolderListStyles.helperTextStyle(context), + textAlign: TextAlign.center, + ), + ), ], ), ); } + + void _showFolderOptions(BuildContext context, String directory) { + showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.folder_open), + title: const Text('Open in File Manager'), + onTap: () { + Navigator.pop(context); + // Could implement opening folder in system file manager + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Opening folder in file manager...'), + ), + ); + }, + ), + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('Folder Info'), + onTap: () async { + Navigator.pop(context); + final dir = Directory(directory); + int fileCount = 0; + if (await dir.exists()) { + try { + fileCount = await dir + .list(recursive: true) + .where((entity) => entity is File) + .length; + } catch (e) { + debugPrint('Error counting files: $e'); + } + } + + if (context.mounted) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Folder Information'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Path: $directory'), + const SizedBox(height: 8), + Text('Files: $fileCount'), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ); + } + }, + ), + ListTile( + leading: const Icon(Icons.delete, color: Colors.red), + title: const Text('Remove from Sync', + style: TextStyle(color: Colors.red)), + onTap: () { + Navigator.pop(context); + // Trigger the dismiss action + context.read().edit((state) { + state.mediaDirectories.remove(directory); + }); + }, + ), + ], + ), + ), + ); + } } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 44811f0..a423ed9 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -16,15 +16,21 @@ See the License for the specific language governing permissions and limitations under the License. */ import 'dart:typed_data'; - +import 'dart:async'; +import 'package:intl/intl.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:sync_client/config/config.dart'; +import 'package:sync_client/config/theme/app_theme.dart'; import 'package:sync_client/core/core.dart'; import 'package:sync_client/screens/components/components.dart'; +import 'package:sync_client/screens/components/gallery_app_bar.dart'; import 'package:sync_client/services/services.dart'; import 'package:sync_client/storage/storage.dart'; +import 'package:sync_client/models/photo_item.dart'; + +// Photo model and cache service are now in separate files class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -34,29 +40,816 @@ class HomeScreen extends StatefulWidget { } class HomeScreenState extends State { + // State management + List _folders = []; + Map> _photosCache = {}; + Map> _photosByMonth = {}; + bool _isLoading = false; + bool _isRefreshing = false; + bool _hasError = false; + String? _errorMessage; + Timer? _timeoutTimer; + + // UI State + bool _isGridView = true; + int _crossAxisCount = 3; + final ScrollController _scrollController = ScrollController(); + bool _selectionMode = false; + final Set _selectedPaths = {}; + final Set _collapsedMonths = {}; + bool _wasRouteCurrent = false; + bool _leftHome = false; + + // Loading configuration + static const Duration _timeout = Duration(seconds: 15); + static const Duration _initialDelay = Duration(milliseconds: 100); + @override void initState() { super.initState(); + _initializeLoading(); + _setupScrollController(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final isCurrent = ModalRoute.of(context)?.isCurrent ?? false; + if (_wasRouteCurrent && !isCurrent) { + _leftHome = true; + } + if (isCurrent && _leftHome) { + _leftHome = false; + if (_folders.isNotEmpty) { + _refreshInBackground(); + } + } + _wasRouteCurrent = isCurrent; } @override void dispose() { + _timeoutTimer?.cancel(); + _scrollController.dispose(); super.dispose(); } + void _setupScrollController() { + _scrollController.addListener(() { + // Hide/show FAB based on scroll position + if (_scrollController.position.pixels > 200) { + // Could trigger state change here if needed + } + }); + } + + Future _initializeLoading() async { + // Always load folder/file list from server (fast); thumbnails are cached on device + Future.delayed(_initialDelay, () { + if (mounted) { + _loadFolders(); + } + }); + } + + void _groupPhotosByMonth() { + _photosByMonth.clear(); + + for (final photos in _photosCache.values) { + for (final photo in photos) { + final month = photo.month ?? 'Recent'; + _photosByMonth[month] ??= []; + _photosByMonth[month]!.add(photo); + } + } + + // Sort photos within each month by date + for (final photos in _photosByMonth.values) { + photos.sort((a, b) => + (b.date ?? DateTime.now()).compareTo(a.date ?? DateTime.now())); + } + } + + Future _loadFolders({bool isRetry = false}) async { + if (_isLoading) return; + + final deviceService = context.read(); + + if (mounted) { + setState(() { + _isLoading = true; + _hasError = false; + _errorMessage = null; + }); + } + + // Set timeout + _timeoutTimer?.cancel(); + _timeoutTimer = Timer(_timeout, () { + if (mounted && _isLoading) { + setState(() { + _hasError = true; + _errorMessage = + 'Loading is taking longer than expected. Please check your connection.'; + _isLoading = false; + }); + } + }); + + try { + // Load folders with timeout + final folders = await getAllFolders(deviceService).timeout( + _timeout, + onTimeout: () => throw TimeoutException('Loading folders timed out'), + ); + + if (mounted) { + setState(() { + _folders = folders; + _isLoading = false; + }); + + // Load files for each folder from server + _loadFilesProgressively(folders, deviceService); + } + } catch (e) { + if (mounted) { + final deviceService = context.read(); + final noFolderYet = e is GetFoldersError && + !deviceService.state.showAllDevices && + (deviceService.state.id.isNotEmpty); + setState(() { + _hasError = true; + _errorMessage = noFolderYet + ? 'No photos on server yet. Run sync to upload photos from this device.' + : (e is CustomError + ? e.message + : 'Failed to load folders: ${e.toString()}'); + _isLoading = false; + }); + } + } finally { + _timeoutTimer?.cancel(); + } + } + + Future _loadFilesProgressively( + List folders, DeviceServicesCubit deviceService) async { + for (final folder in folders) { + if (!mounted) break; + + try { + final files = await getAllFiles(deviceService, folder); + if (mounted) { + final showAll = deviceService.state.showAllDevices; + // Filter out .converted.jpg and create PhotoItems; when showAllDevices, file is "deviceId/path" + final photos = files + .where((f) => !f.toLowerCase().contains('.converted.jpg')) + .map((f) { + if (showAll) { + final parsed = PhotoItem.parseDeviceIdPath(f); + final devId = parsed[0]; + final path = parsed[1]!; + final pathFolder = path.contains('/') + ? path.substring(0, path.lastIndexOf('/')) + : folder; + return PhotoItem.fromPath(path, pathFolder, + deviceIdOverride: devId); + } + return PhotoItem.fromPath(f, folder); + }) + .toList(); + + setState(() { + _photosCache[folder] = photos; + }); + _groupPhotosByMonth(); + } + } catch (e) { + debugPrint('Error loading files for $folder: $e'); + } + } + } + + Future _refreshInBackground() async { + if (_isRefreshing) return; + + _isRefreshing = true; + final deviceService = context.read(); + + try { + final folders = await getAllFolders(deviceService); + + if (mounted && !_listEquals(_folders, folders)) { + setState(() { + _folders = folders; + }); + + _loadFilesProgressively(folders, deviceService); + } + } catch (e) { + debugPrint('Background refresh failed: $e'); + } finally { + _isRefreshing = false; + } + } + + bool _listEquals(List a, List b) { + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + + Future _handleRefresh() async { + setState(() { + _folders.clear(); + _photosCache.clear(); + _photosByMonth.clear(); + }); + await _loadFolders(isRetry: true); + } + @override Widget build(BuildContext context) { - return Scaffold( - appBar: MainAppBar.appBar(context), - body: itemsView(context), - floatingActionButton: FloatingActionButton( - onPressed: () => setState(() {}), - tooltip: 'Refresh', - child: const Icon(Icons.refresh), + return BlocListener( + listener: (context, state) { + if (state.homeNeedsRefresh && mounted) { + context.read().clearHomeRefresh(); + _refreshInBackground(); + } + }, + child: Scaffold( + appBar: _selectionMode + ? _buildSelectionAppBar() + : GalleryAppBar.appBar( + context, + crossAxisCount: _crossAxisCount, + isGridView: _isGridView, + onGridSizeChanged: (value) { + setState(() { + _crossAxisCount = value; + }); + }, + onViewModeToggle: () { + setState(() { + _isGridView = !_isGridView; + }); + }, + onMoveDocumentsToTrashPressed: _moveDocumentsToTrash, + onSelectPressed: () { + setState(() { + _selectionMode = true; + _selectedPaths.clear(); + }); + }, + ), + body: _buildBody(context), + floatingActionButton: + _selectionMode ? null : _buildFloatingActionButtons(), ), ); } + AppBar _buildSelectionAppBar() { + final n = _selectedPaths.length; + return AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + setState(() { + _selectionMode = false; + _selectedPaths.clear(); + }); + }, + tooltip: 'Cancel', + ), + title: Text(n == 0 ? 'Select items' : '$n selected'), + actions: [ + if (n > 0) + TextButton.icon( + icon: const Icon(Icons.delete_outline), + label: const Text('Move to Trash'), + onPressed: _moveSelectedToTrash, + ), + ], + ); + } + + static String _selectionKey(PhotoItem photo) { + if (photo.deviceIdOverride != null) { + return '${photo.deviceIdOverride}|${photo.path}'; + } + return photo.path; + } + + void _toggleSelection(PhotoItem photo) { + final key = _selectionKey(photo); + setState(() { + if (_selectedPaths.contains(key)) { + _selectedPaths.remove(key); + } else { + _selectedPaths.add(key); + } + }); + } + + /// Asks the server to run document detection on existing files (server uses its own logic, e.g. Python); thumbnails and metadata cleaned, then documents moved to Trash. + Future _moveDocumentsToTrash() async { + final deviceService = context.read(); + // Prefer userId when auth is used so the server scans the correct folder (UploadDirectory/userId/deviceId). + final user = deviceService.state.currentUser?.userId ?? + deviceService.state.currentUser?.email; + final deviceId = deviceService.state.id; + if (user == null || user.isEmpty || deviceId.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Not signed in')), + ); + } + return; + } + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Move documents to Trash'), + content: const Text( + 'The server will scan folders and detect documents (using its detection logic), clean their thumbnails and metadata, and move them to Trash. You can restore them later from Trash.', + textAlign: TextAlign.center, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Run on server'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Detecting documents on server…')), + ); + } + try { + final moved = await apiRunDocumentDetection(user, deviceId); + if (!mounted) return; + if (moved < 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text('Server did not accept or endpoint not implemented')), + ); + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(moved == 0 + ? 'No documents found on the server.' + : '$moved document(s) moved to Trash on server.')), + ); + context.read().requestHomeRefresh(); + context.read().requestTrashRefresh(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } + } + + Future _moveSelectedToTrash() async { + if (_selectedPaths.isEmpty) return; + final deviceService = context.read(); + final user = deviceService.state.currentUser?.email; + final currentDeviceId = deviceService.state.id; + if (user == null || user.isEmpty || currentDeviceId.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Not signed in')), + ); + } + return; + } + final keys = _selectedPaths.toList(); + final byDevice = >{}; + final pathsForCache = []; + for (final key in keys) { + final bar = key.indexOf('|'); + final deviceId = bar > 0 ? key.substring(0, bar) : currentDeviceId; + final path = bar > 0 ? key.substring(bar + 1) : key; + byDevice.putIfAbsent(deviceId, () => []).add(path); + pathsForCache.add(path); + } + try { + bool allOk = true; + for (final entry in byDevice.entries) { + final ok = await apiMoveToTrash(user, entry.key, entry.value); + if (!ok) allOk = false; + } + if (!mounted) return; + if (allOk) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${keys.length} item(s) moved to Trash')), + ); + setState(() { + _selectionMode = false; + _selectedPaths.clear(); + _removePhotosFromCache(pathsForCache); + }); + context.read().requestTrashRefresh(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to move to Trash')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } + } + + void _removePhotosFromCache(List paths) { + final pathSet = paths.toSet(); + for (final month in _photosByMonth.keys.toList()) { + final photos = _photosByMonth[month]!; + photos.removeWhere((p) => pathSet.contains(p.path)); + if (photos.isEmpty) { + _photosByMonth.remove(month); + } + } + for (final folder in _photosCache.keys.toList()) { + final photos = _photosCache[folder]!; + photos.removeWhere((p) => pathSet.contains(p.path)); + if (photos.isEmpty) { + _photosCache.remove(folder); + } else { + _photosCache[folder] = photos; + } + } + } + + Widget _buildFloatingActionButtons() { + return Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (_scrollController.hasClients && _scrollController.offset > 200) + FloatingActionButton.small( + heroTag: 'scrollTop', + onPressed: () { + _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOut, + ); + }, + child: const Icon(Icons.arrow_upward), + ), + const SizedBox(height: 10), + FloatingActionButton.small( + heroTag: 'refresh', + onPressed: _handleRefresh, + tooltip: 'Refresh', + child: _isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Icon(Icons.refresh), + ), + ], + ); + } + + Widget _buildBody(BuildContext context) { + final DeviceServicesCubit deviceService = + context.read(); + + if (!deviceService.isAuthenticated()) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + context.push("/login"); + } + }); + return const Center(child: CircularProgressIndicator()); + } + + if ((deviceService.state.serverUrl ?? "").isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(20), + child: Text( + "Server is not configured. Please configure the server URL.", + textAlign: TextAlign.center, + ), + ), + ); + } + + // Show error state + if (_hasError) { + return _buildErrorState(); + } + + // Show loading state only if no cached data + if (_isLoading && _photosByMonth.isEmpty) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text("Loading your photos..."), + ], + ), + ); + } + + // Show empty state + if (_photosByMonth.isEmpty && !_isLoading) { + return _buildEmptyState(); + } + + // Show gallery + return _buildGallery(); + } + + Widget _buildErrorState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 64, color: Colors.red), + const SizedBox(height: 16), + Text( + _errorMessage ?? 'An error occurred', + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => _loadFolders(isRetry: true), + child: const Text("Retry"), + ), + if (_photosByMonth.isNotEmpty) ...[ + const SizedBox(height: 8), + TextButton( + onPressed: () { + setState(() { + _hasError = false; + }); + }, + child: const Text("Show cached photos"), + ), + ], + ], + ), + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.photo_library_outlined, + size: GalleryStyles.emptyStateIconSize, + color: GalleryStyles.emptyStateIconColor(context), + ), + const SizedBox(height: 16), + Text( + "No photos found", + style: GalleryStyles.emptyStateTitleStyle(context), + ), + const SizedBox(height: 8), + Text( + "Sync your photos to see them here", + style: GalleryStyles.emptyStateSubtitleStyle(context), + ), + ], + ), + ); + } + + Widget _buildGallery() { + final sortedMonths = _photosByMonth.keys.toList(); + sortedMonths.sort((a, b) { + // Put "Recent" (unknown date) last; otherwise newest month first (descending by date taken) + if (a == 'Recent') return 1; + if (b == 'Recent') return -1; + try { + final dateA = DateFormat('MMMM yyyy').parse(a); + final dateB = DateFormat('MMMM yyyy').parse(b); + return dateB.compareTo(dateA); + } catch (e) { + return b.compareTo(a); + } + }); + + return RefreshIndicator( + onRefresh: _handleRefresh, + child: ListView.builder( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + itemCount: sortedMonths.length * 2 + + 1, // month headers + grids + loading indicator + itemBuilder: (context, index) { + // Loading indicator at top + if (index == 0 && (_isLoading || _isRefreshing)) { + return const LinearProgressIndicator(); + } + + // Adjust index for loading indicator + final adjustedIndex = + (_isLoading || _isRefreshing) ? index - 1 : index; + + // Calculate which month and whether it's header or grid + final monthIndex = adjustedIndex ~/ 2; + final isHeader = adjustedIndex % 2 == 0; + + if (monthIndex >= sortedMonths.length) { + return const SizedBox(height: 80); // Bottom padding + } + + final month = sortedMonths[monthIndex]; + final photos = _photosByMonth[month] ?? []; + + if (isHeader) { + // Month header (tappable to collapse/expand) + final isCollapsed = _collapsedMonths.contains(month); + return Material( + color: Theme.of(context).scaffoldBackgroundColor, + child: InkWell( + onTap: () { + setState(() { + if (isCollapsed) { + _collapsedMonths.remove(month); + } else { + _collapsedMonths.add(month); + } + }); + }, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon( + isCollapsed ? Icons.chevron_right : Icons.expand_more, + size: 28, + color: Theme.of(context).colorScheme.onSurface, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + month, + style: + Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + '${photos.length}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } else { + // Photos grid (hidden when month is collapsed) + if (_collapsedMonths.contains(month)) { + return const SizedBox.shrink(); + } + if (_isGridView) { + return Padding( + padding: GalleryStyles.galleryPadding, + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _crossAxisCount, + mainAxisSpacing: GalleryStyles.photoSpacing, + crossAxisSpacing: GalleryStyles.photoSpacing, + childAspectRatio: 1.0, + ), + itemCount: photos.length, + itemBuilder: (context, index) { + final photo = photos[index]; + return GalleryPhotoTile( + photo: photo, + onTap: _selectionMode + ? () => _toggleSelection(photo) + : () => _openPhotoViewer(context, photos, index), + isSelectionMode: _selectionMode, + isSelected: _selectedPaths.contains(_selectionKey(photo)), + ); + }, + ), + ); + } else { + // List view + return Column( + children: photos.map((photo) { + final index = photos.indexOf(photo); + return ListTile( + leading: SizedBox( + width: 60, + height: 60, + child: GalleryPhotoTile( + photo: photo, + onTap: _selectionMode + ? () => _toggleSelection(photo) + : () => _openPhotoViewer(context, photos, index), + isSelectionMode: _selectionMode, + isSelected: _selectedPaths.contains(_selectionKey(photo)), + ), + ), + title: Text(photo.path.split('/').last), + subtitle: Text(photo.folder), + onTap: _selectionMode + ? () => _toggleSelection(photo) + : () => _openPhotoViewer(context, photos, index), + ); + }).toList(), + ); + } + } + }, + ), + ); + } + + void _openPhotoViewer( + BuildContext context, List photos, int initialIndex) { + final photo = photos[initialIndex]; + + if (photo.isVideo) { + _openVideoPlayer(context, photo, photos, initialIndex); + } else { + _pushViewerAndRefreshIfTrashed( + context, + MaterialPageRoute( + builder: (context) => PhotoViewerScreen( + photos: photos, + initialIndex: initialIndex, + ), + ), + ); + } + } + + void _openVideoPlayer( + BuildContext context, + PhotoItem video, + List photos, + int initialIndex, + ) { + _pushViewerAndRefreshIfTrashed( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + video: video, + photos: photos, + initialIndex: initialIndex, + ), + ), + ); + } + + Future _pushViewerAndRefreshIfTrashed( + BuildContext context, MaterialPageRoute route) async { + final result = await Navigator.push(context, route); + if (!mounted) return; + if (result is String) { + setState(() => _removePhotosFromCache([result])); + } else if (result is List) { + setState(() => _removePhotosFromCache(result)); + } + } + List getChildrenFolders(List? folders) { final List allSubFolders = []; if (folders != null) { @@ -71,25 +864,57 @@ class HomeScreenState extends State { } Future> getAllFolders(DeviceServicesCubit deviceService) async { - if ((deviceService.state.serverUrl ?? "") == "") { + if ((deviceService.state.serverUrl ?? "").isEmpty) { return []; } + final deviceId = deviceService.state.showAllDevices + ? '' + : deviceService.state.id; List? folders = await apiGetFolders( - deviceService.state.currentUser!.email, deviceService.state.id); + deviceService.state.currentUser!.email, deviceId); final List allFolders = getChildrenFolders(folders); - return allFolders; + return allFolders + .where((f) => f != 'Trash' && !f.startsWith('Trash/')) + .toList(); } Future> getAllFiles( DeviceServicesCubit deviceService, String folder) async { - if ((deviceService.state.serverUrl ?? "") == "") { + final url = deviceService.state.serverUrl; + if (url == null || url.isEmpty) { return []; } + final deviceId = deviceService.state.showAllDevices + ? '' + : deviceService.state.id; List? files = await apiGetFiles( - deviceService.state.currentUser!.email, deviceService.state.id, folder); + deviceService.state.currentUser!.email, deviceId, folder); - return files; + // ignore: dead_null_aware_expression + return files ?? []; + } + + // ignore: unused_element + List _getAllPhotosInOrder() { + final sortedMonths = _photosByMonth.keys.toList(); + sortedMonths.sort((a, b) { + if (a == 'Recent') return 1; + if (b == 'Recent') return -1; + try { + final dateA = DateFormat('MMMM yyyy').parse(a); + final dateB = DateFormat('MMMM yyyy').parse(b); + return dateB.compareTo(dateA); + } catch (e) { + return b.compareTo(a); + } + }); + + final allPhotos = []; + for (final month in sortedMonths) { + allPhotos.addAll(_photosByMonth[month] ?? []); + } + return allPhotos; } Widget itemsView(BuildContext context) { diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index ea4cf50..3bcc6f6 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -14,12 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:sync_client/config/config.dart'; import 'package:sync_client/core/core.dart'; import 'package:sync_client/screens/components/components.dart'; import 'package:sync_client/services/services.dart'; +import 'package:sync_client/storage/storage.dart'; class LogInScreen extends StatefulWidget { const LogInScreen({super.key}); @@ -34,11 +36,17 @@ class LogInScreenState extends State { late TextEditingController _emailController; late TextEditingController _passwordController; + late TextEditingController _deviceIdController; + late TextEditingController _serverUrlController; @override void initState() { _emailController = TextEditingController()..addListener(clearError); _passwordController = TextEditingController()..addListener(clearError); + _deviceIdController = TextEditingController(text: currentDeviceSettings.id); + _serverUrlController = TextEditingController( + text: currentDeviceSettings.serverUrl ?? '') + ..addListener(clearError); super.initState(); } @@ -46,52 +54,137 @@ class LogInScreenState extends State { void dispose() { _emailController.dispose(); _passwordController.dispose(); + _deviceIdController.dispose(); + _serverUrlController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; return Scaffold( body: Container( - padding: const EdgeInsets.all(25), - margin: const EdgeInsets.only(top: 30), - child: Form( - child: SingleChildScrollView( - child: Column( - children: [ - Text(_isLogin ? 'Log In' : 'Sign Up', - style: const TextStyle(fontSize: 25)), - loginField(_emailController, - labelText: "Email", - hintText: "Enter valid email like abc@gmail.com"), - loginField(_passwordController, - labelText: "Password", - hintText: "Enter secure password", - obscure: true), - const Padding( - padding: EdgeInsets.fromLTRB(15, 0, 15, 0), - child: Text( - "Please login or register with a Mobi Sync user account.", - textAlign: TextAlign.center), - ), - loginButton(context, - child: Text(_isLogin ? "Log in" : "Sign up"), - onPressed: () => _logInOrSignUpUser(context, - _emailController.text, _passwordController.text)), - TextButton( - onPressed: () => setState(() => _isLogin = !_isLogin), - child: Text( - _isLogin - ? "New to Mobi Sync? Sign up" - : 'Already have an account? Log in.', - )), - Padding( - padding: const EdgeInsets.all(25), - child: Text(_errorMessage ?? "", - style: errorTextStyle(context), - textAlign: TextAlign.center), - ), - ], + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colorScheme.primary.withValues(alpha: 0.15), + colorScheme.surface, + ], + ), + ), + child: SafeArea( + child: Form( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Column( + children: [ + const SizedBox(height: 24), + Icon( + Icons.cloud_sync_rounded, + size: 64, + color: colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + _isLogin ? 'Welcome back' : 'Create account', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + letterSpacing: 0.3, + ), + ), + const SizedBox(height: 8), + Text( + _isLogin ? 'Log in to Mobi Sync' : 'Sign up for Mobi Sync', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + const SizedBox(height: 32), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 28), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest + .withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.08), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + children: [ + loginField(context, _serverUrlController, + labelText: "Server URL", + hintText: "e.g. http://192.168.1.10:8080"), + loginField(context, _emailController, + labelText: "Email", + hintText: "e.g. you@example.com"), + loginField(context, _passwordController, + labelText: "Password", + hintText: "Enter your password", + obscure: true), + if (!_isLogin) _buildDeviceIdField(context), + const SizedBox(height: 8), + Text( + "Login or register with your Mobi Sync account.", + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + loginButton(context, + child: Text(_isLogin ? "Log in" : "Sign up"), + onPressed: () => _logInOrSignUpUser( + context, + _emailController.text, + _passwordController.text)), + TextButton( + onPressed: () => + setState(() => _isLogin = !_isLogin), + child: Text( + _isLogin + ? "New to Mobi Sync? Sign up" + : 'Already have an account? Log in.', + style: TextStyle( + fontWeight: FontWeight.w600, + color: colorScheme.primary, + ), + )), + ], + ), + ), + if (_errorMessage != null && _errorMessage!.isNotEmpty) ...[ + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: + colorScheme.errorContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.error.withValues(alpha: 0.5), + ), + ), + child: Text( + _errorMessage!, + style: errorTextStyle(context), + textAlign: TextAlign.center, + ), + ), + ], + ], + ), ), ), ), @@ -99,6 +192,85 @@ class LogInScreenState extends State { ); } + Widget _buildDeviceIdField(BuildContext context) { + final deviceId = _deviceIdController.text; + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Device ID', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: _deviceIdController, + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w500), + decoration: InputDecoration( + hintText: 'Confirm or change the code', + floatingLabelBehavior: FloatingLabelBehavior.never, + filled: true, + fillColor: theme.colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide( + color: Colors.grey.shade300, + width: 1, + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + ), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: () { + if (deviceId.isNotEmpty) { + Clipboard.setData(ClipboardData(text: deviceId)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Code copied'), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ); + } + }, + icon: const Icon(Icons.copy_rounded), + tooltip: 'Copy', + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ), + ], + ), + ); + } + void clearError() { if (_errorMessage != null) { setState(() { @@ -112,15 +284,32 @@ class LogInScreenState extends State { BuildContext context, String email, String password) async { final deviceServices = context.read(); clearError(); + final serverUrl = _serverUrlController.text.trim(); + if (serverUrl.isEmpty) { + setState(() { + _errorMessage = "Server URL is required to log in."; + }); + return; + } try { + await deviceServices.updateServerUrl(serverUrl); if (_isLogin) { await deviceServices.logInUserEmailPassword(email, password); } else { - await deviceServices.registerUserEmailPassword(email, password); + // Sign up: try login first; if user exists we just log them in + try { + await deviceServices.logInUserEmailPassword(email, password); + } on InvalidCredentialError { + // User does not exist, register + await deviceServices.registerUserEmailPassword(email, password); + final newDeviceId = _deviceIdController.text.trim(); + if (newDeviceId.isNotEmpty) { + await deviceServices.updateDeviceId(newDeviceId); + } + } } - setState(() { - context.push("/"); - }); + if (!context.mounted) return; + context.go("/"); } catch (err) { setState(() { if (err is CustomError) { diff --git a/lib/screens/nickname_screen.dart b/lib/screens/nickname_screen.dart index 90b75cf..370dbe9 100644 --- a/lib/screens/nickname_screen.dart +++ b/lib/screens/nickname_screen.dart @@ -66,7 +66,7 @@ class NicknameScreenState extends State { style: const TextStyle(fontSize: 25)), _hasName! ? Container() - : loginField(_nicknameController, + : loginField(context, _nicknameController, labelText: "Nickname", hintText: "Enter letters or numbers without spaces"), const Padding( @@ -123,12 +123,12 @@ class NicknameScreenState extends State { throw RequiredNicknameError(); } await deviceServices.registerUserEmailPassword(email, password); - setState(() { - context.push("/"); - if ((deviceServices.state.serverUrl ?? "").trim() == "") { - context.push("/sync"); - } - }); + if (!context.mounted) return; + if ((deviceServices.state.serverUrl ?? "").trim().isEmpty) { + context.go("/sync"); + } else { + context.go("/"); + } } catch (err) { setState(() { if (err is CustomError) { diff --git a/lib/screens/screens.dart b/lib/screens/screens.dart index f4b07cd..98cb4d0 100644 --- a/lib/screens/screens.dart +++ b/lib/screens/screens.dart @@ -14,11 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ export 'home_screen.dart'; +export 'trash_screen.dart'; export 'servers_list_screen.dart'; export 'folders_list_screen.dart'; export 'login_screen.dart'; -export 'deleting_enabled_screen.dart'; -export 'components/components.dart'; export 'nickname_screen.dart'; export 'sync_screen.dart'; export 'account_screen.dart'; diff --git a/lib/screens/servers_list_screen.dart b/lib/screens/servers_list_screen.dart index fd84ca0..6003191 100644 --- a/lib/screens/servers_list_screen.dart +++ b/lib/screens/servers_list_screen.dart @@ -25,7 +25,7 @@ class ServersListScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: MainAppBar.appBar(context), + appBar: MainAppBar.appBarWithBack(context, title: "Server address"), body: const _ServersListScreenView(), ); } diff --git a/lib/screens/sync_screen.dart b/lib/screens/sync_screen.dart index 52ef3a8..4740fe6 100644 --- a/lib/screens/sync_screen.dart +++ b/lib/screens/sync_screen.dart @@ -96,19 +96,6 @@ class SyncScreenView extends StatelessWidget { onTap: () { context.push("/folders"); }, - )), - Card( - child: ListTile( - leading: const Icon(Icons.clear), - title: const Text("Delete synced files from this device?"), - subtitle: reactiveBuilder( - child: (context, state) { - return Text( - 'Deleting: ${state.deleteLocalFilesEnabled ?? false ? "ON" : "OFF"}'); - }), - onTap: () { - context.push("/deleteOption"); - }, )) ], ), @@ -156,47 +143,7 @@ class SyncScreenView extends StatelessWidget { Future _sync(BuildContext context, DeviceServicesCubit deviceService, SyncServicesCubit syncService) async { - if (!(deviceService.state.deleteLocalFilesEnabled ?? false)) { - await _run(context, deviceService, syncService); - } else { - final errorMessage = _validate(deviceService); - if (errorMessage.isNotEmpty) { - await deviceService.edit((state) { - state.lastErrorMessage = errorMessage; - }); - } else { - showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: const Text('Synced files will be deleted'), - content: const Wrap( - alignment: WrapAlignment.center, - spacing: 20, - runSpacing: 20, - children: [ - Text( - 'WARNING: Option Deleting=ON.', - textAlign: TextAlign.center, - ), - Text( - 'All the synced files will be deleted from this device.', - textAlign: TextAlign.center, - ), - Text( - 'Would you like to continue?', - textAlign: TextAlign.center, - ), - ]), - actions: [ - okButton(context, "Confirm", onPressed: () { - Navigator.pop(context); - _run(context, deviceService, syncService); - }), - cancelButton(context) - ], - )); - } - } + await _run(context, deviceService, syncService); } Future _run(BuildContext context, DeviceServicesCubit deviceService, @@ -211,6 +158,9 @@ class SyncScreenView extends StatelessWidget { } syncService.reset(); + // Clear thumbnail cache so new/updated files load fresh from server + await ThumbnailCacheService.clear(); + await deviceService.edit((state) { state.lastErrorMessage = null; state.isSyncing = true; diff --git a/lib/screens/trash_screen.dart b/lib/screens/trash_screen.dart new file mode 100644 index 0000000..1804243 --- /dev/null +++ b/lib/screens/trash_screen.dart @@ -0,0 +1,488 @@ +/* + Copyright 2024 Take Control - Software & Infrastructure + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import 'dart:async'; +import 'package:intl/intl.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:sync_client/config/config.dart'; +import 'package:sync_client/config/theme/app_theme.dart'; +import 'package:sync_client/core/core.dart'; +import 'package:sync_client/models/photo_item.dart'; +import 'package:sync_client/screens/components/components.dart'; +import 'package:sync_client/services/services.dart'; + +class TrashScreen extends StatefulWidget { + const TrashScreen({super.key}); + + @override + State createState() => _TrashScreenState(); +} + +class _TrashScreenState extends State { + List _trashPhotos = []; + Map> _trashPhotosByMonth = {}; + bool _isLoading = false; + String? _errorMessage; + bool _wasRouteCurrent = false; + bool _selectionMode = false; + bool _isRestoring = false; + final Set _selectedPaths = {}; + final Set _collapsedMonths = {}; + + static const String _trashFolder = 'Trash'; + + void _groupTrashByMonth() { + _trashPhotosByMonth.clear(); + for (final photo in _trashPhotos) { + final month = photo.month ?? 'Recent'; + _trashPhotosByMonth[month] ??= []; + _trashPhotosByMonth[month]!.add(photo); + } + for (final photos in _trashPhotosByMonth.values) { + photos.sort((a, b) => + (b.date ?? DateTime.now()).compareTo(a.date ?? DateTime.now())); + } + } + + List _getTrashPhotosInOrder() { + final sortedMonths = _trashPhotosByMonth.keys.toList(); + sortedMonths.sort((a, b) { + // Newest month first; "Recent" (unknown date) last + if (a == 'Recent') return 1; + if (b == 'Recent') return -1; + try { + final dateA = DateFormat('MMMM yyyy').parse(a); + final dateB = DateFormat('MMMM yyyy').parse(b); + return dateB.compareTo(dateA); + } catch (e) { + return b.compareTo(a); + } + }); + final list = []; + for (final month in sortedMonths) { + list.addAll(_trashPhotosByMonth[month] ?? []); + } + return list; + } + + Future _loadTrashFiles() async { + final deviceService = context.read(); + if (!deviceService.isAuthenticated()) { + if (mounted) context.push("/login"); + return; + } + final user = deviceService.state.currentUser?.email; + final deviceId = deviceService.state.id; + if (user == null || user.isEmpty || deviceId.isEmpty) return; + if ((deviceService.state.serverUrl ?? "").isEmpty) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final files = await apiGetFiles(user, deviceId, _trashFolder); + if (!mounted) return; + final photos = files + .where((f) => !f.toLowerCase().contains('.converted.jpg')) + .map((f) => PhotoItem.fromPath(f, _trashFolder)) + .toList(); + setState(() { + _trashPhotos = photos; + _groupTrashByMonth(); + _isLoading = false; + }); + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = e is CustomError ? e.message : e.toString(); + }); + } + } + } + + void _openPhotoViewer( + BuildContext context, List photos, int initialIndex) { + final photo = photos[initialIndex]; + if (photo.isVideo) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + video: photo, + photos: photos, + initialIndex: initialIndex, + isFromTrash: true, + ), + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PhotoViewerScreen( + photos: photos, + initialIndex: initialIndex, + isFromTrash: true, + ), + ), + ); + } + } + + void _toggleSelection(PhotoItem photo) { + setState(() { + if (_selectedPaths.contains(photo.path)) { + _selectedPaths.remove(photo.path); + } else { + _selectedPaths.add(photo.path); + } + }); + } + + Future _restoreSelected() async { + if (_selectedPaths.isEmpty) return; + final deviceService = context.read(); + final user = deviceService.state.currentUser?.email; + final deviceId = deviceService.state.id; + if (user == null || user.isEmpty || deviceId.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Not signed in')), + ); + } + return; + } + final paths = _selectedPaths.toList(); + setState(() => _isRestoring = true); + try { + final ok = await apiRestoreFromTrash(user, deviceId, paths) + .timeout(const Duration(seconds: 60)); + if (!mounted) return; + if (ok) { + context.read().requestHomeRefresh(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${paths.length} item(s) restored to Home')), + ); + setState(() { + _selectionMode = false; + _selectedPaths.clear(); + _isRestoring = false; + }); + _loadTrashFiles(); + // Clear cache in background so UI doesn't hang (many keys = slow) + unawaited(CacheService.clearCache()); + } else { + setState(() => _isRestoring = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to restore from Trash')), + ); + } + } on TimeoutException { + if (mounted) { + setState(() => _isRestoring = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text('Restore timed out. Check the server and try again.')), + ); + } + } catch (e) { + if (mounted) { + setState(() => _isRestoring = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } + } + + Widget _buildTrashList() { + final sortedMonths = _trashPhotosByMonth.keys.toList(); + sortedMonths.sort((a, b) { + // Newest month first; "Recent" last + if (a == 'Recent') return 1; + if (b == 'Recent') return -1; + try { + final dateA = DateFormat('MMMM yyyy').parse(a); + final dateB = DateFormat('MMMM yyyy').parse(b); + return dateB.compareTo(dateA); + } catch (e) { + return b.compareTo(a); + } + }); + final photosInOrder = _getTrashPhotosInOrder(); + + return ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: sortedMonths.length * 2 + 1, + itemBuilder: (context, index) { + if (index == 0 && _isLoading) { + return const LinearProgressIndicator(); + } + final adjustedIndex = _isLoading ? index - 1 : index; + final monthIndex = adjustedIndex ~/ 2; + final isHeader = adjustedIndex % 2 == 0; + + if (monthIndex >= sortedMonths.length) { + return const SizedBox.shrink(); + } + + final month = sortedMonths[monthIndex]; + final photos = _trashPhotosByMonth[month] ?? []; + + if (isHeader) { + final isCollapsed = _collapsedMonths.contains(month); + return Material( + color: Theme.of(context).scaffoldBackgroundColor, + child: InkWell( + onTap: () { + setState(() { + if (isCollapsed) { + _collapsedMonths.remove(month); + } else { + _collapsedMonths.add(month); + } + }); + }, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon( + isCollapsed ? Icons.chevron_right : Icons.expand_more, + size: 28, + color: Theme.of(context).colorScheme.onSurface, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + month, + style: + Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + '${photos.length}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } + + if (_collapsedMonths.contains(month)) { + return const SizedBox.shrink(); + } + + return Padding( + padding: GalleryStyles.galleryPadding, + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: GalleryStyles.photoSpacing, + crossAxisSpacing: GalleryStyles.photoSpacing, + childAspectRatio: 1.0, + ), + itemCount: photos.length, + itemBuilder: (context, gridIndex) { + final photo = photos[gridIndex]; + final globalIndex = photosInOrder.indexOf(photo); + return GalleryPhotoTile( + photo: photo, + onTap: _selectionMode + ? () => _toggleSelection(photo) + : () => + _openPhotoViewer(context, photosInOrder, globalIndex), + isSelectionMode: _selectionMode, + isSelected: _selectedPaths.contains(photo.path), + ); + }, + ), + ); + }, + ); + } + + AppBar _buildSelectionAppBar() { + final n = _selectedPaths.length; + return AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: _isRestoring + ? null + : () { + setState(() { + _selectionMode = false; + _selectedPaths.clear(); + }); + }, + tooltip: 'Cancel', + ), + title: Text(n == 0 ? 'Select items' : '$n selected'), + actions: [ + if (n > 0) + TextButton.icon( + icon: _isRestoring + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.restore_from_trash), + label: Text(_isRestoring ? 'Restoring…' : 'Restore from Trash'), + onPressed: _isRestoring ? null : _restoreSelected, + ), + ], + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final isCurrent = ModalRoute.of(context)?.isCurrent ?? false; + if (isCurrent && !_wasRouteCurrent) { + _loadTrashFiles(); + } + _wasRouteCurrent = isCurrent; + } + + @override + Widget build(BuildContext context) { + final deviceService = context.read(); + if (!deviceService.isAuthenticated()) { + return Scaffold( + appBar: MainAppBar.appBar(context), + body: const Center(child: CircularProgressIndicator()), + ); + } + + return BlocListener( + listener: (context, state) { + if (state.trashNeedsRefresh && mounted) { + context.read().clearTrashRefresh(); + _loadTrashFiles(); + } + }, + child: Scaffold( + appBar: _selectionMode + ? _buildSelectionAppBar() + : MainAppBar.appBar( + context, + actionsBeforeMenu: _trashPhotos.isEmpty + ? null + : [ + IconButton( + icon: const Icon(Icons.checklist_rtl), + onPressed: () { + setState(() { + _selectionMode = true; + _selectedPaths.clear(); + }); + }, + tooltip: 'Select', + ), + ], + ), + floatingActionButton: _selectionMode + ? null + : Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FloatingActionButton.small( + heroTag: 'trash_refresh', + onPressed: _isLoading ? null : _loadTrashFiles, + tooltip: 'Refresh', + child: _isLoading && _trashPhotos.isNotEmpty + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Icon(Icons.refresh), + ), + ], + ), + body: RefreshIndicator( + onRefresh: _loadTrashFiles, + child: _isLoading && _trashPhotos.isEmpty + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _errorMessage!, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadTrashFiles, + child: const Text('Retry'), + ), + ], + ), + ), + ) + : _trashPhotos.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.delete_outline, + size: 64, + color: Theme.of(context).colorScheme.primary), + const SizedBox(height: 16), + Text( + 'Trash', + style: + Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + 'Items moved to Trash will appear here.', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ) + : _buildTrashList(), + ), + ), + ); + } +} diff --git a/lib/services/cache_service.dart b/lib/services/cache_service.dart new file mode 100644 index 0000000..9374812 --- /dev/null +++ b/lib/services/cache_service.dart @@ -0,0 +1,114 @@ +// lib/services/cache_service.dart + +import 'dart:typed_data'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class CacheService { + static const String _foldersKey = 'cached_folders'; + static const String _foldersTimeKey = 'cached_folders_time'; + static const String _filesPrefix = 'cached_files_'; + static const String _imagesPrefix = 'cached_image_'; + static const Duration _cacheExpiry = Duration(hours: 1); + static const Duration _imageCacheExpiry = Duration(days: 7); + + static Future?> getCachedFolders() async { + try { + final prefs = await SharedPreferences.getInstance(); + final cachedData = prefs.getString(_foldersKey); + final cacheTime = prefs.getInt(_foldersTimeKey); + + if (cachedData != null && cacheTime != null) { + final cacheAge = DateTime.now().millisecondsSinceEpoch - cacheTime; + if (cacheAge < _cacheExpiry.inMilliseconds) { + final List decoded = json.decode(cachedData); + return decoded.cast(); + } + } + } catch (e) { + debugPrint('Error reading folder cache: $e'); + } + return null; + } + + static Future cacheFolders(List folders) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_foldersKey, json.encode(folders)); + await prefs.setInt( + _foldersTimeKey, DateTime.now().millisecondsSinceEpoch); + } catch (e) { + debugPrint('Error caching folders: $e'); + } + } + + static Future?> getCachedFiles(String folder) async { + try { + final prefs = await SharedPreferences.getInstance(); + final key = '$_filesPrefix$folder'; + final cachedData = prefs.getString(key); + + if (cachedData != null) { + final List decoded = json.decode(cachedData); + return decoded.cast(); + } + } catch (e) { + debugPrint('Error reading files cache: $e'); + } + return null; + } + + static Future cacheFiles(String folder, List files) async { + try { + final prefs = await SharedPreferences.getInstance(); + final key = '$_filesPrefix$folder'; + await prefs.setString(key, json.encode(files)); + } catch (e) { + debugPrint('Error caching files: $e'); + } + } + + static Future cacheImage(String file, Uint8List data) async { + try { + final prefs = await SharedPreferences.getInstance(); + final key = '$_imagesPrefix$file'; + final base64String = base64Encode(data); + await prefs.setString(key, base64String); + await prefs.setInt('${key}_time', DateTime.now().millisecondsSinceEpoch); + } catch (e) { + debugPrint('Error caching image: $e'); + } + } + + static Future getCachedImage(String file) async { + try { + final prefs = await SharedPreferences.getInstance(); + final key = '$_imagesPrefix$file'; + final base64String = prefs.getString(key); + final cacheTime = prefs.getInt('${key}_time'); + + if (base64String != null && cacheTime != null) { + final cacheAge = DateTime.now().millisecondsSinceEpoch - cacheTime; + if (cacheAge < _imageCacheExpiry.inMilliseconds) { + return base64Decode(base64String); + } + } + } catch (e) { + debugPrint('Error reading image cache: $e'); + } + return null; + } + + static Future clearCache() async { + final prefs = await SharedPreferences.getInstance(); + final keys = prefs.getKeys(); + for (final key in keys) { + if (key.startsWith(_foldersKey) || + key.startsWith(_filesPrefix) || + key.startsWith(_imagesPrefix)) { + await prefs.remove(key); + } + } + } +} diff --git a/lib/services/device_services.dart b/lib/services/device_services.dart index 068780a..8aab49a 100644 --- a/lib/services/device_services.dart +++ b/lib/services/device_services.dart @@ -2,28 +2,38 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:sync_client/core/core.dart'; import 'package:sync_client/storage/storage.dart'; class DeviceServicesCubit extends Cubit { DeviceServicesCubit() : super(currentDeviceSettings); bool isAuthenticated() { - bool loggedIn = state.currentUser?.loggedIn ?? false; - if (!loggedIn) { - logOut(); + if (state.currentUser == null) { + return false; } - return loggedIn; + return state.currentUser?.loggedIn ?? false; } Future logInUserEmailPassword(String email, String password) async { - if (state.currentUser == null || - state.currentUser?.email != email || - state.currentUser?.password != password) { - throw ArgumentError("Invalid user credentials", email); + if (state.serverUrl == null || state.serverUrl!.trim().isEmpty) { + throw ServerUrlNotSetError(); } + final result = await apiLogin(email, password); + await setAuthToken(result.token); await edit( (state) { - state.currentUser!.loggedIn = true; + if (state.currentUser == null) { + state.currentUser = User(email) + ..password = password + ..userId = result.userId + ..loggedIn = true; + } else { + state.currentUser!.email = email; + state.currentUser!.password = password; + state.currentUser!.userId = result.userId; + state.currentUser!.loggedIn = true; + } }, ); emit(state); @@ -31,29 +41,35 @@ class DeviceServicesCubit extends Cubit { } Future registerUserEmailPassword(String email, String password) async { + if (state.serverUrl == null || state.serverUrl!.trim().isEmpty) { + throw ServerUrlNotSetError(); + } + final result = await apiRegister(email, password); + await setAuthToken(result.token); await edit( (state) { if (state.currentUser == null) { - state.currentUser ??= User(email)..password = password; + state.currentUser = User(email) + ..password = password + ..userId = result.userId + ..loggedIn = true; } else { state.currentUser!.email = email; state.currentUser!.password = password; + state.currentUser!.userId = result.userId; + state.currentUser!.loggedIn = true; } - state.currentUser!.loggedIn = true; }, ); return state.currentUser!; } Future logOut() async { - if (isAuthenticated()) { - edit( - (state) { - state.currentUser!.loggedIn = false; - }, - ); + await clearAuthToken(); + await edit((state) { state.currentUser = null; - } + }); + emit(state); } Future edit(T Function(DeviceSettings) editCallback) async { @@ -73,4 +89,44 @@ class DeviceServicesCubit extends Cubit { DeviceSettings newState = currentDeviceSettings; emit(newState); } + + /// Updates the device ID (e.g. after user confirms or changes it on registration). + Future updateDeviceId(String newId) async { + final trimmed = newId.trim(); + if (trimmed.isEmpty) return; + await edit((state) { + state.id = trimmed; + }); + } + + /// Updates the server URL (e.g. on login/sign up screen before authenticating). + Future updateServerUrl(String url) async { + final trimmed = url.trim(); + if (trimmed.isEmpty) return; + await edit((state) { + if (state.serverUrl != trimmed) { + state.syncedFiles = []; + state.lastSyncDateTime = null; + } + state.serverUrl = trimmed; + }); + } + + /// Toggles whether gallery shows photos from all devices (true) or only this device (false). + Future updateShowAllDevices(bool showAll) async { + await edit((state) { + state.showAllDevices = showAll; + }); + } + + /// Clears sync metadata only: syncedFiles, lastSyncDateTime, isSyncing. + /// Keeps account (currentUser, serverUrl, id) and selected folders (mediaDirectories). + /// Persists to deviceSettings.json. + Future clearSyncMetadata() async { + await edit((state) { + state.syncedFiles = []; + state.lastSyncDateTime = null; + state.isSyncing = null; + }); + } } diff --git a/lib/services/enhanced_cache_service.dart b/lib/services/enhanced_cache_service.dart new file mode 100644 index 0000000..915430e --- /dev/null +++ b/lib/services/enhanced_cache_service.dart @@ -0,0 +1,119 @@ +// lib/services/enhanced_cache_service.dart + +import 'dart:typed_data'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'cache_service.dart'; +import 'thumbnail_cache_service.dart'; + +class EnhancedCacheService extends CacheService { + static const String _thumbnailPrefix = 'cached_thumb_'; + static const String _fullImagePrefix = 'cached_image_'; + static const Duration _imageCacheExpiry = Duration(days: 7); + + // Thumbnail caching: disk + memory via ThumbnailCacheService for fast lists + static Future cacheThumbnail(String file, Uint8List data) async { + await ThumbnailCacheService.put(file, data); + } + + static Future getCachedThumbnail(String file) async { + return ThumbnailCacheService.get(file); + } + + // Full image caching + static Future cacheImage(String file, Uint8List data) async { + try { + final prefs = await SharedPreferences.getInstance(); + final key = '$_fullImagePrefix${_sanitizeKey(file)}'; + + final base64String = base64Encode(data); + await prefs.setString(key, base64String); + await prefs.setInt('${key}_time', DateTime.now().millisecondsSinceEpoch); + } catch (e) { + debugPrint('Error caching image: $e'); + } + } + + static Future getCachedImage(String file) async { + try { + final prefs = await SharedPreferences.getInstance(); + final key = '$_fullImagePrefix${_sanitizeKey(file)}'; + final base64String = prefs.getString(key); + final cacheTime = prefs.getInt('${key}_time'); + + if (base64String != null && cacheTime != null) { + final cacheAge = DateTime.now().millisecondsSinceEpoch - cacheTime; + if (cacheAge < _imageCacheExpiry.inMilliseconds) { + return base64Decode(base64String); + } + } + } catch (e) { + debugPrint('Error reading image cache: $e'); + } + return null; + } + + // Clear all caches + static Future clearCache() async { + await ThumbnailCacheService.clear(); + final prefs = await SharedPreferences.getInstance(); + final keys = prefs.getKeys(); + + for (final key in keys) { + if (key.startsWith(_thumbnailPrefix) || + key.startsWith(_fullImagePrefix)) { + await prefs.remove(key); + } + } + } + + // Clear old cache entries (SharedPreferences only; disk thumbnail cache has its own expiry) + static Future clearOldCache() async { + final prefs = await SharedPreferences.getInstance(); + final keys = prefs.getKeys(); + final now = DateTime.now().millisecondsSinceEpoch; + + for (final key in keys) { + if (key.endsWith('_time')) { + final cacheTime = prefs.getInt(key); + if (cacheTime != null) { + final age = now - cacheTime; + + // Remove if older than expiry (full image only; thumbnails are on disk) + if (key.contains(_fullImagePrefix) && + age > _imageCacheExpiry.inMilliseconds) { + final dataKey = key.substring(0, key.length - 5); // Remove '_time' + await prefs.remove(dataKey); + await prefs.remove(key); + } + } + } + } + } + + // Get cache size (SharedPreferences only) + static Future getCacheSize() async { + final prefs = await SharedPreferences.getInstance(); + final keys = prefs.getKeys(); + int totalSize = 0; + + for (final key in keys) { + if ((key.startsWith(_thumbnailPrefix) || + key.startsWith(_fullImagePrefix)) && + !key.endsWith('_time')) { + final data = prefs.getString(key); + if (data != null) { + totalSize += data.length; + } + } + } + + return totalSize; + } + + // Sanitize key to remove invalid characters + static String _sanitizeKey(String key) { + return key.replaceAll(RegExp(r'[^\w\-.]'), '_'); + } +} diff --git a/lib/services/services.dart b/lib/services/services.dart index 71a29b5..12c4821 100644 --- a/lib/services/services.dart +++ b/lib/services/services.dart @@ -16,3 +16,7 @@ limitations under the License. export 'device_services.dart'; export 'sync_services.dart'; + +export 'cache_service.dart'; +export 'enhanced_cache_service.dart'; +export 'thumbnail_cache_service.dart'; diff --git a/lib/services/thumbnail_cache_service.dart b/lib/services/thumbnail_cache_service.dart new file mode 100644 index 0000000..95ebfa8 --- /dev/null +++ b/lib/services/thumbnail_cache_service.dart @@ -0,0 +1,237 @@ +/* + Copyright 2024 Take Control - Software & Infrastructure + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; + +const String _manifestFilename = 'manifest.txt'; + +/// Thumbnails stored as files on the device. No expiry – thumbnails don't change +/// unless deleted on server. Cache is cleared when user presses Sync. +class ThumbnailCacheService { + static const int _maxMemoryEntries = 120; + + static Directory? _cacheDir; + static final Map _memoryCache = {}; + static final List _memoryLru = []; + + /// Thumbnail files on device (e.g. ...\Roaming\...\thumbnails on Windows). + /// Prefer support dir; fallback to cache then temp. + static Future _getCacheDir() async { + if (_cacheDir != null) return _cacheDir!; + + Future tryBase( + Future baseFuture, String label) async { + final base = await baseFuture; + if (!await base.exists()) { + await base.create(recursive: true); + } + final thumbDir = Directory(p.join(base.path, 'thumbnails')); + await thumbDir.create(recursive: true); + debugPrint('ThumbnailCacheService: using $label: ${thumbDir.path}'); + return thumbDir; + } + + try { + _cacheDir = await tryBase(getApplicationSupportDirectory(), 'support'); + return _cacheDir!; + } catch (e) { + debugPrint( + 'ThumbnailCacheService: support dir failed ($e), trying cache dir'); + try { + _cacheDir = await tryBase(getApplicationCacheDirectory(), 'cache'); + return _cacheDir!; + } catch (e2) { + debugPrint( + 'ThumbnailCacheService: cache dir failed ($e2), using temp dir'); + try { + _cacheDir = await tryBase(getTemporaryDirectory(), 'temp'); + return _cacheDir!; + } catch (e3) { + debugPrint('ThumbnailCacheService: all dirs failed ($e3)'); + rethrow; + } + } + } + } + + static String _sanitizeKey(String path) { + final safe = path + .replaceAll(RegExp(r'[/\\]'), '_') + .replaceAll(RegExp(r'[^\w\-.]'), '_'); + if (safe.length > 200) { + return '${safe.hashCode.abs()}_${safe.substring(safe.length - 50)}'; + } + return safe.isEmpty ? 'empty' : safe; + } + + /// Returns cached thumbnail bytes: device files first, then in-memory cache. + /// Returns null if not on device → caller should fetch from server and call put(). + /// Never throws; on timeout or disk error returns null so caller fetches from server. + static Future get(String path) async { + try { + final key = _sanitizeKey(path); + + // 2) In-memory cache first (no disk I/O) + final mem = _memoryCache[key]; + if (mem != null) { + _touchLru(key); + return mem.bytes; + } + + // 1) Device files (with timeout so we don't hang on Windows) + try { + final dir = await _getCacheDir().timeout(const Duration(seconds: 3), + onTimeout: () => throw TimeoutException('getCacheDir')); + final file = File(p.join(dir.path, key)); + if (await file.exists()) { + final bytes = await file.readAsBytes(); + _putMemory(key, bytes); + return bytes; + } + } on TimeoutException catch (e) { + debugPrint('ThumbnailCacheService get (disk) timeout: $e'); + } catch (e) { + debugPrint('ThumbnailCacheService get (disk) error: $e'); + } + } catch (e) { + debugPrint('ThumbnailCacheService get error: $e'); + } + return null; + } + + /// Saves thumbnail as a file on the device and keeps a copy in memory. + /// Never throws; if disk write fails, memory copy still used this session. + static Future put(String path, Uint8List bytes) async { + try { + final key = _sanitizeKey(path); + _putMemory(key, bytes); + + try { + final dir = await _getCacheDir(); + final filePath = p.join(dir.path, key); + final file = File(filePath); + await file.writeAsBytes(bytes, flush: true); + final ok = await file.exists(); + debugPrint( + 'ThumbnailCacheService put: ${ok ? "OK" : "FAIL"} $filePath'); + if (!ok) { + debugPrint('ThumbnailCacheService put: file missing after write'); + } + await _manifestAdd(dir, path); + } on FileSystemException catch (e) { + debugPrint( + 'ThumbnailCacheService put (disk) FileSystemException: ${e.message} path=${e.path}'); + } catch (e, st) { + debugPrint('ThumbnailCacheService put (disk) error: $e'); + debugPrint('ThumbnailCacheService put stack: $st'); + } + } catch (e) { + debugPrint('ThumbnailCacheService put error: $e'); + } + } + + static File _manifestFile(Directory dir) => + File(p.join(dir.path, _manifestFilename)); + + static Future _manifestAdd(Directory dir, String path) async { + try { + final file = _manifestFile(dir); + final lines = await file.exists() ? await file.readAsLines() : []; + if (lines.contains(path)) return; + await file.writeAsString('${path.replaceAll('\n', ' ')}\n', + mode: FileMode.append); + } catch (e) { + debugPrint('ThumbnailCacheService _manifestAdd error: $e'); + } + } + + /// All paths that have a cached thumbnail (from manifest). + static Future> listCachedPaths() async { + try { + final dir = await _getCacheDir(); + final file = _manifestFile(dir); + if (!await file.exists()) return []; + final lines = await file.readAsLines(); + return lines.map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); + } catch (e) { + debugPrint('ThumbnailCacheService listCachedPaths error: $e'); + return []; + } + } + + /// Removes one cached thumbnail (file + manifest entry + memory). Path must match manifest. + static Future delete(String path) async { + final key = _sanitizeKey(path); + _memoryCache.remove(key); + _memoryLru.remove(key); + try { + final dir = await _getCacheDir(); + final file = File(p.join(dir.path, key)); + if (await file.exists()) await file.delete(); + final manifest = _manifestFile(dir); + if (await manifest.exists()) { + final lines = await manifest.readAsLines(); + final rest = lines.where((s) => s.trim() != path).toList(); + await manifest + .writeAsString(rest.isEmpty ? '' : '${rest.join('\n')}\n'); + } + } catch (e) { + debugPrint('ThumbnailCacheService delete error: $e'); + } + } + + static void _putMemory(String key, Uint8List bytes) { + while (_memoryLru.length >= _maxMemoryEntries && _memoryLru.isNotEmpty) { + final evict = _memoryLru.removeAt(0); + _memoryCache.remove(evict); + } + _memoryCache[key] = _CacheEntry(bytes, DateTime.now()); + _touchLru(key); + } + + static void _touchLru(String key) { + _memoryLru.remove(key); + _memoryLru.add(key); + } + + static Future clear() async { + _memoryCache.clear(); + _memoryLru.clear(); + try { + final dir = await _getCacheDir(); + if (await dir.exists()) { + await for (final entity in dir.list()) { + if (entity is File) await entity.delete(); + } + } + final manifest = _manifestFile(dir); + if (await manifest.exists()) await manifest.delete(); + } catch (e) { + debugPrint('ThumbnailCacheService clear error: $e'); + } + } +} + +class _CacheEntry { + final Uint8List bytes; + final DateTime at; + _CacheEntry(this.bytes, this.at); +} diff --git a/lib/storage/device_id.dart b/lib/storage/device_id.dart new file mode 100644 index 0000000..1ebe9ea --- /dev/null +++ b/lib/storage/device_id.dart @@ -0,0 +1,212 @@ +/* + Copyright 2026 Take Control - Software & Infrastructure + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +const _storageKeyDeviceId = 'persistent_device_id'; + +/// Spoofed MAC returned by Android 6+ and iOS (not usable as device ID). +const _spoofedMacPattern = '02:00:00:00:00:00'; + +/// Returns a stable device identifier so that after app reinstall the same +/// device still maps to the same folder on the server (UserId/DeviceId/...). +/// - Android: Android ID (survives reinstall). Serial/MAC not accessible (OS restriction). +/// - iOS: identifierForVendor (survives reinstall). Serial/MAC not accessible (OS restriction). +/// - Desktop (Windows/Linux/macOS): tries primary ID (deviceId/machineId/systemGUID), +/// then serial number (when available), then MAC address (when available and not randomized). +/// - Fallback: UUID stored in SharedPreferences. +Future getPersistentDeviceId() async { + final deviceInfo = DeviceInfoPlugin(); + try { + if (Platform.isAndroid) { + final android = await deviceInfo.androidInfo; + final id = android.id; + if (id.isNotEmpty && id != 'unknown') { + return _sanitizeForPath(id); + } + } + if (Platform.isIOS) { + final ios = await deviceInfo.iosInfo; + final id = ios.identifierForVendor; + if (id != null && id.isNotEmpty) { + return _sanitizeForPath(id); + } + } + if (Platform.isWindows) { + final windows = await deviceInfo.windowsInfo; + if (windows.deviceId.isNotEmpty) + return _sanitizeForPath(windows.deviceId); + final serial = await _getDesktopSerialNumber(); + if (serial != null && serial.isNotEmpty) return _sanitizeForPath(serial); + final mac = await _getDesktopMacAddress(); + if (mac != null && mac.isNotEmpty) return _sanitizeForPath(mac); + } + if (Platform.isLinux) { + final linux = await deviceInfo.linuxInfo; + if (linux.machineId != null && linux.machineId!.isNotEmpty) { + return _sanitizeForPath(linux.machineId!); + } + final serial = await _getDesktopSerialNumber(); + if (serial != null && serial.isNotEmpty) return _sanitizeForPath(serial); + final mac = await _getDesktopMacAddress(); + if (mac != null && mac.isNotEmpty) return _sanitizeForPath(mac); + } + if (Platform.isMacOS) { + final macos = await deviceInfo.macOsInfo; + if (macos.systemGUID != null && macos.systemGUID!.isNotEmpty) { + return _sanitizeForPath(macos.systemGUID!); + } + final serial = await _getDesktopSerialNumber(); + if (serial != null && serial.isNotEmpty) return _sanitizeForPath(serial); + final mac = await _getDesktopMacAddress(); + if (mac != null && mac.isNotEmpty) return _sanitizeForPath(mac); + } + } catch (_) {} + return _getOrCreateStoredDeviceId(); +} + +/// Tries to read the machine serial number on desktop (Windows/Linux/macOS). +/// Returns null on mobile, on failure, or when value is placeholder (e.g. "To be filled by O.E.M."). +Future _getDesktopSerialNumber() async { + try { + if (Platform.isWindows) { + final result = await Process.run( + 'powershell', + [ + '-NoProfile', + '-Command', + r'(Get-CimInstance Win32_BIOS).SerialNumber' + ], + runInShell: false, + ); + if (result.exitCode == 0 && result.stdout != null) { + final s = (result.stdout as String).trim(); + if (s.isNotEmpty && !_isPlaceholderSerial(s)) return s; + } + } + if (Platform.isLinux) { + const path = '/sys/class/dmi/id/product_serial'; + final file = File(path); + if (await file.exists()) { + final s = (await file.readAsString()).trim(); + if (s.isNotEmpty && !_isPlaceholderSerial(s)) return s; + } + } + if (Platform.isMacOS) { + final result = await Process.run( + 'ioreg', + ['-c', 'IOPlatformExpertDevice', '-d', '2'], + runInShell: false, + ); + if (result.exitCode == 0 && result.stdout != null) { + final out = result.stdout as String; + final match = + RegExp(r'"IOPlatformSerialNumber"\s*=\s*"([^"]+)"').firstMatch(out); + if (match != null) { + final s = match.group(1)?.trim() ?? ''; + if (s.isNotEmpty && !_isPlaceholderSerial(s)) return s; + } + } + } + } catch (_) {} + return null; +} + +bool _isPlaceholderSerial(String s) { + final lower = s.toLowerCase(); + return lower.contains('to be filled') || + lower.contains('o.e.m') || + lower == 'unknown' || + lower == 'none' || + s == '0'; +} + +/// Tries to read the first non-loopback MAC address on desktop. +/// Returns null on mobile, on failure, or when MAC is spoofed (e.g. 02:00:00:00:00:00). +Future _getDesktopMacAddress() async { + try { + if (Platform.isWindows) { + final result = await Process.run( + 'powershell', + [ + '-NoProfile', + '-Command', + r'(Get-NetAdapter | Where-Object MacAddress -ne $null | Select-Object -First 1).MacAddress', + ], + runInShell: false, + ); + if (result.exitCode == 0 && result.stdout != null) { + final s = (result.stdout as String).trim(); + if (s.isNotEmpty && !_isSpoofedMac(s)) return s; + } + } + if (Platform.isLinux) { + final netDir = Directory('/sys/class/net'); + if (await netDir.exists()) { + await for (final entity in netDir.list()) { + if (entity is! Directory || entity.path.endsWith('/lo')) continue; + final name = entity.path.split('/').last; + if (name == 'lo') continue; + final addrFile = File('${entity.path}/address'); + if (await addrFile.exists()) { + final s = (await addrFile.readAsString()).trim(); + if (s.isNotEmpty && !_isSpoofedMac(s)) return s; + } + } + } + } + if (Platform.isMacOS) { + for (final iface in ['en0', 'en1', 'ether']) { + final result = + await Process.run('ifconfig', [iface], runInShell: false); + if (result.exitCode == 0 && result.stdout != null) { + final match = RegExp(r'ether\s+([0-9a-fA-F:]+)') + .firstMatch(result.stdout as String); + if (match != null) { + final s = match.group(1)?.trim() ?? ''; + if (s.isNotEmpty && !_isSpoofedMac(s)) return s; + } + } + } + } + } catch (_) {} + return null; +} + +bool _isSpoofedMac(String mac) { + final n = mac.replaceAll(':', '').toLowerCase(); + return n == '020000000000' || mac.trim().toLowerCase() == _spoofedMacPattern; +} + +/// Fallback: read stored UUID or create and store one (web, or when platform ID is unavailable). +Future _getOrCreateStoredDeviceId() async { + final prefs = await SharedPreferences.getInstance(); + String? id = prefs.getString(_storageKeyDeviceId); + if (id == null || id.isEmpty) { + id = const Uuid().v4(); + await prefs.setString(_storageKeyDeviceId, id); + } + return _sanitizeForPath(id); +} + +/// Keep only characters safe for folder names (alphanumeric, hyphen, underscore). +String _sanitizeForPath(String id) { + return id.replaceAll(RegExp(r'[^a-zA-Z0-9\-_]'), '_'); +} diff --git a/lib/storage/schema.dart b/lib/storage/schema.dart index 6895cf9..49e307f 100644 --- a/lib/storage/schema.dart +++ b/lib/storage/schema.dart @@ -8,6 +8,9 @@ class User { String email; String? password; + + /// Server-side user id (from /auth/login or /auth/register). Used for paths when auth DB is enabled. + String? userId; bool? loggedIn = false; factory User.fromJson(Map json) => _$UserFromJson(json); @@ -23,16 +26,31 @@ class DeviceSettings { String? model; String? serverUrl; User? currentUser; + + @JsonKey( + toJson: _setToList, + fromJson: _listToSet, + defaultValue: {}, + ) Set mediaDirectories = {}; + String? lastErrorMessage; String? successMessage; DateTime? lastSyncDateTime; - bool? deleteLocalFilesEnabled; + + /// When true, gallery shows photos from all devices for this account; when false, only this device. + bool showAllDevices = true; + + @JsonKey(defaultValue: []) List syncedFiles = []; + bool? isSyncing; - factory DeviceSettings.fromJson(Map json) => - _$DeviceSettingsFromJson(json); + factory DeviceSettings.fromJson(Map json) { + // Ensure mediaDirectories is not null + json['mediaDirectories'] ??= []; + return _$DeviceSettingsFromJson(json); + } Map toJson() => _$DeviceSettingsToJson(this); @@ -47,6 +65,17 @@ class DeviceSettings { @override int get hashCode => super.hashCode + 1; + + // Helper methods for JSON conversion + static List _setToList(Set set) => set.toList(); + + static Set _listToSet(dynamic list) { + if (list == null) return {}; + if (list is List) { + return Set.from(list.whereType()); + } + return {}; + } } @JsonSerializable() diff --git a/lib/storage/schema.g.dart b/lib/storage/schema.g.dart index f9ea348..d0ce9e5 100644 --- a/lib/storage/schema.g.dart +++ b/lib/storage/schema.g.dart @@ -10,11 +10,13 @@ User _$UserFromJson(Map json) => User( json['email'] as String, ) ..password = json['password'] as String? + ..userId = json['userId'] as String? ..loggedIn = json['loggedIn'] as bool?; Map _$UserToJson(User instance) => { 'email': instance.email, 'password': instance.password, + 'userId': instance.userId, 'loggedIn': instance.loggedIn, }; @@ -27,18 +29,19 @@ DeviceSettings _$DeviceSettingsFromJson(Map json) => ..currentUser = json['currentUser'] == null ? null : User.fromJson(json['currentUser'] as Map) - ..mediaDirectories = (json['mediaDirectories'] as List) - .map((e) => e as String) - .toSet() + ..mediaDirectories = json['mediaDirectories'] == null + ? {} + : DeviceSettings._listToSet(json['mediaDirectories']) ..lastErrorMessage = json['lastErrorMessage'] as String? ..successMessage = json['successMessage'] as String? ..lastSyncDateTime = json['lastSyncDateTime'] == null ? null : DateTime.parse(json['lastSyncDateTime'] as String) - ..deleteLocalFilesEnabled = json['deleteLocalFilesEnabled'] as bool? - ..syncedFiles = (json['syncedFiles'] as List) - .map((e) => SyncedFile.fromJson(e as Map)) - .toList() + ..showAllDevices = json['showAllDevices'] as bool? ?? true + ..syncedFiles = (json['syncedFiles'] as List?) + ?.map((e) => SyncedFile.fromJson(e as Map)) + .toList() ?? + [] ..isSyncing = json['isSyncing'] as bool?; Map _$DeviceSettingsToJson(DeviceSettings instance) => @@ -47,11 +50,11 @@ Map _$DeviceSettingsToJson(DeviceSettings instance) => 'model': instance.model, 'serverUrl': instance.serverUrl, 'currentUser': instance.currentUser, - 'mediaDirectories': instance.mediaDirectories.toList(), + 'mediaDirectories': DeviceSettings._setToList(instance.mediaDirectories), 'lastErrorMessage': instance.lastErrorMessage, 'successMessage': instance.successMessage, 'lastSyncDateTime': instance.lastSyncDateTime?.toIso8601String(), - 'deleteLocalFilesEnabled': instance.deleteLocalFilesEnabled, + 'showAllDevices': instance.showAllDevices, 'syncedFiles': instance.syncedFiles, 'isSyncing': instance.isSyncing, }; diff --git a/lib/storage/storage.dart b/lib/storage/storage.dart index b86250c..1184220 100644 --- a/lib/storage/storage.dart +++ b/lib/storage/storage.dart @@ -16,9 +16,9 @@ limitations under the License. import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:uuid/uuid.dart'; import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:sync_client/storage/device_id.dart'; import 'package:sync_client/storage/schema.dart'; export 'schema.dart'; @@ -28,40 +28,156 @@ late DeviceSettings currentDeviceSettings; Future updateCurrentDevice( DeviceSettings deviceSettings) async { - deviceSettings.id = const Uuid().v4(); + deviceSettings.id = await getPersistentDeviceId(); return deviceSettings; } Future loadDeviceSettings() async { - Directory appDocDir = await getApplicationDocumentsDirectory(); - File jsonFile = File("${appDocDir.path}/$dataFilename"); - if (!await jsonFile.exists() || jsonFile.readAsStringSync().isEmpty) { - ByteData realmBytes = await rootBundle.load("data/$dataFilename"); - await jsonFile.writeAsBytes( - realmBytes.buffer - .asUint8List(realmBytes.offsetInBytes, realmBytes.lengthInBytes), - mode: FileMode.write, - ); - final jsonAsString = await jsonFile.readAsString(); - final deviceSettings = jsonAsString.isNotEmpty - ? DeviceSettings.fromJson(jsonDecode(jsonAsString)) - : DeviceSettings(""); - currentDeviceSettings = await updateCurrentDevice(deviceSettings); - jsonFile.writeAsStringSync(jsonEncode(currentDeviceSettings.toJson())); + try { + Directory appDocDir = await getApplicationDocumentsDirectory(); + File jsonFile = File("${appDocDir.path}/$dataFilename"); + + if (!await jsonFile.exists() || jsonFile.readAsStringSync().isEmpty) { + // Try to load from bundled asset + try { + ByteData assetBytes = await rootBundle.load("data/$dataFilename"); + await jsonFile.writeAsBytes( + assetBytes.buffer + .asUint8List(assetBytes.offsetInBytes, assetBytes.lengthInBytes), + mode: FileMode.write, + ); + } catch (e) { + // If asset doesn't exist, create default settings + print( + 'No bundled deviceSettings.json found, creating default settings'); + currentDeviceSettings = await updateCurrentDevice(DeviceSettings("")); + await saveDeviceSettings(currentDeviceSettings); + return; + } + + final jsonAsString = await jsonFile.readAsString(); + + DeviceSettings deviceSettings; + if (jsonAsString.isNotEmpty) { + try { + final jsonData = jsonDecode(jsonAsString); + // Ensure mediaDirectories exists and is a list + jsonData['mediaDirectories'] ??= []; + deviceSettings = DeviceSettings.fromJson(jsonData); + } catch (e) { + print('Error parsing bundled deviceSettings.json: $e'); + deviceSettings = DeviceSettings(""); + } + } else { + deviceSettings = DeviceSettings(""); + } + + currentDeviceSettings = await updateCurrentDevice(deviceSettings); + jsonFile.writeAsStringSync(jsonEncode(currentDeviceSettings.toJson())); + } + + // Read and parse the JSON file + final jsonAsString = jsonFile.readAsStringSync(); + + if (jsonAsString.isEmpty) { + // Empty file, create default settings + currentDeviceSettings = await updateCurrentDevice(DeviceSettings("")); + await saveDeviceSettings(currentDeviceSettings); + return; + } + + try { + final jsonData = jsonDecode(jsonAsString); + + // Ensure mediaDirectories is not null + if (jsonData is Map) { + jsonData['mediaDirectories'] ??= []; + + // Ensure it's a list + if (jsonData['mediaDirectories'] is! List) { + jsonData['mediaDirectories'] = []; + } + } + + currentDeviceSettings = DeviceSettings.fromJson(jsonData); + } catch (e) { + print('Error parsing deviceSettings.json: $e'); + // If parsing fails, create default settings + currentDeviceSettings = await updateCurrentDevice(DeviceSettings("")); + await saveDeviceSettings(currentDeviceSettings); + } + } catch (e) { + print('Error loading device settings: $e'); + // Final fallback - create default settings + currentDeviceSettings = DeviceSettings(""); + currentDeviceSettings.id = await getPersistentDeviceId(); } - final jsonAsString = jsonFile.readAsStringSync(); - currentDeviceSettings = DeviceSettings.fromJson(jsonDecode(jsonAsString)); } Future saveDeviceSettings(DeviceSettings deviceSettings) async { - Directory appDocDir = await getApplicationDocumentsDirectory(); - File jsonFile = File("${appDocDir.path}/$dataFilename"); - jsonFile.writeAsStringSync(jsonEncode(deviceSettings.toJson())); + try { + Directory appDocDir = await getApplicationDocumentsDirectory(); + File jsonFile = File("${appDocDir.path}/$dataFilename"); + + // Ensure the data is valid before saving + final jsonData = deviceSettings.toJson(); + jsonData['mediaDirectories'] ??= []; + + jsonFile.writeAsStringSync(jsonEncode(jsonData)); + } catch (e) { + print('Error saving device settings: $e'); + } } Future deleteDeviceSettings() async { - Directory appDocDir = await getApplicationDocumentsDirectory(); - File jsonFile = File("${appDocDir.path}/$dataFilename"); - jsonFile.deleteSync(); - await loadDeviceSettings(); + try { + Directory appDocDir = await getApplicationDocumentsDirectory(); + File jsonFile = File("${appDocDir.path}/$dataFilename"); + + if (await jsonFile.exists()) { + await jsonFile.delete(); + } + + // Create fresh default settings + currentDeviceSettings = DeviceSettings(""); + currentDeviceSettings.id = await getPersistentDeviceId(); + await saveDeviceSettings(currentDeviceSettings); + } catch (e) { + print('Error deleting device settings: $e'); + // Fallback to default settings + currentDeviceSettings = DeviceSettings(""); + currentDeviceSettings.id = await getPersistentDeviceId(); + } +} + +// Optional: Add a migration function +Future migrateDeviceSettings() async { + try { + Directory appDocDir = await getApplicationDocumentsDirectory(); + File jsonFile = File("${appDocDir.path}/$dataFilename"); + + if (await jsonFile.exists()) { + final jsonAsString = jsonFile.readAsStringSync(); + if (jsonAsString.isNotEmpty) { + final jsonData = jsonDecode(jsonAsString); + + bool needsUpdate = false; + + // Fix missing or null mediaDirectories + if (jsonData['mediaDirectories'] == null || + jsonData['mediaDirectories'] is! List) { + jsonData['mediaDirectories'] = []; + needsUpdate = true; + } + + // Add other migrations as needed + + if (needsUpdate) { + await jsonFile.writeAsString(jsonEncode(jsonData)); + } + } + } + } catch (e) { + print('Error migrating device settings: $e'); + } } diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 67ba57d..4485dcb 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -7,7 +7,7 @@ project(runner LANGUAGES CXX) set(BINARY_NAME "sync_client") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "eu.mobisync.sync_client") +set(APPLICATION_ID "eu.mobisync.home") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..d96f6c4 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,22 @@ #include "generated_plugin_registrant.h" +#include +#include +#include +#include void fl_register_plugins(FlPluginRegistry* registry) { + 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) media_kit_libs_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); + media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); + g_autoptr(FlPluginRegistrar) media_kit_video_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin"); + media_kit_video_plugin_register_with_registrar(media_kit_video_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); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..c3954b3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,10 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux + media_kit_libs_linux + media_kit_video + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/linux/my_application.cc b/linux/my_application.cc index ab6af89..ce0c0ca 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -40,11 +40,11 @@ static void my_application_activate(GApplication* application) { 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, "sync_client"); + gtk_header_bar_set_title(header_bar, "SpaceItMobiSync"); 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, "sync_client"); + gtk_window_set_title(window, "SpaceItMobiSync"); } gtk_window_set_default_size(window, 1280, 720); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d484543..38fbe1b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,10 +5,28 @@ import FlutterMacOS import Foundation +import device_info_plus import file_picker -import path_provider_foundation +import flutter_secure_storage_macos +import media_kit_libs_macos_video +import media_kit_video +import package_info_plus +import share_plus +import shared_preferences_foundation +import sqflite_darwin +import url_launcher_macos +import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) + MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) } diff --git a/macos/Podfile b/macos/Podfile index 2a052c3..4793771 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 3b2a14b..f23edac 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,29 +1,79 @@ PODS: + - device_info_plus (0.0.1): + - FlutterMacOS - file_picker (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) + - media_kit_libs_macos_video (1.0.4): + - FlutterMacOS + - media_kit_video (0.0.1): + - FlutterMacOS + - package_info_plus (0.0.1): + - FlutterMacOS - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - share_plus (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - wakelock_plus (0.0.1): + - FlutterMacOS DEPENDENCIES: + - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - FlutterMacOS (from `Flutter/ephemeral`) + - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`) + - media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) + - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) EXTERNAL SOURCES: + device_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos file_picker: :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos FlutterMacOS: :path: Flutter/ephemeral + media_kit_libs_macos_video: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos + media_kit_video: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + share_plus: + :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin + wakelock_plus: + :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos SPEC CHECKSUMS: + device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215 file_picker: e716a70a9fe5fd9e09ebc922d7541464289443af - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + media_kit_libs_macos_video: b3e2bbec2eef97c285f2b1baa7963c67c753fb82 + media_kit_video: c75b07f14d59706c775778e4dd47dd027de8d1e5 + package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + share_plus: 1fa619de8392a4398bfaf176d441853922614e89 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + wakelock_plus: 9d63063ffb7af1c215209769067c57103bde719d -PODFILE CHECKSUM: e6941affa76272697b93766f7a7df72c8e5d2cb8 +PODFILE CHECKSUM: 5bc6a71ad752a68d2b4bce2aeb96d0f7aab52719 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 1226427..3e7a773 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -477,7 +477,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.client.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.home.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/sync_client.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/sync_client"; @@ -492,7 +492,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.client.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.home.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/sync_client.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/sync_client"; @@ -507,7 +507,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.client.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.home.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/sync_client.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/sync_client"; @@ -552,7 +552,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -568,13 +568,14 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = R25XLT6Z87; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Runner/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Mobi Sync Client"; + INFOPLIST_KEY_CFBundleDisplayName = "SpaceItMobiSync"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -639,7 +640,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -686,7 +687,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -702,13 +703,14 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = R25XLT6Z87; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Runner/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Mobi Sync Client"; + INFOPLIST_KEY_CFBundleDisplayName = "SpaceItMobiSync"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -736,7 +738,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = R25XLT6Z87; INFOPLIST_FILE = Runner/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Mobi Sync Client"; + INFOPLIST_KEY_CFBundleDisplayName = "SpaceItMobiSync"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index dd9c93b..ec02519 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -8,7 +8,7 @@ PRODUCT_NAME = sync_client // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.client +PRODUCT_BUNDLE_IDENTIFIER = eu.mobisync.spaceit // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2023 eu.mobisync. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2023 spaceit.mobisync.eu. All rights reserved. diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index ff96473..ec0c067 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -2,11 +2,34 @@ - com.apple.security.app-sandbox - - com.apple.security.cs.allow-jit - - com.apple.security.network.client - + com.apple.security.app-sandbox + + + com.apple.security.files.user-selected.read-write + + + + com.apple.security.assets.pictures.read-write + + + + com.apple.security.assets.movies.read-write + + + + com.apple.security.assets.music.read-write + + + + com.apple.security.files.downloads.read-write + + + + com.apple.security.files.removable-volume.read-write + + com.apple.security.network.client + + com.apple.security.network.server + - + \ No newline at end of file diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 9e802f4..bb8a492 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -4,33 +4,78 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + SpaceItMobiSync CFBundleExecutable $(EXECUTABLE_NAME) - CFBundleIconFile - CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName - $(PRODUCT_NAME) + SpaceItMobiSync CFBundlePackageType APPL CFBundleShortVersionString $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) - LSApplicationCategoryType - public.app-category.photography - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication + LSRequiresIPhoneOS + + + + NSLocalNetworkUsageDescription + This app needs local network access for debugging purposes during development + + NSBonjourServices + + _dartVmService._tcp + + + + NSPhotoLibraryUsageDescription + This app needs access to photo library to sync your photos + + NSPhotoLibraryAddUsageDescription + This app needs access to save photos to your library + + NSDocumentsFolderUsageDescription + This app needs access to documents folder for file synchronization + + + NSCameraUsageDescription + This app needs camera access to take photos + + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + UIRequiresFullScreen + + + + UIMainStoryboardFile + Main UILaunchStoryboardName - MainMenu.xib + LaunchScreen + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + - + \ No newline at end of file diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index ee95ab7..b70d19a 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -2,9 +2,34 @@ - com.apple.security.app-sandbox - - com.apple.security.network.client - + com.apple.security.app-sandbox + + + com.apple.security.files.user-selected.read-write + + + + com.apple.security.assets.pictures.read-write + + + + com.apple.security.assets.movies.read-write + + + + com.apple.security.assets.music.read-write + + + + com.apple.security.files.downloads.read-write + + + + com.apple.security.files.removable-volume.read-write + + com.apple.security.network.client + + com.apple.security.network.server + - + \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 4a001c7..504e796 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: sync_client description: Mobile and Desktop device application for automatically uploading image, video and audio files publish_to: 'none' -version: 1.0.9 +version: 1.0.10 environment: sdk: '>=3.2.0 <5.0.0' @@ -25,15 +25,27 @@ dependencies: go_router: ^16.0.0 intl: ^0.20.2 json_annotation: ^4.9.0 - path_provider: ^2.1.4 popup_menu: ^2.0.0 uuid: ^4.4.2 media_store_plus: ^0.1.2 permission_handler: ^12.0.1 + shared_preferences: ^2.2.2 + path_provider: ^2.1.5 + visibility_detector: ^0.4.0+2 + photo_view: ^0.15.0 + share_plus: ^11.0.0 + cached_network_image: ^3.3.1 + media_kit: ^1.2.6 + media_kit_video: ^2.0.1 + media_kit_libs_video: ^1.0.7 + device_info_plus: ^11.1.0 + saver_gallery: ^3.0.6 + flutter_secure_storage: ^9.2.2 + url_launcher: ^6.2.0 dev_dependencies: - integration_test: - sdk: flutter + #integration_test: + #sdk: flutter flutter_test: sdk: flutter diff --git a/release/app-release.apk b/release/app-release.apk new file mode 100644 index 0000000..3efd358 Binary files /dev/null and b/release/app-release.apk differ diff --git a/test_driver/integration_test.dart b/test_driver/integration_test.dart deleted file mode 100644 index b38629c..0000000 --- a/test_driver/integration_test.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:integration_test/integration_test_driver.dart'; - -Future main() => integrationDriver(); diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..16e7380 --- /dev/null +++ b/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + SpaceItMobiSync + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..41acebf --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "SpaceItMobiSync", + "short_name": "SpaceItMobiSync", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "SpaceItMobiSync - spaceit.mobisync.eu", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 74f5e4f..cd77bd8 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -10,6 +10,12 @@ set(BINARY_NAME "sync_client") # versions of CMake. cmake_policy(VERSION 3.14...3.25) +# Suppress CMP0175 warning from plugins (e.g. media_kit_libs_windows_video) that use +# add_custom_command(TARGET) without explicit PRE_BUILD/PRE_LINK/POST_BUILD. +if(POLICY CMP0175) + cmake_policy(SET CMP0175 OLD) +endif() + # Define build configuration option. get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) if(IS_MULTICONFIG) @@ -55,6 +61,12 @@ add_subdirectory("runner") # Generated plugin build rules, which manage building the plugins and adding # them to the application. +# Set CMP0175 OLD immediately before loading plugins so media_kit_libs_windows_video +# (and similar) do not warn about add_custom_command() missing PRE_BUILD/PRE_LINK/POST_BUILD. +# CMP0175 was introduced in CMake 3.31. +if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.31") + cmake_policy(SET CMP0175 OLD) +endif() include(flutter/generated_plugins.cmake) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 48de52b..d6389b2 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,24 @@ #include "generated_plugin_registrant.h" +#include +#include +#include #include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); + MediaKitVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 0e69e40..bc24391 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,7 +3,12 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_windows + media_kit_libs_windows_video + media_kit_video permission_handler_windows + share_plus + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 1dedc4c..4a2c869 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -89,13 +89,13 @@ BEGIN BEGIN BLOCK "040904e4" BEGIN - VALUE "CompanyName", "eu.mobisync" "\0" - VALUE "FileDescription", "sync_client" "\0" + VALUE "CompanyName", "spaceit.mobisync.eu" "\0" + VALUE "FileDescription", "SpaceItMobiSync" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "sync_client" "\0" - VALUE "LegalCopyright", "Copyright (C) 2023 eu.mobisync. All rights reserved." "\0" + VALUE "InternalName", "SpaceItMobiSync" "\0" + VALUE "LegalCopyright", "Copyright (C) 2023 spaceit.mobisync.eu. All rights reserved." "\0" VALUE "OriginalFilename", "sync_client.exe" "\0" - VALUE "ProductName", "sync_client" "\0" + VALUE "ProductName", "SpaceItMobiSync" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index c92ff49..bdb0450 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); - if (!window.Create(L"sync_client", origin, size)) { + if (!window.Create(L"SpaceItMobiSync", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true);