diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 120000 index 0000000..ea061fe --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1 @@ +../tools/content/agent.json \ No newline at end of file diff --git a/.cursorrules b/.cursorrules new file mode 120000 index 0000000..e8fe021 --- /dev/null +++ b/.cursorrules @@ -0,0 +1 @@ +tools/content/CONTEXT.md \ No newline at end of file diff --git a/.gitignore b/.gitignore index 70c8ed9..b8706f1 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,4 @@ app.*.map.json /flutter_data .fvm/ -android/app/.cxx/ - .env \ No newline at end of file diff --git a/.metadata b/.metadata index 24a13c1..fdb4416 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "bae5e49bc2a867403c43b2aae2de8f8c33b037e4" + revision: "fcf2c11572af6f390246c056bc905eca609533a0" channel: "stable" project_type: app @@ -13,26 +13,26 @@ project_type: app migration: platforms: - platform: root - create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4 - base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4 + create_revision: fcf2c11572af6f390246c056bc905eca609533a0 + base_revision: fcf2c11572af6f390246c056bc905eca609533a0 - platform: android - create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4 - base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4 + create_revision: fcf2c11572af6f390246c056bc905eca609533a0 + base_revision: fcf2c11572af6f390246c056bc905eca609533a0 - platform: ios - create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4 - base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4 + create_revision: fcf2c11572af6f390246c056bc905eca609533a0 + base_revision: fcf2c11572af6f390246c056bc905eca609533a0 - platform: linux - create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4 - base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4 + create_revision: fcf2c11572af6f390246c056bc905eca609533a0 + base_revision: fcf2c11572af6f390246c056bc905eca609533a0 - platform: macos - create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4 - base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4 + create_revision: fcf2c11572af6f390246c056bc905eca609533a0 + base_revision: fcf2c11572af6f390246c056bc905eca609533a0 - platform: web - create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4 - base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4 + create_revision: fcf2c11572af6f390246c056bc905eca609533a0 + base_revision: fcf2c11572af6f390246c056bc905eca609533a0 - platform: windows - create_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4 - base_revision: bae5e49bc2a867403c43b2aae2de8f8c33b037e4 + create_revision: fcf2c11572af6f390246c056bc905eca609533a0 + base_revision: fcf2c11572af6f390246c056bc905eca609533a0 # User provided section diff --git a/.roo/mcp.json b/.roo/mcp.json new file mode 120000 index 0000000..46ffb31 --- /dev/null +++ b/.roo/mcp.json @@ -0,0 +1 @@ +../.cursor/mcp.json \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md index e9fbbc8..cecad56 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Zapstore +Copyright (c) 2025 Zapstore Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 1781ef8..a2808be 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# zapstore +# Zapstore + +The open app store powered by your social network + +[https://zapstore.dev](https://zapstore.dev) ## Build from source diff --git a/analysis_options.yaml b/analysis_options.yaml index 0cf006c..5da7251 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,8 +9,7 @@ # packages, and plugins designed to encourage good coding practices. analyzer: errors: - no_leading_underscores_for_local_identifiers: ignore - must_be_immutable: ignore + dangling_library_doc_comments: ignore include: package:flutter_lints/flutter.yaml linter: @@ -27,12 +26,5 @@ linter: rules: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - prefer_const_constructors: false - prefer_const_constructors_in_immutables: false - avoid_print: false - prefer_const_literals_to_create_immutables: false - no_wildcard_variable_uses: false - omit_local_variable_types: true - # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore index 6f56801..be3943c 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -5,9 +5,10 @@ gradle-wrapper.jar /gradlew.bat /local.properties GeneratedPluginRegistrant.java +.cxx/ # Remember to never publicly share your keystore. -# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +# See https://flutter.dev/to/reference-keystore key.properties **/*.keystore **/*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle deleted file mode 100644 index a59bcea..0000000 --- a/android/app/build.gradle +++ /dev/null @@ -1,81 +0,0 @@ -plugins { - id "com.android.application" - id "kotlin-android" - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id "dev.flutter.flutter-gradle-plugin" -} - -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -def keystoreProperties = new Properties() -def keystorePropertiesFile = rootProject.file('key.properties') -if (keystorePropertiesFile.exists()) { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) -} - -android { - namespace "dev.zapstore.app" - compileSdk flutter.compileSdkVersion - ndkVersion flutter.ndkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8 - } - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - - defaultConfig { - applicationId "dev.zapstore.app" - minSdkVersion 21 - targetSdkVersion flutter.targetSdkVersion - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - } - - signingConfigs { - release { - keyAlias keystoreProperties['keyAlias'] - keyPassword keystoreProperties['keyPassword'] - storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null - storePassword keystoreProperties['storePassword'] - } - } - - buildTypes { - release { - signingConfig signingConfigs.debug - signingConfig signingConfigs.release - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - } - } -} - -flutter { - source '../..' -} - -dependencies {} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..e52c683 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,102 @@ +import java.util.Properties +import java.io.FileInputStream + +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +// Load signing properties from key.properties (not checked into VCS) +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + +android { + namespace = "dev.zapstore.alpha" + compileSdk = 36 + ndkVersion = "27.0.12077973" + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "dev.zapstore.alpha" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = 29 + targetSdk = 36 + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + signingConfigs { + create("release") { + val alias = keystoreProperties["keyAlias"] as String? + val keyPass = keystoreProperties["keyPassword"] as String? + val storePath = keystoreProperties["storeFile"] as String? + val storePass = keystoreProperties["storePassword"] as String? + + if (!storePath.isNullOrBlank()) { + storeFile = file(storePath) + } + if (!alias.isNullOrBlank()) { + keyAlias = alias + } + if (!keyPass.isNullOrBlank()) { + keyPassword = keyPass + } + if (!storePass.isNullOrBlank()) { + storePassword = storePass + } + } + } + + buildTypes { + release { + // Use release signing config when key.properties is present + signingConfig = signingConfigs.getByName("release") + // Enable code shrinking and resource optimization + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + dependenciesInfo { + // Disables dependency metadata when building APKs (for IzzyOnDroid/F-Droid) + includeInApk = false + // Disables dependency metadata when building Android App Bundles (for Google Play) + includeInBundle = false + } + + packagingOptions { + jniLibs { + // Compress native .so files inside the APK to reduce artifact size + useLegacyPackaging = true + } + } +} + +flutter { + source = "../.." +} + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") + implementation("androidx.lifecycle:lifecycle-process:2.7.0") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index a2ef6bc..65e5fdd 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -1,6 +1,36 @@ --dontwarn com.google.errorprone.annotations.CanIgnoreReturnValue --dontwarn com.google.errorprone.annotations.CheckReturnValue --dontwarn com.google.errorprone.annotations.Immutable --dontwarn com.google.errorprone.annotations.RestrictedApi --dontwarn javax.annotation.Nullable --dontwarn javax.annotation.concurrent.GuardedBy +# Flutter wrapper +-keep class io.flutter.app.** { *; } +-keep class io.flutter.plugin.** { *; } +-keep class io.flutter.util.** { *; } +-keep class io.flutter.view.** { *; } +-keep class io.flutter.** { *; } +-keep class io.flutter.plugins.** { *; } + +# Keep native methods +-keepclasseswithmembernames class * { + native ; +} + +# Keep Kotlin metadata +-keep class kotlin.Metadata { *; } +-dontwarn kotlin.** + +# Keep SQLite3 native libraries +-keep class org.sqlite.** { *; } + +# Keep package manager plugin classes +-keep class dev.zapstore.alpha.plugins.** { *; } + +# Ignore missing Google Play Core classes (optional dependency for dynamic features) +-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication +-dontwarn com.google.android.play.core.splitinstall.SplitInstallException +-dontwarn com.google.android.play.core.splitinstall.SplitInstallManager +-dontwarn com.google.android.play.core.splitinstall.SplitInstallManagerFactory +-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest$Builder +-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest +-dontwarn com.google.android.play.core.splitinstall.SplitInstallSessionState +-dontwarn com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener +-dontwarn com.google.android.play.core.tasks.OnFailureListener +-dontwarn com.google.android.play.core.tasks.OnSuccessListener +-dontwarn com.google.android.play.core.tasks.Task + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0e182e6..b1e2ba0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,24 +1,34 @@ + tools:ignore="QueryAllPackagesPermission"> + + + + + + + + + + + + + + - - - - - - + + + + + + + + + + - - - + + - - - - - - - + diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png deleted file mode 100644 index 1fb4afc..0000000 Binary files a/android/app/src/main/ic_launcher-playstore.png and /dev/null differ diff --git a/android/app/src/main/kotlin/dev/zapstore/alpha/MainActivity.kt b/android/app/src/main/kotlin/dev/zapstore/alpha/MainActivity.kt new file mode 100644 index 0000000..86750bf --- /dev/null +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/MainActivity.kt @@ -0,0 +1,16 @@ +package dev.zapstore.alpha + +import dev.zapstore.alpha.plugins.AndroidPackageManagerPlugin +import dev.zapstore.alpha.plugins.AppRestartPlugin +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine + +class MainActivity: FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + // Register plugins + flutterEngine.plugins.add(AndroidPackageManagerPlugin()) + flutterEngine.plugins.add(AppRestartPlugin()) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt new file mode 100644 index 0000000..8196927 --- /dev/null +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt @@ -0,0 +1,922 @@ +package dev.zapstore.alpha.plugins + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.UserManager +import android.provider.Settings +import android.system.Os +import android.util.Log +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import java.io.File +import java.io.FileInputStream +import java.security.MessageDigest + +private const val TAG = "AndroidPackageManager" + +/** Uninstall polling timeout - 30 seconds is plenty for user to confirm/cancel */ +private const val UNINSTALL_TIMEOUT_MS = 30_000L +private const val UNINSTALL_POLL_INTERVAL_MS = 500L + +/** Watchdog timeouts (bounded with backoff to avoid false negatives on slow devices) */ +private const val VERIFY_WATCHDOG_MS = 10_000L +private const val INSTALL_WATCHDOG_MS = 10_000L +private const val MAX_INSTALL_WATCHDOG_MS = 120_000L + +/** + * Install status values emitted via EventChannel. + * These form a simple state machine with no hanging states. + */ +object InstallStatus { + const val STARTED = "started" + const val VERIFYING = "verifying" + const val PENDING_USER_ACTION = "pendingUserAction" + const val ALREADY_IN_PROGRESS = "alreadyInProgress" + const val SUCCESS = "success" + const val FAILED = "failed" + const val CANCELLED = "cancelled" +} + +/** + * Structured error codes for failures. + * Dart uses these for reliable error categorization instead of parsing strings. + */ +object ErrorCode { + const val DOWNLOAD_FAILED = "downloadFailed" + const val HASH_MISMATCH = "hashMismatch" + const val INVALID_FILE = "invalidFile" + const val INSTALL_FAILED = "installFailed" + const val CERT_MISMATCH = "certMismatch" + const val PERMISSION_DENIED = "permissionDenied" + const val INSUFFICIENT_STORAGE = "insufficientStorage" + const val INCOMPATIBLE = "incompatible" + const val BLOCKED = "blocked" + const val ALREADY_IN_PROGRESS = "alreadyInProgress" + const val INSTALL_TIMEOUT = "installTimeout" +} + +/** + * AndroidPackageManagerPlugin - Event-driven architecture for clean state management. + * + * Architecture: + * - MethodChannel: Fire-and-forget commands (install, uninstall, etc.) + * - EventChannel: Streams install status events to Dart + * - ProcessLifecycleOwner: Auto-detects app foreground state (no Dart involvement) + * + * The native side is the single source of truth for install state. + * Dart simply reacts to events - no polling, no probing, no hanging awaits. + */ +class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, + EventChannel.StreamHandler, DefaultLifecycleObserver { + + private lateinit var methodChannel: MethodChannel + private lateinit var eventChannel: EventChannel + private lateinit var context: Context + private val mainHandler = Handler(Looper.getMainLooper()) + + /** Per-app watchdog generation counters (cancels old scheduled callbacks) */ + private val watchdogGen = mutableMapOf() + private val watchdogDeadlineMs = mutableMapOf() + + /** Track verification threads so we can "ping" and avoid false timeouts */ + private val verificationThreads = mutableMapOf() + + private var eventSink: EventChannel.EventSink? = null + + companion object { + private var instance: AndroidPackageManagerPlugin? = null + + /** Map sessionId to packageName for reverse lookup in broadcasts */ + private val sessionToPackage = mutableMapOf() + + /** Pending user action intents - stored for re-launch when app returns to foreground */ + private val pendingUserActionIntents = mutableMapOf() + + /** Buffer last-known event per appId when Dart isn't listening yet */ + private val bufferedEvents = mutableMapOf>() + + /** App foreground state - auto-managed by ProcessLifecycleOwner */ + private var isAppInForeground = true + + private var appContext: Context? = null + + /** + * Called by InstallResultReceiver when a broadcast arrives. + * Emits the status event to Dart via EventChannel. + */ + fun onInstallResult( + sessionId: Int, + status: String, + packageName: String, + message: String? = null, + isUpdate: Boolean = false, + confirmIntent: Intent? = null, + errorCode: String? = null + ) { + val pkg = packageName.ifEmpty { sessionToPackage[sessionId] ?: "unknown" } + + Log.d(TAG, "onInstallResult: pkg=$pkg, status=$status, msg=$message, errorCode=$errorCode, sessionId=$sessionId") + + // Handle pending user action - store intent for potential re-launch + if (status == InstallStatus.PENDING_USER_ACTION && confirmIntent != null) { + pendingUserActionIntents[pkg] = confirmIntent + + // Auto-launch dialog if app is in foreground + if (isAppInForeground) { + launchConfirmDialog(pkg, confirmIntent) + } + } + + // Clean up tracking on terminal states + if (status in listOf(InstallStatus.SUCCESS, InstallStatus.FAILED, InstallStatus.CANCELLED)) { + sessionToPackage.remove(sessionId) + pendingUserActionIntents.remove(pkg) + + if (status != InstallStatus.SUCCESS) { + abandonSession(sessionId) + } + } + + val inst = instance + if (inst == null) { + Log.e(TAG, "CRITICAL: Cannot emit event - plugin instance is null! pkg=$pkg, status=$status") + bufferEvent(pkg, status, message, errorCode) + return + } + inst.emitInstallStatus(pkg, status, message, errorCode) + } + + private fun bufferEvent( + appId: String, + status: String, + message: String? = null, + errorCode: String? = null + ) { + val event = mutableMapOf( + "appId" to appId, + "status" to status + ) + if (message != null) event["message"] = message + if (errorCode != null) event["errorCode"] = errorCode + bufferedEvents[appId] = event + } + + private fun launchConfirmDialog(packageName: String, intent: Intent) { + val ctx = appContext ?: return + try { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + ctx.startActivity(intent) + Log.d(TAG, "Launched confirmation dialog for $packageName") + } catch (e: Exception) { + Log.w(TAG, "Failed to launch confirmation dialog for $packageName", e) + } + } + + private fun abandonSession(sessionId: Int) { + val ctx = appContext ?: return + try { + ctx.packageManager.packageInstaller.abandonSession(sessionId) + } catch (e: Exception) { + Log.d(TAG, "Could not abandon session $sessionId: ${e.message}") + } + } + + fun isAppInForeground(): Boolean = isAppInForeground + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // FLUTTER PLUGIN LIFECYCLE + // ═══════════════════════════════════════════════════════════════════════════════ + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + appContext = context + instance = this + + methodChannel = MethodChannel(binding.binaryMessenger, "android_package_manager") + methodChannel.setMethodCallHandler(this) + + eventChannel = EventChannel(binding.binaryMessenger, "android_package_manager/events") + eventChannel.setStreamHandler(this) + + // Auto-detect foreground state via ProcessLifecycleOwner + mainHandler.post { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } + + cleanupStaleSessions() + Log.d(TAG, "AndroidPackageManagerPlugin initialized") + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + mainHandler.post { + ProcessLifecycleOwner.get().lifecycle.removeObserver(this) + } + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + eventSink = null + instance = null + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // LIFECYCLE OBSERVER (Auto foreground detection) + // ═══════════════════════════════════════════════════════════════════════════════ + + override fun onStart(owner: LifecycleOwner) { + // App came to foreground + val wasForeground = isAppInForeground + isAppInForeground = true + Log.d(TAG, "App foregrounded") + + if (!wasForeground) { + // Re-launch any pending install dialogs + for ((packageName, intent) in pendingUserActionIntents.toMap()) { + Log.d(TAG, "Re-launching pending dialog for $packageName") + launchConfirmDialog(packageName, intent) + } + } + } + + override fun onStop(owner: LifecycleOwner) { + // App went to background + isAppInForeground = false + Log.d(TAG, "App backgrounded") + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // EVENT CHANNEL STREAM HANDLER + // ═══════════════════════════════════════════════════════════════════════════════ + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + // Flush buffered events (if any) so Dart can reconcile state. + val sink = eventSink ?: return + mainHandler.post { + if (bufferedEvents.isNotEmpty()) { + for (event in bufferedEvents.values) { + try { + sink.success(event) + } catch (e: Exception) { + Log.w(TAG, "Failed to flush buffered event: $event", e) + } + } + bufferedEvents.clear() + } + } + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + private fun emitInstallStatus( + appId: String, + status: String, + message: String? = null, + errorCode: String? = null, + description: String? = null + ) { + val emitNow = emit@{ + // Update watchdog regardless of whether Dart is listening. + updateWatchdog(appId, status) + + val sink = eventSink + if (sink == null) { + Log.e(TAG, "CRITICAL: Cannot emit event - eventSink is null! appId=$appId, status=$status, msg=$message") + bufferEvent(appId, status, message, errorCode) + return@emit + } + + val event = mutableMapOf( + "appId" to appId, + "status" to status + ) + if (message != null) { + event["message"] = message + } + if (errorCode != null) { + event["errorCode"] = errorCode + } + if (description != null) { + event["description"] = description + } + Log.d(TAG, "Emitting to Dart: $event") + sink.success(event) + } + + // If we're already on the main thread (common for MethodChannel calls), + // emit synchronously so the UI can transition immediately. + if (Looper.myLooper() == Looper.getMainLooper()) { + emitNow() + } else { + mainHandler.post { emitNow() } + } + } + + private fun bumpWatchdogGen(appId: String): Int { + val next = (watchdogGen[appId] ?: 0) + 1 + watchdogGen[appId] = next + return next + } + + private fun clearWatchdog(appId: String) { + bumpWatchdogGen(appId) + watchdogDeadlineMs.remove(appId) + } + + private fun scheduleWatchdog(appId: String, initialDelayMs: Long) { + val gen = bumpWatchdogGen(appId) + val now = System.currentTimeMillis() + val deadline = watchdogDeadlineMs[appId] ?: (now + MAX_INSTALL_WATCHDOG_MS).also { + watchdogDeadlineMs[appId] = it + } + + mainHandler.postDelayed({ + if (watchdogGen[appId] != gen) return@postDelayed + + // "Ping" reality before timing out. + val hasSession = hasActiveSession(appId) + val hasPendingUi = pendingUserActionIntents.containsKey(appId) + val verifyThread = verificationThreads[appId] + val verifyingAlive = verifyThread?.isAlive == true + + // If work is still happening, extend (bounded by deadline). + val now2 = System.currentTimeMillis() + if (now2 < deadline && (hasSession || hasPendingUi || verifyingAlive)) { + // Nudge Dart to show the most accurate state (esp. after reconnect). + if (hasPendingUi) { + emitInstallStatus(appId, InstallStatus.PENDING_USER_ACTION, "User confirmation pending") + } else if (hasSession) { + emitInstallStatus(appId, InstallStatus.STARTED) + } else if (verifyingAlive) { + emitInstallStatus(appId, InstallStatus.VERIFYING) + } + // Backoff: next check is another INSTALL_WATCHDOG_MS. + scheduleWatchdog(appId, INSTALL_WATCHDOG_MS) + return@postDelayed + } + + // Deadline exceeded or no evidence of progress: fail fast and cleanup. + Log.w(TAG, "Watchdog timeout for $appId (hasSession=$hasSession, pendingUi=$hasPendingUi, verifyingAlive=$verifyingAlive)") + abandonExistingSession(appId) + emitInstallStatus( + appId, + InstallStatus.FAILED, + "Installation timed out. Please retry.", + ErrorCode.INSTALL_TIMEOUT + ) + clearWatchdog(appId) + }, initialDelayMs) + } + + private fun updateWatchdog(appId: String, status: String) { + when (status) { + InstallStatus.VERIFYING -> { + // Start watchdog for verify stage + scheduleWatchdog(appId, VERIFY_WATCHDOG_MS) + } + InstallStatus.STARTED, InstallStatus.PENDING_USER_ACTION -> { + // Start/refresh watchdog for install stage + scheduleWatchdog(appId, INSTALL_WATCHDOG_MS) + } + InstallStatus.SUCCESS, InstallStatus.FAILED, InstallStatus.CANCELLED -> { + clearWatchdog(appId) + verificationThreads.remove(appId) + } + } + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // METHOD CHANNEL HANDLER + // ═══════════════════════════════════════════════════════════════════════════════ + + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + "install" -> { + val filePath = call.argument("filePath") + val packageName = call.argument("packageName") + val expectedHash = call.argument("expectedHash") + val expectedSize = call.argument("expectedSize")?.toLong() + + if (filePath == null || packageName == null) { + result.error("MISSING_ARGUMENT", "filePath and packageName required", null) + return + } + installApk(filePath, packageName, expectedHash, expectedSize, result) + } + + "canInstallSilently" -> { + val packageName = call.argument("packageName") + result.success(canInstallSilently(packageName)) + } + + "hasUnknownSourcesPermission" -> result.success(hasUnknownSourcesPermission()) + + "requestInstallPermission" -> requestInstallPermission(result) + + "getInstalledApps" -> { + val includeSystem = call.argument("includeSystemApps") ?: false + result.success(getInstalledApps(includeSystem)) + } + + "uninstall" -> { + val packageName = call.argument("packageName") + if (packageName == null) { + result.error("MISSING_ARGUMENT", "packageName required", null) + return + } + uninstallApp(packageName, result) + } + + "launchApp" -> { + val packageName = call.argument("packageName") + if (packageName == null) { + result.error("MISSING_ARGUMENT", "packageName required", null) + return + } + launchApp(packageName, result) + } + + else -> result.notImplemented() + } + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // INSTALLATION + // ═══════════════════════════════════════════════════════════════════════════════ + + private fun installApk( + filePath: String, + packageName: String, + expectedHash: String?, + expectedSize: Long?, + result: Result + ) { + val file = File(filePath) + if (!file.exists()) { + emitInstallStatus(packageName, InstallStatus.FAILED, "APK file not found", ErrorCode.DOWNLOAD_FAILED) + result.success(mapOf("started" to false, "error" to "APK file not found", "errorCode" to ErrorCode.DOWNLOAD_FAILED)) + return + } + + if (hasActiveSession(packageName)) { + if (pendingUserActionIntents.containsKey(packageName)) { + // Real pending dialog - re-launch it and let user complete + pendingUserActionIntents[packageName]?.let { launchConfirmDialog(packageName, it) } + emitInstallStatus(packageName, InstallStatus.PENDING_USER_ACTION, "User confirmation pending") + result.success(mapOf("started" to false, "alreadyInProgress" to true)) + return + } + // No pending dialog = stale session. Abandon and proceed with fresh install. + Log.d(TAG, "Abandoning stale session for $packageName") + abandonExistingSession(packageName) + } + + if (expectedHash != null && expectedSize != null) { + // Emit verifying status before starting hash check + emitInstallStatus(packageName, InstallStatus.VERIFYING) + result.success(mapOf("started" to true, "verifying" to true)) + + val t = Thread { + val verification = verifyApk(file, expectedHash, expectedSize) + mainHandler.post { + if (verification.isSuccess) { + startInstallSession(file, packageName, result = null) // Result already sent + } else { + emitInstallStatus( + packageName, + InstallStatus.FAILED, + verification.errorTitle, + verification.errorCode, + verification.errorDescription + ) + } + verificationThreads.remove(packageName) + } + } + verificationThreads[packageName] = t + t.start() + } else { + startInstallSession(file, packageName, result) + } + } + + private fun startInstallSession(apkFile: File, packageName: String, result: Result?) { + try { + val userManager = context.getSystemService(UserManager::class.java) + val installBlocked = userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_APPS) || + userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES) || + userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY) + + if (installBlocked) { + emitInstallStatus(packageName, InstallStatus.FAILED, "Installation blocked by device policy", ErrorCode.BLOCKED) + result?.success(mapOf("started" to false, "error" to "Blocked by policy", "errorCode" to ErrorCode.BLOCKED)) + return + } + + val packageInstaller = context.packageManager.packageInstaller + val pkgInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0) + if (pkgInfo == null) { + emitInstallStatus(packageName, InstallStatus.FAILED, "Invalid APK file", ErrorCode.INVALID_FILE) + result?.success(mapOf("started" to false, "error" to "Invalid APK", "errorCode" to ErrorCode.INVALID_FILE)) + return + } + + // CRITICAL: Use the passed packageName for tracking, not the APK's internal package name + // This ensures Dart's operation key matches the events we emit + // The APK's packageName should match, but we log a warning if not + val trackingPackageName = packageName + if (pkgInfo.packageName != packageName) { + Log.w(TAG, "Package name mismatch: tracking='$packageName', APK contains='${pkgInfo.packageName}'. Using tracking name for events.") + } + + val isUpdate = try { + context.packageManager.getPackageInfo(pkgInfo.packageName, 0) + true + } catch (_: PackageManager.NameNotFoundException) { false } + + abandonExistingSession(trackingPackageName) + + val sessionParams = PackageInstaller.SessionParams( + PackageInstaller.SessionParams.MODE_FULL_INSTALL + ).apply { + // Set app package name for proper installer tracking + setAppPackageName(pkgInfo.packageName) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) + // Explicitly set ourselves as the installer for update ownership + setInstallerPackageName(context.packageName) + } + setInstallLocation(pkgInfo.installLocation) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + setPackageSource(PackageInstaller.PACKAGE_SOURCE_STORE) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + setRequestUpdateOwnership(true) + } + } + + val sessionId = packageInstaller.createSession(sessionParams) + val session = packageInstaller.openSession(sessionId) + // Use tracking name so events match Dart's operation key + sessionToPackage[sessionId] = trackingPackageName + + FileInputStream(apkFile).use { fis -> + val fileSize = Os.fstat(fis.fd).st_size + val sessionStream = session.openWrite(apkFile.name, 0, fileSize) + fis.copyTo(sessionStream) + fis.close() + session.fsync(sessionStream) + sessionStream.close() + } + + val intent = Intent(context.applicationContext, InstallResultReceiver::class.java).apply { + putExtra("sessionId", sessionId) + // Use tracking name so events match Dart's operation key + putExtra("packageName", trackingPackageName) + putExtra("isUpdate", isUpdate) + } + val pendingIntent = PendingIntent.getBroadcast( + context.applicationContext, sessionId, intent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + session.commit(pendingIntent.intentSender) + session.close() + + emitInstallStatus(trackingPackageName, InstallStatus.STARTED) + result?.success(mapOf("started" to true, "sessionId" to sessionId, "packageName" to trackingPackageName)) + + } catch (e: SecurityException) { + emitInstallStatus(packageName, InstallStatus.FAILED, "Permission denied: ${e.message}", ErrorCode.PERMISSION_DENIED) + result?.success(mapOf("started" to false, "error" to "Permission denied", "errorCode" to ErrorCode.PERMISSION_DENIED)) + } catch (e: Exception) { + emitInstallStatus(packageName, InstallStatus.FAILED, e.message ?: "Installation failed", ErrorCode.INSTALL_FAILED) + result?.success(mapOf("started" to false, "error" to e.message, "errorCode" to ErrorCode.INSTALL_FAILED)) + } + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // SESSION MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════════ + + private fun hasActiveSession(packageName: String): Boolean { + return sessionToPackage.containsValue(packageName) || + findExistingSession(packageName) != null + } + + private fun findExistingSession(packageName: String): PackageInstaller.SessionInfo? { + return try { + context.packageManager.packageInstaller.mySessions + .find { it.appPackageName == packageName } + } catch (_: Exception) { null } + } + + private fun abandonExistingSession(packageName: String) { + val session = findExistingSession(packageName) ?: return + try { + context.packageManager.packageInstaller.abandonSession(session.sessionId) + sessionToPackage.remove(session.sessionId) + pendingUserActionIntents.remove(packageName) + } catch (_: Exception) {} + } + + private fun cleanupStaleSessions() { + try { + val installer = context.packageManager.packageInstaller + installer.mySessions.filter { !it.isActive }.forEach { session -> + try { installer.abandonSession(session.sessionId) } catch (_: Exception) {} + } + sessionToPackage.clear() + pendingUserActionIntents.clear() + } catch (_: Exception) {} + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // APK VERIFICATION + // ═══════════════════════════════════════════════════════════════════════════════ + + private data class VerificationResult( + val isSuccess: Boolean, + val errorTitle: String = "", + val errorDescription: String? = null, + val errorCode: String? = null + ) + + private fun verifyApk(file: File, expectedHash: String, expectedSize: Long): VerificationResult { + if (!isValidApkFormat(file)) { + file.delete() + return VerificationResult( + false, + "Invalid APK file", + "The downloaded file is not a valid APK format.", + ErrorCode.INVALID_FILE + ) + } + + val digest = MessageDigest.getInstance("SHA-256") + FileInputStream(file).use { fis -> + val buffer = ByteArray(8192) + var bytesRead: Int + while (fis.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + } + val actualHash = digest.digest().joinToString("") { "%02x".format(it) } + + if (actualHash.lowercase() != expectedHash.lowercase()) { + return VerificationResult( + false, + "Hash verification failed", + "The downloaded file hash does not match.\n\nExpected: $expectedHash\nActual: $actualHash", + ErrorCode.HASH_MISMATCH + ) + } + return VerificationResult(true) + } + + private fun isValidApkFormat(file: File): Boolean { + return try { + FileInputStream(file).use { fis -> + val magic = ByteArray(4) + if (fis.read(magic) < 4) return false + magic[0] == 0x50.toByte() && magic[1] == 0x4B.toByte() && + magic[2] == 0x03.toByte() && magic[3] == 0x04.toByte() + } + } catch (_: Exception) { false } + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // PERMISSIONS + // ═══════════════════════════════════════════════════════════════════════════════ + + private fun canInstallSilently(packageName: String? = null): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + Log.d(TAG, "canInstallSilently($packageName): false - API < 31") + return false + } + + val canRequest = try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.packageManager.canRequestPackageInstalls() + } else true + } catch (_: Throwable) { false } + + if (!canRequest) { + Log.d(TAG, "canInstallSilently($packageName): false - no install permission") + return false + } + + if (packageName != null) { + return try { + val installSource = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + context.packageManager.getInstallSourceInfo(packageName) + } else null + + val installer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && installSource != null) { + installSource.installingPackageName + } else { + @Suppress("DEPRECATION") + context.packageManager.getInstallerPackageName(packageName) + } + + val ourPackage = context.packageName + val result = installer == ourPackage + + Log.d(TAG, "canInstallSilently($packageName): installer=$installer, us=$ourPackage, result=$result") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && installSource != null) { + Log.d(TAG, " initiatingPackage=${installSource.initiatingPackageName}") + Log.d(TAG, " originatingPackage=${installSource.originatingPackageName}") + } + + result + } catch (_: PackageManager.NameNotFoundException) { + Log.d(TAG, "canInstallSilently($packageName): false - package not found") + false // Package not found = can't silently install it + } catch (e: Exception) { + Log.d(TAG, "canInstallSilently($packageName): false - error: ${e.message}") + false + } + } + + // Check general capability (no specific package) + val hasPermission = try { + context.checkSelfPermission("android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION") == + PackageManager.PERMISSION_GRANTED + } catch (_: Throwable) { false } + + Log.d(TAG, "canInstallSilently(general): hasUpdatePermission=$hasPermission") + return hasPermission + } + + private fun hasUnknownSourcesPermission(): Boolean { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.packageManager.canRequestPackageInstalls() + } else true + } catch (_: Throwable) { false } + } + + private fun requestInstallPermission(result: Result) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + !context.packageManager.canRequestPackageInstalls()) { + try { + val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = Uri.parse("package:${context.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + result.success(mapOf("success" to true)) + } catch (e: Exception) { + result.success(mapOf("success" to false, "message" to e.message)) + } + } else { + result.success(mapOf("success" to true)) + } + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // UNINSTALL + // ═══════════════════════════════════════════════════════════════════════════════ + + private fun uninstallApp(packageName: String, result: Result) { + try { + try { + context.packageManager.getPackageInfo(packageName, 0) + } catch (_: PackageManager.NameNotFoundException) { + result.success(mapOf("isSuccess" to false, "errorMessage" to "Package not found")) + return + } + + val intent = Intent(Intent.ACTION_DELETE).apply { + data = Uri.parse("package:$packageName") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Poll for removal with 30 second timeout + Thread { + val startTime = System.currentTimeMillis() + + while (System.currentTimeMillis() - startTime < UNINSTALL_TIMEOUT_MS) { + try { + Thread.sleep(UNINSTALL_POLL_INTERVAL_MS) + context.packageManager.getPackageInfo(packageName, 0) + } catch (_: PackageManager.NameNotFoundException) { + mainHandler.post { + result.success(mapOf("isSuccess" to true, "packageName" to packageName)) + } + return@Thread + } catch (_: InterruptedException) { break } + } + + val stillInstalled = try { + context.packageManager.getPackageInfo(packageName, 0) + true + } catch (_: PackageManager.NameNotFoundException) { false } + + mainHandler.post { + result.success(mapOf( + "isSuccess" to !stillInstalled, + "packageName" to packageName, + "cancelled" to stillInstalled + )) + } + }.start() + + } catch (e: Exception) { + result.success(mapOf("isSuccess" to false, "errorMessage" to e.message)) + } + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // APP LAUNCH + // ═══════════════════════════════════════════════════════════════════════════════ + + private fun launchApp(packageName: String, result: Result) { + try { + val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName) + if (launchIntent == null) { + result.success(mapOf("isSuccess" to false, "errorMessage" to "No launch activity")) + return + } + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + context.startActivity(launchIntent) + result.success(mapOf("isSuccess" to true)) + } catch (e: Exception) { + result.success(mapOf("isSuccess" to false, "errorMessage" to e.message)) + } + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // INSTALLED APPS + // ═══════════════════════════════════════════════════════════════════════════════ + + private fun getInstalledApps(includeSystemApps: Boolean): List> { + val pm = context.packageManager + return pm.getInstalledPackages(0).mapNotNull { pkg -> + val appInfo = pkg.applicationInfo ?: return@mapNotNull null + val isSystem = (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0 + if (!includeSystemApps && isSystem) return@mapNotNull null + + val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + pkg.longVersionCode + } else { + @Suppress("DEPRECATION") + pkg.versionCode.toLong() + } + + mapOf( + "name" to appInfo.loadLabel(pm)?.toString(), + "bundleId" to pkg.packageName, + "versionName" to pkg.versionName, + "versionCode" to versionCode, + "signatureHash" to getSignatureHash(pkg.packageName), + "canInstallSilently" to canInstallSilently(pkg.packageName) + ) + } + } + + private fun getSignatureHash(packageName: String): String { + return try { + val pm = context.packageManager + val pkgInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of( + PackageManager.GET_SIGNING_CERTIFICATES.toLong())) + } else { + @Suppress("DEPRECATION") + pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES) + } + + val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + pkgInfo.signingInfo?.let { si -> + if (si.hasMultipleSigners()) si.apkContentsSigners else si.signingCertificateHistory + } ?: emptyArray() + } else { + @Suppress("DEPRECATION") + pkgInfo.signatures ?: emptyArray() + } + + if (signatures.isEmpty()) return "" + + MessageDigest.getInstance("SHA-256") + .digest(signatures[0].toByteArray()) + .joinToString("") { "%02x".format(it) } + } catch (_: Exception) { "" } + } +} diff --git a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AppRestartPlugin.kt b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AppRestartPlugin.kt new file mode 100644 index 0000000..0295d71 --- /dev/null +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AppRestartPlugin.kt @@ -0,0 +1,43 @@ +package dev.zapstore.alpha.plugins + +import android.content.Context +import android.content.Intent +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import kotlin.system.exitProcess + +class AppRestartPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { + private lateinit var channel: MethodChannel + private lateinit var context: Context + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + channel = MethodChannel(binding.binaryMessenger, "dev.zapstore/app_restart") + channel.setMethodCallHandler(this) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "restart" -> { + restartApp() + result.success(null) + } + else -> result.notImplemented() + } + } + + private fun restartApp() { + val packageManager = context.packageManager + val intent = packageManager.getLaunchIntentForPackage(context.packageName)?.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + } + context.startActivity(intent) + exitProcess(0) + } +} + diff --git a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/InstallResultReceiver.kt b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/InstallResultReceiver.kt new file mode 100644 index 0000000..fe43ec1 --- /dev/null +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/InstallResultReceiver.kt @@ -0,0 +1,177 @@ +package dev.zapstore.alpha.plugins + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.os.Build +import android.util.Log + +private const val TAG = "InstallResultReceiver" + +/** + * BroadcastReceiver for APK installation results. + * + * Translates Android PackageInstaller status codes to simple status events + * that are emitted to Dart via EventChannel. + * + * This receiver is stateless - it simply maps broadcasts to events. + * All state management happens in AndroidPackageManagerPlugin. + */ +class InstallResultReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) + val packageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME) + ?: intent.getStringExtra("packageName") + ?: "" + val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + val sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1) + val isUpdate = intent.getBooleanExtra("isUpdate", false) + + Log.d(TAG, "Received: status=$status, package=$packageName, session=$sessionId, msg=$message") + + when (status) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + // User confirmation required - extract the dialog intent + val confirmIntent = extractConfirmIntent(intent) + + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.PENDING_USER_ACTION, + packageName = packageName, + isUpdate = isUpdate, + confirmIntent = confirmIntent + ) + } + + PackageInstaller.STATUS_SUCCESS -> { + Log.d(TAG, "Installation successful: $packageName") + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.SUCCESS, + packageName = packageName, + isUpdate = isUpdate + ) + } + + PackageInstaller.STATUS_FAILURE_ABORTED -> { + Log.d(TAG, "Installation cancelled: $packageName") + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.CANCELLED, + packageName = packageName, + message = "Installation was cancelled" + ) + } + + PackageInstaller.STATUS_FAILURE -> { + Log.w(TAG, "Installation failed: $packageName - $message") + // Try to determine error code from message content + val errorCode = categorizeFailure(message) + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.FAILED, + packageName = packageName, + message = message ?: "Installation failed", + errorCode = errorCode + ) + } + + PackageInstaller.STATUS_FAILURE_BLOCKED -> { + Log.w(TAG, "Installation blocked: $packageName") + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.FAILED, + packageName = packageName, + message = message ?: "Installation blocked by device policy", + errorCode = ErrorCode.BLOCKED + ) + } + + PackageInstaller.STATUS_FAILURE_CONFLICT -> { + Log.w(TAG, "Installation conflict: $packageName") + // Conflict typically means signature mismatch + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.FAILED, + packageName = packageName, + message = message ?: "Installation conflicts with existing package", + errorCode = ErrorCode.CERT_MISMATCH + ) + } + + PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> { + Log.w(TAG, "Incompatible package: $packageName") + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.FAILED, + packageName = packageName, + message = message ?: "Package is incompatible with this device", + errorCode = ErrorCode.INCOMPATIBLE + ) + } + + PackageInstaller.STATUS_FAILURE_INVALID -> { + Log.w(TAG, "Invalid package: $packageName") + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.FAILED, + packageName = packageName, + message = message ?: "Invalid installation package", + errorCode = ErrorCode.INVALID_FILE + ) + } + + PackageInstaller.STATUS_FAILURE_STORAGE -> { + Log.w(TAG, "Storage failure: $packageName") + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.FAILED, + packageName = packageName, + message = message ?: "Insufficient storage space", + errorCode = ErrorCode.INSUFFICIENT_STORAGE + ) + } + + else -> { + Log.w(TAG, "Unknown status $status: $packageName") + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.FAILED, + packageName = packageName, + message = message ?: "Installation failed (status: $status)", + errorCode = ErrorCode.INSTALL_FAILED + ) + } + } + } + + /** + * Categorize a generic failure message to determine the error code. + */ + private fun categorizeFailure(message: String?): String { + if (message == null) return ErrorCode.INSTALL_FAILED + val lower = message.lowercase() + return when { + lower.contains("signature") || + lower.contains("certificate") || + lower.contains("update_incompatible") -> ErrorCode.CERT_MISMATCH + lower.contains("storage") || lower.contains("space") -> ErrorCode.INSUFFICIENT_STORAGE + lower.contains("permission") || lower.contains("denied") -> ErrorCode.PERMISSION_DENIED + else -> ErrorCode.INSTALL_FAILED + } + } + + /** + * Extract the confirmation dialog intent from a PENDING_USER_ACTION broadcast. + */ + private fun extractConfirmIntent(intent: Intent): Intent? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_INTENT) + } + } +} diff --git a/android/app/src/main/kotlin/dev/zapstore/app/MainActivity.kt b/android/app/src/main/kotlin/dev/zapstore/app/MainActivity.kt deleted file mode 100644 index 1b4c74a..0000000 --- a/android/app/src/main/kotlin/dev/zapstore/app/MainActivity.kt +++ /dev/null @@ -1,5 +0,0 @@ -package dev.zapstore.app - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity: FlutterActivity() \ No newline at end of file diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png new file mode 100644 index 0000000..77bbeed Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png similarity index 54% rename from android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png rename to android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png index 1bd538b..b8a4a1c 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png similarity index 54% rename from android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png rename to android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png index 1bd538b..b8a4a1c 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_notification.png b/android/app/src/main/res/drawable-hdpi/ic_notification.png deleted file mode 100644 index 6a413ed..0000000 Binary files a/android/app/src/main/res/drawable-hdpi/ic_notification.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png new file mode 100644 index 0000000..b1c26fd Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png similarity index 63% rename from android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png rename to android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png index 7163544..be861e3 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png similarity index 63% rename from android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png rename to android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png index 7163544..be861e3 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_notification.png b/android/app/src/main/res/drawable-mdpi/ic_notification.png deleted file mode 100644 index 4c0c495..0000000 Binary files a/android/app/src/main/res/drawable-mdpi/ic_notification.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml index f74085f..6840462 100644 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -3,10 +3,10 @@ - - + - --> + android:src="@drawable/logo" /> + diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000..dc724d3 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png similarity index 50% rename from android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png rename to android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png index 1f61e8e..23f3d6b 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png similarity index 50% rename from android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png rename to android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png index 1f61e8e..23f3d6b 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_notification.png b/android/app/src/main/res/drawable-xhdpi/ic_notification.png deleted file mode 100644 index 581e288..0000000 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_notification.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..b3cb68a Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ef96edb Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..ef96edb Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_notification.png b/android/app/src/main/res/drawable-xxhdpi/ic_notification.png deleted file mode 100644 index 3632578..0000000 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_notification.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..88d858e Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..cbbb0a6 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..cbbb0a6 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png b/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png deleted file mode 100644 index 0e1d77e..0000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png and /dev/null differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 304732f..ad6a12c 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -3,10 +3,10 @@ - - + - --> + android:src="@drawable/logo" /> + diff --git a/android/app/src/main/res/drawable/logo.png b/android/app/src/main/res/drawable/logo.png new file mode 100644 index 0000000..f1595f8 Binary files /dev/null and b/android/app/src/main/res/drawable/logo.png differ diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index e7b046d..854fef6 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,14 @@ - - - + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 33df00e..c0ebae8 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png deleted file mode 100644 index fdece37..0000000 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 0646d16..0d72f83 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png deleted file mode 100644 index 1c3a4cf..0000000 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index ef84db4..e64d465 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png deleted file mode 100644 index 8031634..0000000 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d4e7ed2..e3607bc 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png deleted file mode 100644 index 3ab1f09..0000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index d959028..0000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png deleted file mode 100644 index d959028..0000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index d4e1658..6849f4b 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png deleted file mode 100644 index f4f4910..0000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 6dbe1a9..0000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png deleted file mode 100644 index 6dbe1a9..0000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml deleted file mode 100644 index ffa74ab..0000000 --- a/android/app/src/main/res/xml/file_paths.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle deleted file mode 100644 index e4def05..0000000 --- a/android/build.gradle +++ /dev/null @@ -1,55 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - - -subprojects { - afterEvaluate { project -> - if (project.plugins.hasPlugin("com.android.application") || - project.plugins.hasPlugin("com.android.library")) { - project.android { - compileSdkVersion 34 - buildToolsVersion "34.0.0" - } - } - } -} - -// Used for plugins that do not specify their own namespace -// https://discuss.gradle.org/t/flutter-not-able-to-migrate-to-gradle-8/46937/7 -subprojects { - afterEvaluate { project -> - if (project.hasProperty('android')) { - project.android { - if (namespace == null) { - namespace project.group - } - } - } - } -} - -rootProject.buildDir = '../build' -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { - project.evaluationDependsOn(':app') -} - -tasks.register("clean", Delete) { - delete rootProject.buildDir -} - -tasks.withType(JavaCompile) { - options.compilerArgs << "-Xlint:-options" -} - -ext { - compileSdkVersion = 34 - targetSdkVersion = 34 - appCompatVersion = "1.7.0" -} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..89176ef --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties index f33d08c..f018a61 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,3 @@ -org.gradle.jvmargs=-Xmx4G +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true -kotlin.jvm.target.validation.mode = IGNORE \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index afa1e8e..ac3b479 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.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/android/settings.gradle b/android/settings.gradle deleted file mode 100644 index df8b135..0000000 --- a/android/settings.gradle +++ /dev/null @@ -1,26 +0,0 @@ -pluginManagement { - def flutterSdkPath = { - def properties = new Properties() - file("local.properties").withInputStream { properties.load(it) } - def flutterSdkPath = properties.getProperty("flutter.sdk") - assert flutterSdkPath != null, "flutter.sdk not set in local.properties" - return flutterSdkPath - } - settings.ext.flutterSdkPath = flutterSdkPath() - - includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.3.2" apply false - id "org.jetbrains.kotlin.android" version "2.0.21" apply false -} - -include ":app" diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ab39a10 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/assets/fonts/InterDisplay-ExtraBold.ttf b/assets/fonts/InterDisplay-ExtraBold.ttf new file mode 100644 index 0000000..4ad8a62 Binary files /dev/null and b/assets/fonts/InterDisplay-ExtraBold.ttf differ diff --git a/assets/fonts/InterDisplay-Regular.ttf b/assets/fonts/InterDisplay-Regular.ttf new file mode 100644 index 0000000..956be38 Binary files /dev/null and b/assets/fonts/InterDisplay-Regular.ttf differ diff --git a/assets/images/fdroid.png b/assets/images/fdroid.png new file mode 100644 index 0000000..e04e0e9 Binary files /dev/null and b/assets/images/fdroid.png differ diff --git a/assets/images/github.png b/assets/images/github.png new file mode 100644 index 0000000..cf0caee Binary files /dev/null and b/assets/images/github.png differ diff --git a/assets/images/gitlab.png b/assets/images/gitlab.png new file mode 100644 index 0000000..7904845 Binary files /dev/null and b/assets/images/gitlab.png differ diff --git a/assets/images/logo-bg.png b/assets/images/logo-bg.png index e928068..d78ffc8 100644 Binary files a/assets/images/logo-bg.png and b/assets/images/logo-bg.png differ diff --git a/assets/images/logo-fg.png b/assets/images/logo-fg.png index 9c59374..4b5b5a6 100644 Binary files a/assets/images/logo-fg.png and b/assets/images/logo-fg.png differ diff --git a/assets/images/logo.png b/assets/images/logo.png index 82c9a7c..5bb0ede 100644 Binary files a/assets/images/logo.png and b/assets/images/logo.png differ diff --git a/build.yaml b/build.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/lib/constants/app_constants.dart b/lib/constants/app_constants.dart new file mode 100644 index 0000000..3cde44e --- /dev/null +++ b/lib/constants/app_constants.dart @@ -0,0 +1,47 @@ +/// Application identifier for Zapstore itself +const kZapstoreAppIdentifier = 'dev.zapstore.alpha'; + +/// Zapstore's public key for relay-signed apps +const kZapstorePubkey = + '78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d'; + +/// Franzap's public key for curation sets +const kFranzapPubkey = + '726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11'; + +/// Identifier for storing user saved apps +const kAppBookmarksIdentifier = 'zapstore-bookmarks'; + +/// Event filter for app stacks - excludes saved apps and stacks with zero App references +bool appStackEventFilter(Map event) { + final tags = event['tags'] as List?; + if (tags == null) return false; + + // Check for saved apps identifier in 'd' tag + for (final tag in tags) { + if (tag is List && tag.isNotEmpty && tag[0] == 'd') { + if (tag.length > 1 && tag[1] == kAppBookmarksIdentifier) { + return false; + } + } + } + + // Check for at least one 'a' tag starting with '32267:' (App kind) + for (final tag in tags) { + if (tag is List && tag.isNotEmpty && tag[0] == 'a') { + if (tag.length > 1 && tag[1] is String && tag[1].startsWith('32267:')) { + return true; + } + } + } + + // No valid App references found + return false; +} + +/// Amber signer package ID +const kAmberPackageId = 'com.greenart7c3.nostrsigner'; + +/// Amber signer naddr +const kAmberNaddr = + 'naddr1qqdkxmmd9enhyet9deshyaphvvejumn0wd68yumfvahx2uszyp6hjpmdntls5n8aa7n7ypzlyjrv0ewch33ml3452wtjx0smhl93jqcyqqq8uzcgpp6ky'; diff --git a/lib/main.dart b/lib/main.dart index b692811..7a752af 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,97 +1,320 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; +import 'dart:io' show Platform; -import 'package:app_links/app_links.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_data/flutter_data.dart'; -import 'package:flutter_phoenix/flutter_phoenix.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; -import 'package:zapstore/navigation/router.dart'; -import 'package:zapstore/utils/debounce.dart'; -import 'package:zapstore/utils/theme.dart'; -import 'package:zapstore/widgets/error_container.dart'; +import 'package:purplebase/purplebase.dart'; +import 'package:amber_signer/amber_signer.dart'; +import 'package:zapstore/services/app_restart_service.dart'; +import 'package:zapstore/services/background_update_service.dart'; +import 'package:zapstore/router.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/theme.dart'; +import 'package:zapstore/services/package_manager/android_package_manager.dart'; +import 'package:zapstore/services/package_manager/dummy_package_manager.dart'; +import 'package:zapstore/services/market_intent_service.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/breathing_logo.dart'; -const kDbVersion = 2; -final appLinks = AppLinks(); +/// Global provider container for error reporting (accessible outside widget tree) +late final ProviderContainer _providerContainer; -/// Application entry point. -/// - Initializes Riverpod (and Flutter Data local storage) -/// - Handles application errors -/// - Triggers routing void main() { + // Create provider container with overrides + _providerContainer = ProviderContainer( + overrides: [ + storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new), + packageManagerProvider.overrideWith( + (ref) => Platform.isAndroid + ? AndroidPackageManager(ref) + : DummyPackageManager(ref), + ), + ], + ); + runZonedGuarded(() { runApp( - Phoenix( - child: ProviderScope( - overrides: [ - localStorageProvider.overrideWithValue( - LocalStorage( - baseDirFn: () async { - final path = (await getApplicationSupportDirectory()).path; - print('Initializing local storage at $path'); - return path; - }, - clear: LocalStorageClearStrategy.whenError, - ), - ) - ], - child: const ZapstoreApp(), - ), + UncontrolledProviderScope( + container: _providerContainer, + child: const ZapstoreApp(), ), ); - }, errorHandler); + }, _errorHandler); FlutterError.onError = (details) { // Prevents debugger stopping multiple times FlutterError.dumpErrorToConsole(details); - errorHandler(details.exception, details.stack); + _errorHandler(details.exception, details.stack); }; } -class ZapstoreApp extends ConsumerWidget { +/// Global error handler that reports errors via NIP-44 encrypted DMs +void _errorHandler(Object exception, StackTrace? stack) { + // Report error asynchronously (fire and forget) + // TODO: Disabled until careful review + // unawaited( + // _providerContainer + // .read(errorReportingServiceProvider) + // .reportError(exception, stack), + // ); +} + +class ZapstoreApp extends HookConsumerWidget { const ZapstoreApp({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + final notifier = + ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier; + final title = 'Zapstore'; + + // Watch initialization state for error overlay display + final initState = ref.watch(appInitializationProvider); + + // Listen to app lifecycle and check for updates when app regains focus + useEffect(() { + final observer = _AppLifecycleObserver(ref); + WidgetsBinding.instance.addObserver(observer); + return () => WidgetsBinding.instance.removeObserver(observer); + }, []); + + // Listen to connectivity changes and trigger ensureConnected when going online + useEffect(() { + final connectivity = Connectivity(); + StreamSubscription>? subscription; + + // Check initial connectivity state + connectivity.checkConnectivity().then((results) { + notifier.connect(); + }); + + // Listen to connectivity changes + subscription = connectivity.onConnectivityChanged.listen((results) { + notifier.connect(); + }); + + return () => subscription?.cancel(); + }, []); + + // Always show the main app UI, even during initialization return MaterialApp.router( - builder: materialErrorBuilder, - routerConfig: appRouter, + title: title, + theme: darkTheme, + routerConfig: ref.watch(routerProvider), debugShowCheckedModeBanner: false, - theme: theme, + builder: (context, child) { + // Limit text scale factor to prevent extreme sizes on different devices + final mediaQuery = MediaQuery.of(context); + final constrainedTextScale = mediaQuery.textScaler + .scale(1.0) + .clamp(1.0, 1.2); + final constrainedTextScaler = TextScaler.linear(constrainedTextScale); + + // Show error overlay if initialization failed (do not block UI during loading) + if (initState is AsyncError) { + return MediaQuery( + data: mediaQuery.copyWith(textScaler: constrainedTextScaler), + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: child!, + ), + // Error overlay + Container( + color: Colors.black54, + child: Center( + child: Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Colors.red, + ), + const SizedBox(height: 16), + Text( + 'Initialization Error', + style: context.textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + initState.error.toString(), + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium, + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } + + return MediaQuery( + data: mediaQuery.copyWith(textScaler: constrainedTextScaler), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: child!, + ), + ); + }, ); } } -Directory? _dir; +class ZapstoreHome extends StatelessWidget { + const ZapstoreHome({super.key}); -void errorHandler(Object exception, StackTrace? stack) { - debounce((exception: exception, stack: stack), (records) async { - if (records.isEmpty) return; - _dir ??= await getApplicationDocumentsDirectory(); - final file = File('${_dir!.path}/errors.json'); - - late final Map errorMap; - if (await file.exists()) { - final contents = await file.readAsString(); - errorMap = - contents.isNotEmpty ? jsonDecode(await file.readAsString()) : {}; - } else { - errorMap = {}; - } - - for (final record in records) { - final full = - '${record.exception}${record.stack?.toString() ?? ''}${DateTime.now().toIso8601String()}'; - final key = full.split('\n').take(2).join(); - // Only keep longest stack of similar errors, prevents duplicates - if (full.length > (errorMap[key]?.length ?? 0)) { - errorMap[key] = full; - } - } - await file.writeAsString(jsonEncode(errorMap)); - }); - print(exception); - print(stack); + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const BreathingLogo(size: 120), + const SizedBox(height: 24), + Text('Zapstore', style: context.textTheme.headlineMedium), + const SizedBox(height: 8), + Text( + 'Permissionless app store for Nostr', + style: context.textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} + +final appInitializationProvider = FutureProvider((ref) async { + final dir = await getApplicationDocumentsDirectory(); + final dbPath = path.join(dir.path, 'zapstore.db'); + + // Clear storage if requested from a clear all operation + await maybeClearStorage(dbPath); + + // Initialize storage + await ref.read( + initializationProvider( + StorageConfiguration( + databasePath: dbPath, + defaultQuerySource: LocalAndRemoteSource(relays: 'AppCatalog'), + defaultRelays: { + 'default': {'wss://relay.zapstore.dev'}, + 'bootstrap': {'wss://purplepag.es', 'wss://relay.zapstore.dev'}, + 'AppCatalog': {'wss://relay.zapstore.dev'}, + 'social': { + 'wss://relay.damus.io', + 'wss://relay.primal.net', + 'wss://nos.lol', + }, + 'vertex': {'wss://relay.vertexlab.io'}, + }, + responseTimeout: Duration(seconds: 4), + ), + ).future, + ); + + // Initialize package manager + final packageManager = ref.read(packageManagerProvider.notifier); + await packageManager.syncInstalledPackages(); + + // Initialize background update service (WorkManager + notifications) + final backgroundService = ref.read(backgroundUpdateServiceProvider); + await backgroundService.initialize(); + + // Initialize market intent handling (for market:// URIs) + await ref.read(marketIntentServiceProvider).initialize(); + + await _attemptAutoSignIn(ref); +}); + +// AmberSigner provider for Nostr authentication +final amberSignerProvider = Provider(AmberSigner.new); + +Future _attemptAutoSignIn(Ref ref) async { + try { + await ref.read(amberSignerProvider).attemptAutoSignIn(); + await onSignInSuccess(ref); + } catch (e) { + // Auto sign-in fails on first install — that's fine, just continue + } +} + +/// Query AppCatalogRelayList after successful sign-in +Future onSignInSuccess(Ref ref) async { + final pubkey = ref.read(Signer.activePubkeyProvider); + if (pubkey == null) return; + + final storage = + ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier; + + unawaited( + storage.query( + RequestFilter(authors: {pubkey}).toRequest(), + source: const RemoteSource(relays: 'bootstrap', stream: false), + ), + ); +} + +/// Observes app lifecycle events and manages package/storage state +class _AppLifecycleObserver with WidgetsBindingObserver { + _AppLifecycleObserver(this._ref); + + final WidgetRef _ref; + + /// Handle permission grants that happened while app was backgrounded + Future _checkPermissionGrants() async { + final packageManager = _ref.read(packageManagerProvider.notifier); + if (packageManager is! AndroidPackageManager) return; + + final hasPermission = await packageManager.hasPermission(); + if (!hasPermission) return; + + // Advance any operations waiting for permission + final state = _ref.read(packageManagerProvider); + final awaitingPermission = state.operations.entries + .where((e) => e.value is AwaitingPermission) + .map((e) => e.key) + .toList(growable: false); + + for (final appId in awaitingPermission) { + await packageManager.onPermissionGranted(appId); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final notifier = + _ref.read(storageNotifierProvider.notifier) + as PurplebaseStorageNotifier; + final packageManager = _ref.read(packageManagerProvider.notifier); + + if (state == AppLifecycleState.resumed) { + // Sync installed packages to detect installs that completed while backgrounded + unawaited(packageManager.syncInstalledPackages()); + + // Check for permission grants that happened in settings + unawaited(_checkPermissionGrants()); + + // Reconnect storage/relay connections + notifier.connect(); + } else if (state == AppLifecycleState.paused) { + notifier.disconnect(); + } + } } diff --git a/lib/main.data.dart b/lib/main.data.dart deleted file mode 100644 index cfb5041..0000000 --- a/lib/main.data.dart +++ /dev/null @@ -1,59 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: directives_ordering, top_level_function_literal_block, depend_on_referenced_packages - -import 'package:flutter_data/flutter_data.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'package:zapstore/models/app_curation_set.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/file_metadata.dart'; -import 'package:zapstore/models/local_app.dart'; -import 'package:zapstore/models/release.dart'; -import 'package:zapstore/models/settings.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/models/zap_receipt.dart'; - -final adapterProvidersMap = >>{ - 'appCurationSets': appCurationSetsAdapterProvider, - 'apps': appsAdapterProvider, - 'fileMetadata': fileMetadataAdapterProvider, - 'localApps': localAppsAdapterProvider, - 'releases': releasesAdapterProvider, - 'settings': settingsAdapterProvider, - 'users': usersAdapterProvider, - 'zapReceipts': zapReceiptsAdapterProvider -}; - -extension AdapterWidgetRefX on WidgetRef { - Adapter get appCurationSets => - watch(appCurationSetsAdapterProvider)..internalWatch = watch; - Adapter get apps => watch(appsAdapterProvider)..internalWatch = watch; - Adapter get fileMetadata => - watch(fileMetadataAdapterProvider)..internalWatch = watch; - Adapter get localApps => - watch(localAppsAdapterProvider)..internalWatch = watch; - Adapter get releases => - watch(releasesAdapterProvider)..internalWatch = watch; - Adapter get settings => - watch(settingsAdapterProvider)..internalWatch = watch; - Adapter get users => watch(usersAdapterProvider)..internalWatch = watch; - Adapter get zapReceipts => - watch(zapReceiptsAdapterProvider)..internalWatch = watch; -} - -extension AdapterRefX on Ref { - Adapter get appCurationSets => - watch(appCurationSetsAdapterProvider)..internalWatch = watch; - Adapter get apps => watch(appsAdapterProvider)..internalWatch = watch; - Adapter get fileMetadata => - watch(fileMetadataAdapterProvider)..internalWatch = watch; - Adapter get localApps => - watch(localAppsAdapterProvider)..internalWatch = watch; - Adapter get releases => - watch(releasesAdapterProvider)..internalWatch = watch; - Adapter get settings => - watch(settingsAdapterProvider)..internalWatch = watch; - Adapter get users => watch(usersAdapterProvider)..internalWatch = watch; - Adapter get zapReceipts => - watch(zapReceiptsAdapterProvider)..internalWatch = watch; -} diff --git a/lib/models/app.dart b/lib/models/app.dart deleted file mode 100644 index 47d3c6f..0000000 --- a/lib/models/app.dart +++ /dev/null @@ -1,462 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:isolate'; - -import 'package:async/async.dart'; -import 'package:background_downloader/background_downloader.dart'; -import 'package:collection/collection.dart'; -import 'package:convert/convert.dart'; -import 'package:crypto/crypto.dart'; -import 'package:flutter_data/flutter_data.dart'; -import 'package:install_plugin/install_plugin.dart'; -import 'package:mutex/mutex.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:purplebase/purplebase.dart' as base; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/file_metadata.dart'; -import 'package:zapstore/models/local_app.dart'; -import 'package:zapstore/models/nostr_adapter.dart'; -import 'package:zapstore/models/release.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:path/path.dart' as path; -import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/utils/system_info.dart'; - -part 'app.g.dart'; - -final mutex = Mutex(); - -@DataAdapter([NostrAdapter, AppAdapter]) -class App extends base.App with DataModelMixin { - @override - Object? get id => event.id; - - final HasMany releases; - final BelongsTo signer; - final BelongsTo localApp; - - App.fromJson(super.map) - : releases = hasMany(map['releases']), - signer = belongsTo(map['signer']), - localApp = belongsTo(map['localApp']), - super.fromJson(); - - Map toJson() => super.toMap(); - - Release? get latestRelease { - return releases.toList().sortedByLatest.firstOrNull; - } - - FileMetadata? get latestMetadata { - return latestRelease?.artifacts - // All artifacts *should* be APKs for the - // current architecture, but double check - .where((a) => - a.mimeType == kAndroidMimeType && - a.platforms.contains('android-arm64-v8a')) - .firstOrNull; - } - - bool get canInstall => - localApp.value?.status == null && - latestMetadata != null && - signer.isPresent; - bool get canUpdate => - localApp.value?.status == AppInstallStatus.updatable && - latestMetadata != null && - signer.isPresent; - bool get isInstalled => localApp.value?.status != null; - bool get isUpdated => localApp.value?.status == AppInstallStatus.updated; - bool get isDowngrade => localApp.value?.status == AppInstallStatus.downgrade; - bool get hasCertificateMismatch => - localApp.value?.status == AppInstallStatus.certificateMismatch; - - bool get isSelfSigned => !(signer.value!.pubkey == kZapstorePubkey && - identifier != kZapstoreAppIdentifier); - - Future install({bool alwaysTrustSigner = false}) async { - if (!canInstall && !canUpdate || hasCertificateMismatch) { - throw Exception( - 'Local status: ${localApp.value?.status}, signer: ${signer.value?.npub}, certificate mismatch: $hasCertificateMismatch'); - } - - final adapter = DataModel.adapterFor(this) as AppAdapter; - final notifier = - adapter.ref.read(installationProgressProvider(id!).notifier); - - // Persist trusted preference - if (alwaysTrustSigner) { - final settings = adapter.ref.settings.findOneLocalById('_')!; - settings.trustedUsers.add(signer.value!); - settings.saveLocal(); - } - - final installPermission = await Permission.requestInstallPackages.status; - if (!installPermission.isGranted) { - final newStatus = await Permission.requestInstallPackages.request(); - if (newStatus.isDenied) { - notifier.state = ErrorInstallProgress(Exception('Error'), - info: 'Installation permission denied by user'); - return; - } - } - - final hash = latestMetadata!.hash!; - final dir = await getApplicationSupportDirectory(); - final file = File(path.join(dir.path, hash)); - - installOnDevice() async { - notifier.state = VerifyingHashProgress(); - - if (await _isHashMismatch(file.path, hash)) { - const e = 'Hash mismatch'; - await file.delete(); - notifier.state = ErrorInstallProgress(Exception(e), - info: 'Possibly a malicious file. Aborting installation.'); - return; - } - - notifier.state = RequestInstallProgress(); - - // NOTE: Using https://github.com/hui-z/flutter_install_plugin - // Tried https://github.com/zapstore/android_package_installer - // but it is giving users problems (and for me it's slower) - // Probably need to fork hui-z's with support for user-canceled action - // NOTE: Must use mutex: https://github.com/hui-z/flutter_install_plugin/issues/68 - final result = await mutex.protect(() async { - return InstallPlugin.install(file.path); - }); - - if (result['isSuccess']) { - notifier.state = IdleInstallProgress(success: true); - await file.delete(); - await adapter.ref.localApps.localAppAdapter - .refreshUpdateStatus(appId: identifier); - Future.microtask(() { - notifier.state = IdleInstallProgress(); - }); - } else { - const msg = 'App not installed'; - notifier.state = ErrorInstallProgress( - Exception(msg), - info: 'User canceled or error occured: ${result['errorMessage']}', - ); - } - } - - final fileExists = await file.exists(); - if (fileExists) { - await installOnDevice(); - } else { - if (fileExists) { - // If file exists download was probably partial, remove - await file.delete(); - } - - final url = latestMetadata!.urls.first; - final (baseDirectory, directory, filename) = - await Task.split(filePath: file.path); - - final task = DownloadTask( - url: url, - baseDirectory: baseDirectory, - directory: directory, - filename: filename, - updates: Updates.progress, - ); - - final result = await FileDownloader().download( - task, - onProgress: (progress) { - notifier.state = DownloadingInstallProgress(progress); - }, - ); - - switch (result.status) { - case TaskStatus.complete: - return await installOnDevice(); - default: - notifier.state = ErrorInstallProgress( - Exception('App did not fully download.'), - info: 'Please try again.'); - await file.delete(); - } - } - } -} - -mixin AppAdapter on Adapter { - final queriedAtMap = {}; - - @override - Future> findAll( - {bool? remote = true, - bool? background, - Map? params = const {}, - Map? headers, - bool? syncLocal, - OnSuccessAll? onSuccess, - OnErrorAll? onError, - DataRequestLabel? label}) async { - params ??= {}; - if (params.remove('includes') ?? false) { - return await fetchAppModels(params); - } - return super.findAll(params: params); - } - - Future checkForUpdates() async { - final appIds = await _installedIdentifiers(); - if (appIds.isNotEmpty) { - await fetchAppModels({'#d': appIds}); - // Once apps are loaded, check for installed status - await ref.localApps.localAppAdapter.refreshUpdateStatus(); - } - } - - Future> fetchAppModels(Map params) async { - late final List apps; - late final List releases; - - // If looking up multiple apps via `#d`, use `queriedAtMap` - // to find the earliest queried at Date, to be used in the - // next request `since` field - if (params.containsKey('#d') && !(params['skipCache'] ?? false)) { - DateTime? earliestQueryAt; - final identifiers = {...params['#d']}; - - final cachedIdentifiers = - identifiers.where((i) => queriedAtMap.containsKey(i)); - final newIdentifiers = - identifiers.where((i) => !queriedAtMap.containsKey(i)); - - var cachedApps = []; - var newApps = []; - - // For apps already in cache, find earliest date of the set - // (as we cannot have one `since` per app) - if (cachedIdentifiers.isNotEmpty) { - earliestQueryAt = cachedIdentifiers - .fold(queriedAtMap[cachedIdentifiers.first]!, (acc, e) { - return acc.isBefore(queriedAtMap[e]!) ? acc : queriedAtMap[e]!; - }); - - cachedApps = await super.findAll( - params: { - '#d': cachedIdentifiers, - // Query since latest + 1 - 'since': earliestQueryAt.add(Duration(seconds: 1)), - }, - ); - } - - // For apps not in cache, query without a since - if (newIdentifiers.isNotEmpty) { - newApps = await super.findAll( - params: { - '#d': newIdentifiers, - }, - ); - } - - apps = [...cachedApps, ...newApps]; - - // Find most recent `created_at` from returned apps - // and assign it to their queried at value for caching - final m = apps.isNotEmpty - ? apps.fold(apps.first.event.createdAt, (acc, e) { - return acc.isAfter(e.event.createdAt) ? acc : e.event.createdAt; - }) - : null; - - if (m != null) { - for (final app in apps) { - queriedAtMap[app.identifier] = m; - } - } - } else { - // Search (which uses no #d) - apps = await super.findAll(params: params); - } - - final appsOldFormat = - apps.where((app) => app.event.linkedReplaceableEvents.isNotEmpty); - final appsNewFormat = - apps.where((app) => app.event.linkedReplaceableEvents.isEmpty); - - if (appsOldFormat.isNotEmpty) { - // OLD FORMAT BRANCH - // Find all appid@version ($3) as we need to pick one tag to query on - // (filters by kind ($1) and pubkey ($2) done locally) - final latestReleaseIdentifiers = appsOldFormat - .map((app) => app.event.linkedReplaceableEvents.first.$3); - - if (latestReleaseIdentifiers.isNotEmpty) { - releases = await ref.releases.findAll( - params: {'#d': latestReleaseIdentifiers}, - ); - } else { - releases = []; - } - } else { - // NEW FORMAT BRANCH - final futures = appsNewFormat - .map((app) => ref.releases.findAll( - params: { - '#i': [app.identifier], - 'authors': [app.event.pubkey], - 'limit': 1 - }, - )) - .toSet(); - - final lists = await Future.wait(futures); - releases = lists.expand((a) => a).toList(); - } - - await nostrAdapter.loadArtifactsAndUsers(releases); - await ref.localApps.localAppAdapter.refreshUpdateStatus(); - - return apps; - } - - @override - Future findOne(Object id, - {bool remote = true, - bool background = false, - Map? params = const {}, - Map? headers, - OnSuccessOne? onSuccess, - OnErrorOne? onError, - DataRequestLabel? label}) async { - final apps = await fetchAppModels({ - ...params ?? {}, - '#d': [id] - }); - // If ID not found in relay then clear from local storage - if (apps.isEmpty) { - deleteLocalById(id); - return null; - } - await ref.localApps.localAppAdapter - .refreshUpdateStatus(appId: id.toString()); - return apps.first; - } - - List findWhereIdentifierInLocal(Iterable appIds) { - const len = 5 + 64 + 3; // kind + pubkey + separators length - final result = db.select( - 'SELECT key, data, substr(json_extract(data, \'\$.id\'), $len) AS id from apps where id in (${appIds.map((_) => '?').join(', ')})', - appIds.toList()); - return deserializeFromResult(result); - } - - Future> _installedIdentifiers() async { - if (!Platform.isAndroid) { - return {}; - } - final infos = await packageManager.getInstalledPackages(); - return { - for (final i in infos!) - if (i.packageName != null && - !kExcludedAppIdNamespaces.any((e) => i.packageName!.startsWith(e))) - i.packageName!, - }; - } - - @override - DeserializedData deserialize(Object? data, {String? key}) { - final list = data is Iterable ? data : [data as Map]; - - for (final Map map in list) { - final tags = map['tags']; - map['localApp'] = base.BaseUtil.getTag(tags, 'd')!; - map['signer'] = map['pubkey']; - } - - return super.deserialize(data); - } -} - -Future _isHashMismatch(String path, String hash) async { - return await Isolate.run(() async { - final file = File(path); - final fileStream = file.openRead(); - final reader = ChunkedStreamReader(fileStream); - final digestOutputSink = AccumulatorSink(); - final digestInputSink = sha256.startChunkedConversion(digestOutputSink); - String? digest; - try { - while (true) { - // Chunk size determined from rough CPU/memory profiling - final chunk = await reader.readChunk(3072); - if (chunk.isEmpty) { - break; // EOF - } - digestInputSink.add(chunk); - } - } finally { - digestInputSink.close(); - digest = digestOutputSink.events.single.toString(); - digestOutputSink.close(); - reader.cancel(); - } - return digest != hash; - }); -} - -extension AppExt on Iterable { - List get sortedByLatest => - sorted((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); -} - -// class - -sealed class AppInstallProgress {} - -class IdleInstallProgress extends AppInstallProgress { - final bool? success; - IdleInstallProgress({this.success}); -} - -class DownloadingInstallProgress extends AppInstallProgress { - final double progress; - DownloadingInstallProgress(this.progress); -} - -class VerifyingHashProgress extends AppInstallProgress {} - -class RequestInstallProgress extends AppInstallProgress {} - -class ErrorInstallProgress extends AppInstallProgress { - final Exception e; - final String? info; - final List<(String, Future Function())> actions; - ErrorInstallProgress(this.e, {this.info, this.actions = const []}); -} - -extension ExceptionExt on Exception { - String get message => (this as dynamic).message ?? toString(); -} - -final installationProgressProvider = - StateProvider.family( - (_, arg) => IdleInstallProgress()); - -final appsToUpdateProvider = StateProvider((_) => 0); - -// Constants - -const kAndroidMimeType = 'application/vnd.android.package-archive'; - -const kExcludedAppIdNamespaces = [ - 'android', - 'com.android', - 'com.google', - 'com.samsung', - 'com.sec.android', - 'org.chromium.webview_shell', - 'app.grapheneos', - 'app.vanadium', -]; diff --git a/lib/models/app.g.dart b/lib/models/app.g.dart deleted file mode 100644 index b0cfc0a..0000000 --- a/lib/models/app.g.dart +++ /dev/null @@ -1,85 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'app.dart'; - -// ************************************************************************** -// AdapterGenerator -// ************************************************************************** - -// ignore_for_file: non_constant_identifier_names, duplicate_ignore - -mixin _$AppAdapter on Adapter { - static final Map _kAppRelationshipMetas = { - 'releases': RelationshipMeta( - name: 'releases', - inverseName: 'app', - type: 'releases', - kind: 'HasMany', - instance: (_) => (_ as App).releases, - ), - 'signer': RelationshipMeta( - name: 'signer', - inverseName: 'apps', - type: 'users', - kind: 'BelongsTo', - instance: (_) => (_ as App).signer, - ), - 'localApp': RelationshipMeta( - name: 'localApp', - type: 'localApps', - kind: 'BelongsTo', - instance: (_) => (_ as App).localApp, - ) - }; - - @override - Map get relationshipMetas => _kAppRelationshipMetas; - - @override - App deserializeLocal(map, {String? key}) { - map = transformDeserialize(map); - return internalWrapStopInit(() => App.fromJson(map), key: key); - } - - @override - Map serializeLocal(model, {bool withRelationships = true}) { - final map = model.toJson(); - return transformSerialize(map, withRelationships: withRelationships); - } -} - -final _appsFinders = {}; - -class $AppAdapter = Adapter - with _$AppAdapter, NostrAdapter, AppAdapter; - -final appsAdapterProvider = Provider>( - (ref) => $AppAdapter(ref, InternalHolder(_appsFinders))); - -extension AppAdapterX on Adapter { - NostrAdapter get nostrAdapter => this as NostrAdapter; - AppAdapter get appAdapter => this as AppAdapter; -} - -extension AppRelationshipGraphNodeX on RelationshipGraphNode { - RelationshipGraphNode get releases { - final meta = _$AppAdapter._kAppRelationshipMetas['releases'] - as RelationshipMeta; - return meta.clone( - parent: this is RelationshipMeta ? this as RelationshipMeta : null); - } - - RelationshipGraphNode get signer { - final meta = - _$AppAdapter._kAppRelationshipMetas['signer'] as RelationshipMeta; - return meta.clone( - parent: this is RelationshipMeta ? this as RelationshipMeta : null); - } - - RelationshipGraphNode get localApp { - final meta = _$AppAdapter._kAppRelationshipMetas['localApp'] - as RelationshipMeta; - return meta.clone( - parent: this is RelationshipMeta ? this as RelationshipMeta : null); - } -} diff --git a/lib/models/app_curation_set.dart b/lib/models/app_curation_set.dart deleted file mode 100644 index ca96ac9..0000000 --- a/lib/models/app_curation_set.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:flutter_data/flutter_data.dart'; -import 'package:purplebase/purplebase.dart' as base; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/nostr_adapter.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/utils/extensions.dart'; - -part 'app_curation_set.g.dart'; - -@DataAdapter([NostrAdapter, AppCurationSetAdapter]) -class AppCurationSet extends base.AppCurationSet - with DataModelMixin { - @override - Object get id => event.id; - - final HasMany apps; - final BelongsTo signer; - - AppCurationSet.fromJson(super.map) - : apps = hasMany(map['apps']), - signer = belongsTo(map['signer']), - super.fromJson(); - - Map toJson() => super.toMap(); - - String get name => event.content.isNotEmpty ? event.content : identifier; -} - -mixin AppCurationSetAdapter on Adapter { - @override - Future> findAll( - {bool? remote = true, - bool? background = false, - Map? params, - Map? headers, - bool? syncLocal, - OnSuccessAll? onSuccess, - OnErrorAll? onError, - DataRequestLabel? label}) async { - final sets = await super.findAll(remote: remote!, params: params); - final userIds = {for (final set in sets) set.signer.id}.nonNulls; - await ref.users.findAll(params: {'authors': userIds, 'ignoreReturn': true}); - return sets; - } - - @override - DeserializedData deserialize(Object? data, {String? key}) { - final list = data is Iterable ? data : [data as Map]; - for (final Map map in list) { - final tags = map['tags']; - map['apps'] = base.BaseUtil.getTagSet(tags, 'a'); - } - return super.deserialize(data); - } -} diff --git a/lib/models/app_curation_set.g.dart b/lib/models/app_curation_set.g.dart deleted file mode 100644 index e30c5e7..0000000 --- a/lib/models/app_curation_set.g.dart +++ /dev/null @@ -1,79 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'app_curation_set.dart'; - -// ************************************************************************** -// AdapterGenerator -// ************************************************************************** - -// ignore_for_file: non_constant_identifier_names, duplicate_ignore - -mixin _$AppCurationSetAdapter on Adapter { - static final Map _kAppCurationSetRelationshipMetas = - { - 'apps': RelationshipMeta( - name: 'apps', - type: 'apps', - kind: 'HasMany', - instance: (_) => (_ as AppCurationSet).apps, - ), - 'signer': RelationshipMeta( - name: 'signer', - type: 'users', - kind: 'BelongsTo', - instance: (_) => (_ as AppCurationSet).signer, - ) - }; - - @override - Map get relationshipMetas => - _kAppCurationSetRelationshipMetas; - - @override - AppCurationSet deserializeLocal(map, {String? key}) { - map = transformDeserialize(map); - return internalWrapStopInit(() => AppCurationSet.fromJson(map), key: key); - } - - @override - Map serializeLocal(model, {bool withRelationships = true}) { - final map = model.toJson(); - return transformSerialize(map, withRelationships: withRelationships); - } -} - -final _appCurationSetsFinders = {}; - -class $AppCurationSetAdapter = Adapter - with - _$AppCurationSetAdapter, - NostrAdapter, - AppCurationSetAdapter; - -final appCurationSetsAdapterProvider = Provider>( - (ref) => - $AppCurationSetAdapter(ref, InternalHolder(_appCurationSetsFinders))); - -extension AppCurationSetAdapterX on Adapter { - NostrAdapter get nostrAdapter => - this as NostrAdapter; - AppCurationSetAdapter get appCurationSetAdapter => - this as AppCurationSetAdapter; -} - -extension AppCurationSetRelationshipGraphNodeX - on RelationshipGraphNode { - RelationshipGraphNode get apps { - final meta = _$AppCurationSetAdapter - ._kAppCurationSetRelationshipMetas['apps'] as RelationshipMeta; - return meta.clone( - parent: this is RelationshipMeta ? this as RelationshipMeta : null); - } - - RelationshipGraphNode get signer { - final meta = _$AppCurationSetAdapter - ._kAppCurationSetRelationshipMetas['signer'] as RelationshipMeta; - return meta.clone( - parent: this is RelationshipMeta ? this as RelationshipMeta : null); - } -} diff --git a/lib/models/feedback.dart b/lib/models/feedback.dart deleted file mode 100644 index 012518f..0000000 --- a/lib/models/feedback.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:purplebase/purplebase.dart'; - -class AppFeedback extends RegularEvent { - AppFeedback.fromJson(super.map) : super.fromJson(); -} - -class PartialAppFeedback extends RegularPartialEvent { - PartialAppFeedback({ - required String content, - }) { - event.content = content; - } -} diff --git a/lib/models/file_metadata.dart b/lib/models/file_metadata.dart deleted file mode 100644 index 6b89a70..0000000 --- a/lib/models/file_metadata.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter_data/flutter_data.dart'; -import 'package:purplebase/purplebase.dart' as base; -import 'package:zapstore/models/nostr_adapter.dart'; -import 'package:zapstore/models/release.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/utils/extensions.dart'; - -part 'file_metadata.g.dart'; - -@DataAdapter([NostrAdapter, FileMetadataAdapter]) -class FileMetadata extends base.FileMetadata with DataModelMixin { - final BelongsTo release; - final BelongsTo signer; - - @override - Object? get id => event.id; - - FileMetadata.fromJson(super.map) - : release = belongsTo(map['release']), - signer = belongsTo(map['signer']), - super.fromJson(); - - Map toJson() => super.toMap(); -} - -mixin FileMetadataAdapter on Adapter { - @override - DeserializedData deserialize(Object? data, {String? key}) { - final list = data is Iterable ? data : [data as Map]; - for (final e in list) { - final map = e as Map; - final isNewFormat = map['kind'] == 3063; - if (isNewFormat) { - // Map to old 1063 - map['kind'] = 1063; - map['tags'].add([ - 'min_sdk_version', - base.BaseUtil.getTag(map['tags'], 'min_os_version') - ]); - map['tags'].add([ - 'target_sdk_version', - base.BaseUtil.getTag(map['tags'], 'target_os_version') - ]); - } - } - return super.deserialize(data); - } -} diff --git a/lib/models/file_metadata.g.dart b/lib/models/file_metadata.g.dart deleted file mode 100644 index 8b1d7b0..0000000 --- a/lib/models/file_metadata.g.dart +++ /dev/null @@ -1,75 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'file_metadata.dart'; - -// ************************************************************************** -// AdapterGenerator -// ************************************************************************** - -// ignore_for_file: non_constant_identifier_names, duplicate_ignore - -mixin _$FileMetadataAdapter on Adapter { - static final Map _kFileMetadataRelationshipMetas = { - 'release': RelationshipMeta( - name: 'release', - inverseName: 'artifacts', - type: 'releases', - kind: 'BelongsTo', - instance: (_) => (_ as FileMetadata).release, - ), - 'signer': RelationshipMeta( - name: 'signer', - type: 'users', - kind: 'BelongsTo', - instance: (_) => (_ as FileMetadata).signer, - ) - }; - - @override - Map get relationshipMetas => - _kFileMetadataRelationshipMetas; - - @override - FileMetadata deserializeLocal(map, {String? key}) { - map = transformDeserialize(map); - return internalWrapStopInit(() => FileMetadata.fromJson(map), key: key); - } - - @override - Map serializeLocal(model, {bool withRelationships = true}) { - final map = model.toJson(); - return transformSerialize(map, withRelationships: withRelationships); - } -} - -final _fileMetadataFinders = {}; - -class $FileMetadataAdapter = Adapter - with _$FileMetadataAdapter, NostrAdapter, FileMetadataAdapter; - -final fileMetadataAdapterProvider = Provider>( - (ref) => $FileMetadataAdapter(ref, InternalHolder(_fileMetadataFinders))); - -extension FileMetadataAdapterX on Adapter { - NostrAdapter get nostrAdapter => - this as NostrAdapter; - FileMetadataAdapter get fileMetadataAdapter => this as FileMetadataAdapter; -} - -extension FileMetadataRelationshipGraphNodeX - on RelationshipGraphNode { - RelationshipGraphNode get release { - final meta = - _$FileMetadataAdapter._kFileMetadataRelationshipMetas['release'] - as RelationshipMeta; - return meta.clone( - parent: this is RelationshipMeta ? this as RelationshipMeta : null); - } - - RelationshipGraphNode get signer { - final meta = _$FileMetadataAdapter._kFileMetadataRelationshipMetas['signer'] - as RelationshipMeta; - return meta.clone( - parent: this is RelationshipMeta ? this as RelationshipMeta : null); - } -} diff --git a/lib/models/local_app.dart b/lib/models/local_app.dart deleted file mode 100644 index 22d5803..0000000 --- a/lib/models/local_app.dart +++ /dev/null @@ -1,187 +0,0 @@ -import 'dart:io'; - -import 'package:android_package_manager/android_package_manager.dart'; -import 'package:collection/collection.dart'; -import 'package:crypto/crypto.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_data/flutter_data.dart'; -import 'package:json_annotation/json_annotation.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/utils/system_info.dart'; -import 'package:zapstore/utils/version.dart'; - -part 'local_app.g.dart'; - -@DataAdapter([LocalAppAdapter]) -@JsonSerializable() -class LocalApp extends DataModel { - @override - final Object? id; - final String? installedVersion; - final int? installedVersionCode; - final AppInstallStatus? status; - - LocalApp( - {required this.id, - this.installedVersion, - this.installedVersionCode, - this.status}); - - LocalApp copyWith( - {String? installedVersion, - int? installedVersionCode, - AppInstallStatus? status}) { - return LocalApp( - id: id, - installedVersion: installedVersion ?? this.installedVersion, - installedVersionCode: installedVersionCode ?? this.installedVersionCode, - status: status ?? this.status, - ); - } -} - -mixin LocalAppAdapter on Adapter { - Future refreshUpdateStatus({String? appId}) async { - if (!Platform.isAndroid) { - return; - } - // NOTE: Using packageManager.getPackageInfo(packageName: appId) - // throws an uncatchable error every time it queries a non-installed package - final infos = await packageManager.getInstalledPackages( - flags: PackageInfoFlags( - {PMFlag.getPermissions, PMFlag.getSigningCertificates}, - )); - - final installedPackageInfos = infos!.where((i) => - !kExcludedAppIdNamespaces.any((e) => i.packageName!.startsWith(e))); - - final ids = installedPackageInfos.map((i) => i.packageName).nonNulls; - - // appId == null is the case of all apps - if (appId == null) { - final localApps = findAllLocal(); - final apps = ref.apps.appAdapter.findWhereIdentifierInLocal(ids); - - final localAppsToSave = []; - - for (final i in installedPackageInfos) { - final appId = i.packageName!; - final app = apps.firstWhereOrNull((a) => a.identifier == appId); - - final installedVersion = i.versionName; - final installedVersionCode = i.versionCode; - final signingCertificateBytes = - i.signingInfo?.signingCertificateHistory?.firstOrNull; - - final status = determineUpdateStatus(app, installedVersion, - installedVersionCode, signingCertificateBytes); - - if (status == null) { - continue; - } - final localApp = localApps.firstWhereOrNull((app) => appId == app.id) ?? - LocalApp(id: i.packageName!); - localAppsToSave.add( - localApp.copyWith( - installedVersion: installedVersion, - installedVersionCode: installedVersionCode, - status: status, - ), - ); - } - - // Save local apps - await saveManyLocal(localAppsToSave, async: true); - - // Remove uninstalled apps - final uninstalledApps = localApps.where((a) => !ids.contains(a.id)); - if (uninstalledApps.isNotEmpty) { - deleteLocalByKeys(uninstalledApps.map((a) => DataModel.keyFor(a))); - } - } else { - final app = - ref.apps.appAdapter.findWhereIdentifierInLocal({appId}).firstOrNull; - final i = - installedPackageInfos.firstWhereOrNull((i) => i.packageName == appId); - - final installedVersion = i?.versionName; - final installedVersionCode = i?.versionCode; - final signingCertificateBytes = - i?.signingInfo?.signingCertificateHistory?.firstOrNull; - - final status = determineUpdateStatus( - app, installedVersion, installedVersionCode, signingCertificateBytes); - - if (status != null) { - final localApp = app!.localApp.value ?? LocalApp(id: i!.packageName!); - - localApp - .copyWith( - installedVersion: installedVersion, - installedVersionCode: installedVersionCode, - status: status, - ) - .saveLocal(); - } - } - - updateNumberOfApps(); - } - - void updateNumberOfApps() { - final rs = db.select( - 'SELECT count(*) as c FROM localApps WHERE json_extract(data, \'\$.status\') is \'updatable\''); - ref.read(appsToUpdateProvider.notifier).state = rs.first['c']; - } - - AppInstallStatus? determineUpdateStatus(App? app, String? installedVersion, - int? installedVersionCode, List? signingCertificateBytes) { - if (app == null || app.releases.isEmpty || app.latestMetadata == null) { - return null; - } - if (signingCertificateBytes != null) { - final installedApkSigHash = - sha256.convert(signingCertificateBytes).toString().toLowerCase(); - final metadataSigHashes = - app.latestMetadata?.event.getTagSet('apk_signature_hash') ?? {}; - final matches = metadataSigHashes - .any((msh) => msh.toLowerCase() == installedApkSigHash); - if (!matches) { - return AppInstallStatus.certificateMismatch; - } - } - - if (installedVersion == null || installedVersionCode == null) { - return null; - } - - var comp = 0; - if (app.latestMetadata!.versionCode != null) { - // NOTE: Zapstore development versions always carry a lower version code - // (e.g. 12) than published ones (e.g. 2012) - final code = app.identifier == kZapstoreAppIdentifier && !kReleaseMode - ? installedVersionCode + 2000 - : installedVersionCode; - comp = app.latestMetadata!.versionCode!.compareTo(code); - } else { - // If versionCode was not available, compare string version - try { - comp = compareVersions(installedVersion, app.latestMetadata!.version!); - } catch (_) {} - } - - if (comp == 1) return AppInstallStatus.updatable; - if (comp == 0) return AppInstallStatus.updated; - // else it's a downgrade, which is not installable - return AppInstallStatus.downgrade; - } -} - -enum AppInstallStatus { - updated, - updatable, - downgrade, - certificateMismatch, -} diff --git a/lib/models/local_app.g.dart b/lib/models/local_app.g.dart deleted file mode 100644 index 99ebe21..0000000 --- a/lib/models/local_app.g.dart +++ /dev/null @@ -1,68 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'local_app.dart'; - -// ************************************************************************** -// AdapterGenerator -// ************************************************************************** - -// ignore_for_file: non_constant_identifier_names, duplicate_ignore - -mixin _$LocalAppAdapter on Adapter { - static final Map _kLocalAppRelationshipMetas = {}; - - @override - Map get relationshipMetas => - _kLocalAppRelationshipMetas; - - @override - LocalApp deserializeLocal(map, {String? key}) { - map = transformDeserialize(map); - return internalWrapStopInit(() => _$LocalAppFromJson(map), key: key); - } - - @override - Map serializeLocal(model, {bool withRelationships = true}) { - final map = _$LocalAppToJson(model); - return transformSerialize(map, withRelationships: withRelationships); - } -} - -final _localAppsFinders = {}; - -class $LocalAppAdapter = Adapter - with _$LocalAppAdapter, LocalAppAdapter; - -final localAppsAdapterProvider = Provider>( - (ref) => $LocalAppAdapter(ref, InternalHolder(_localAppsFinders))); - -extension LocalAppAdapterX on Adapter { - LocalAppAdapter get localAppAdapter => this as LocalAppAdapter; -} - -extension LocalAppRelationshipGraphNodeX on RelationshipGraphNode {} - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -LocalApp _$LocalAppFromJson(Map json) => LocalApp( - id: json['id'], - installedVersion: json['installedVersion'] as String?, - installedVersionCode: (json['installedVersionCode'] as num?)?.toInt(), - status: $enumDecodeNullable(_$AppInstallStatusEnumMap, json['status']), - ); - -Map _$LocalAppToJson(LocalApp instance) => { - 'id': instance.id, - 'installedVersion': instance.installedVersion, - 'installedVersionCode': instance.installedVersionCode, - 'status': _$AppInstallStatusEnumMap[instance.status], - }; - -const _$AppInstallStatusEnumMap = { - AppInstallStatus.updated: 'updated', - AppInstallStatus.updatable: 'updatable', - AppInstallStatus.downgrade: 'downgrade', - AppInstallStatus.certificateMismatch: 'certificateMismatch', -}; diff --git a/lib/models/nostr_adapter.dart b/lib/models/nostr_adapter.dart deleted file mode 100644 index 6439b70..0000000 --- a/lib/models/nostr_adapter.dart +++ /dev/null @@ -1,218 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:flutter_data/flutter_data.dart'; -import 'package:purplebase/purplebase.dart' hide Release; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/release.dart'; -import 'package:zapstore/utils/system_info.dart'; -import 'package:zapstore/widgets/latest_releases_container.dart'; - -// NOTE: Very important to use const in relay args to preserve equality in Riverpod families -// const kAppRelays = {'ws://10.0.2.2:3000'}; -const kAppRelays = {'wss://relay.zapstore.dev'}; -const kSocialRelays = { - 'wss://relay.damus.io', - 'wss://relay.primal.net', - 'wss://nos.lol', - 'ndk' -}; -const kVertexRelay = {'wss://relay.vertexlab.io'}; - -mixin NostrAdapter> on Adapter { - RelayMessageNotifier get relay => - ref.read(relayProviderFamily(kAppRelays).notifier); - - RelayMessageNotifier get socialRelays => - ref.read(relayProviderFamily(kSocialRelays).notifier); - - RelayMessageNotifier get vertexRelay => - ref.read(relayProviderFamily(kVertexRelay).notifier); - - @override - Future onInitialized() async { - await super.onInitialized(); - - // DO NOT run on isolates, the mere fact of calling the - // relay getter will trigger its initialization - // as providers are obviously not cached across isolates - if (!inIsolate) { - // Upon adapter initialization, configure relays - // with the event verification caching function - relay.configure( - isEventVerified: (Map map) { - // If replaceable, we check for that ID - final identifier = (map['tags'] as Iterable) - .firstWhereOrNull((t) => t[0] == 'd')?[1] - ?.toString(); - final id = identifier != null - ? (map['kind'] as int, map['pubkey'].toString(), identifier) - .formatted - : map['id']; - return ref.apps.nostrAdapter.existsId(id); - }, - ); - socialRelays.configure( - isEventVerified: (map) => ref.apps.nostrAdapter.existsId(map['pubkey']), - ); - } - } - - int get kind { - var pbType = internalType.singularize().capitalize(); - // handle FileMetadata edge case - pbType = pbType.replaceFirst('datum', 'data'); - return Event.types[pbType]!.kind; - } - - @override - DeserializedData deserialize(Object? data, {String? key}) { - final list = data is Iterable ? data : [data as Map]; - final models = []; - final included = []; - - for (final e in list) { - final map = e as Map; - map['signer'] = map['pubkey']; - - // ID should be the replaceable link/reference so as to make it replaceable in local db too - final tags = map['tags']; - final isReplaceable = BaseUtil.containsTag(tags, 'd'); - if (isReplaceable) { - map['id'] = ( - map['kind'] as int, - map['pubkey'].toString(), - BaseUtil.getTag(tags, 'd')!, - ).formatted; - } - - // Remove signature as it's already been verified - map.remove('sig'); - - // Collect models for current kind and included for others - final eventType = Event.types.entries - .firstWhere((e) => e.value.kind == kind) - .key - .pluralize() - .decapitalize(); - if (eventType == internalType) { - final newData = super.deserialize(map); - models.addAll(newData.models as Iterable); - } else { - final newData = adapters[eventType]!.deserialize(map); - included.addAll(newData.models as Iterable); - } - } - return DeserializedData(models, included: included); - } - - @override - bool existsId(Object id) { - final r = db.select('SELECT 1 FROM _keys WHERE id = ?', [id]); - return r.isNotEmpty; - } - - Iterable existingIds(Iterable ids) { - final r = db.select( - 'SELECT id FROM _keys WHERE id in (${ids.map((e) => '?').join(',')})', - ids.toList()); - return r.map((e) => e['id']); - } - - @override - Future> findAll( - {bool? remote, - bool? background, - Map? params, - Map? headers, - bool? syncLocal, - OnSuccessAll? onSuccess, - OnErrorAll? onError, - DataRequestLabel? label}) async { - params ??= {}; - if (remote == false) { - return findAllLocal(); - } - - final additionalKinds = params.remove('kinds'); - final limit = params.remove('limit'); - final since = params.remove('since'); - final until = params.remove('until'); - final ignoreReturn = params.remove('ignoreReturn'); - - if (['apps', 'fileMetadatas'].contains(internalType)) { - if (Platform.isAndroid) { - final info = - await ref.read(systemInfoNotifierProvider.notifier).fetch(); - params['#f'] = info.androidInfo.supportedAbis.map((a) => 'android-$a'); - } - } - - final req = RelayRequest( - kinds: {kind, ...?additionalKinds}, - tags: params, - limit: limit, - until: until, - // Disable since when until is present - since: until != null ? null : since, - ); - - final result = await relay.queryRaw(req); - final deserialized = await deserializeAsync(result, - save: true, ignoreReturn: ignoreReturn == true); - return deserialized.models; - } - - Future loadArtifactsAndUsers(Iterable releases) async { - final metadataIds = - releases.map((r) => r.event.linkedEvents).nonNulls.expand((_) => _); - final apps = releases.map((r) => r.app.value).nonNulls; - final userIds = apps.map((a) => a.signer.id).nonNulls; - - // Metadata and users probably go to separate relays - // so query in parallel - // Can use ignoreReturn on both as we only care about saved models - await Future.wait([ - if (metadataIds.isNotEmpty) - ref.fileMetadata.findAll( - params: { - 'kinds': {1063, 3063}, - 'ids': metadataIds, - '#m': [kAndroidMimeType], - 'ignoreReturn': true, - }, - ), - if (userIds.isNotEmpty) - ref.users.findAll( - params: { - 'authors': userIds, - 'ignoreReturn': true, - }, - ), - ]); - } -} - -class RelayListenerNotifier extends Notifier { - @override - void build() { - final timer = Timer.periodic(Duration(minutes: 30), (_) => fetch()); - - // This will get disposed when clearing and restarting the app - ref.onDispose(() { - timer.cancel(); - }); - } - - Future fetch() async { - await Future.microtask(() async { - await ref.read(latestReleasesAppProvider.notifier).fetchRemote(); - await ref.apps.appAdapter.checkForUpdates(); - }); - } -} - -final relayListenerProvider = - NotifierProvider(RelayListenerNotifier.new); diff --git a/lib/models/release.dart b/lib/models/release.dart deleted file mode 100644 index b66abfc..0000000 --- a/lib/models/release.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter_data/flutter_data.dart'; -import 'package:purplebase/purplebase.dart' as base; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/file_metadata.dart'; -import 'package:zapstore/models/nostr_adapter.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/utils/extensions.dart'; - -part 'release.g.dart'; - -@DataAdapter([NostrAdapter, ReleaseAdapter]) -class Release extends base.Release with DataModelMixin { - @override - Object? get id => event.id; - - final HasMany artifacts; - final BelongsTo app; - final BelongsTo signer; - - // Release( - // {super.createdAt, - // super.content, - // super.tags, - // required this.artifacts, - // required this.app, - // required this.signer}); - - Release.fromJson(super.map) - : app = belongsTo(map['app']), - artifacts = hasMany(map['artifacts']), - signer = belongsTo(map['signer']), - super.fromJson(); - - Map toJson() => super.toMap(); -} - -mixin ReleaseAdapter on Adapter { - @override - DeserializedData deserialize(Object? data, {String? key}) { - final list = data is Iterable ? data : [data as Map]; - - for (final Map map in list) { - final tags = map['tags']; - map['app'] = base.BaseUtil.getTag(tags, 'a') ?? - '32267:${map['pubkey']}:${base.BaseUtil.getTag(tags, 'i')}'; - map['artifacts'] = base.BaseUtil.getTagSet(tags, 'e').toList(); - } - return super.deserialize(data); - } -} - -extension ReleaseExt on Iterable { - List get sortedByLatest => - sorted((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); -} diff --git a/lib/models/release.g.dart b/lib/models/release.g.dart deleted file mode 100644 index ba92ea8..0000000 --- a/lib/models/release.g.dart +++ /dev/null @@ -1,86 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'release.dart'; - -// ************************************************************************** -// AdapterGenerator -// ************************************************************************** - -// ignore_for_file: non_constant_identifier_names, duplicate_ignore - -mixin _$ReleaseAdapter on Adapter { - static final Map _kReleaseRelationshipMetas = { - 'artifacts': RelationshipMeta( - name: 'artifacts', - inverseName: 'release', - type: 'fileMetadata', - kind: 'HasMany', - instance: (_) => (_ as Release).artifacts, - ), - 'app': RelationshipMeta( - name: 'app', - inverseName: 'releases', - type: 'apps', - kind: 'BelongsTo', - instance: (_) => (_ as Release).app, - ), - 'signer': RelationshipMeta( - name: 'signer', - type: 'users', - kind: 'BelongsTo', - instance: (_) => (_ as Release).signer, - ) - }; - - @override - Map get relationshipMetas => - _kReleaseRelationshipMetas; - - @override - Release deserializeLocal(map, {String? key}) { - map = transformDeserialize(map); - return internalWrapStopInit(() => Release.fromJson(map), key: key); - } - - @override - Map serializeLocal(model, {bool withRelationships = true}) { - final map = model.toJson(); - return transformSerialize(map, withRelationships: withRelationships); - } -} - -final _releasesFinders = {}; - -class $ReleaseAdapter = Adapter - with _$ReleaseAdapter, NostrAdapter, ReleaseAdapter; - -final releasesAdapterProvider = Provider>( - (ref) => $ReleaseAdapter(ref, InternalHolder(_releasesFinders))); - -extension ReleaseAdapterX on Adapter { - NostrAdapter get nostrAdapter => this as NostrAdapter; - ReleaseAdapter get releaseAdapter => this as ReleaseAdapter; -} - -extension ReleaseRelationshipGraphNodeX on RelationshipGraphNode { - RelationshipGraphNode get artifacts { - final meta = _$ReleaseAdapter._kReleaseRelationshipMetas['artifacts'] - as RelationshipMeta; - return meta.clone( - parent: this is RelationshipMeta ? this as RelationshipMeta : null); - } - - RelationshipGraphNode get app { - final meta = _$ReleaseAdapter._kReleaseRelationshipMetas['app'] - as RelationshipMeta; - return meta.clone( - parent: this is RelationshipMeta ? this as RelationshipMeta : null); - } - - RelationshipGraphNode get signer { - final meta = _$ReleaseAdapter._kReleaseRelationshipMetas['signer'] - as RelationshipMeta; - return meta.clone( - parent: this is RelationshipMeta ? this as RelationshipMeta : null); - } -} diff --git a/lib/models/settings.dart b/lib/models/settings.dart deleted file mode 100644 index 3c8c709..0000000 --- a/lib/models/settings.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter_data/flutter_data.dart'; -import 'package:json_annotation/json_annotation.dart'; -import 'package:zapstore/models/user.dart'; - -part 'settings.g.dart'; - -@JsonSerializable() -@DataAdapter([SettingsAdapter]) -class Settings extends DataModel { - @override - String get id => '_'; - final BelongsTo user = BelongsTo(); - final HasMany trustedUsers = HasMany(); - SignInMethod? signInMethod; -} - -enum SignInMethod { - pubkey, - nip55, -} - -mixin SettingsAdapter on Adapter { - @override - Future onInitialized() async { - await super.onInitialized(); - if (inIsolate) { - return; - } - // ensure it's always present - if (!existsId('_')) { - Settings().saveLocal(); - } - } -} diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart deleted file mode 100644 index 35e55c4..0000000 --- a/lib/models/settings.g.dart +++ /dev/null @@ -1,89 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'settings.dart'; - -// ************************************************************************** -// AdapterGenerator -// ************************************************************************** - -// ignore_for_file: non_constant_identifier_names, duplicate_ignore - -mixin _$SettingsAdapter on Adapter { - static final Map _kSettingsRelationshipMetas = { - 'user': RelationshipMeta( - name: 'user', - inverseName: 'settings', - type: 'users', - kind: 'BelongsTo', - instance: (_) => (_ as Settings).user, - ), - 'trustedUsers': RelationshipMeta( - name: 'trustedUsers', - inverseName: 'settings', - type: 'users', - kind: 'HasMany', - instance: (_) => (_ as Settings).trustedUsers, - ) - }; - - @override - Map get relationshipMetas => - _kSettingsRelationshipMetas; - - @override - Settings deserializeLocal(map, {String? key}) { - map = transformDeserialize(map); - return internalWrapStopInit(() => _$SettingsFromJson(map), key: key); - } - - @override - Map serializeLocal(model, {bool withRelationships = true}) { - final map = _$SettingsToJson(model); - return transformSerialize(map, withRelationships: withRelationships); - } -} - -final _settingsFinders = {}; - -class $SettingsAdapter = Adapter - with _$SettingsAdapter, SettingsAdapter; - -final settingsAdapterProvider = Provider>( - (ref) => $SettingsAdapter(ref, InternalHolder(_settingsFinders))); - -extension SettingsAdapterX on Adapter { - SettingsAdapter get settingsAdapter => this as SettingsAdapter; -} - -extension SettingsRelationshipGraphNodeX on RelationshipGraphNode { - RelationshipGraphNode get user { - final meta = _$SettingsAdapter._kSettingsRelationshipMetas['user'] - as RelationshipMeta; - return meta.clone( - parent: this is RelationshipMeta ? this as RelationshipMeta : null); - } - - RelationshipGraphNode get trustedUsers { - final meta = _$SettingsAdapter._kSettingsRelationshipMetas['trustedUsers'] - as RelationshipMeta; - return meta.clone( - parent: this is RelationshipMeta ? this as RelationshipMeta : null); - } -} - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Settings _$SettingsFromJson(Map json) => Settings() - ..signInMethod = - $enumDecodeNullable(_$SignInMethodEnumMap, json['signInMethod']); - -Map _$SettingsToJson(Settings instance) => { - 'signInMethod': _$SignInMethodEnumMap[instance.signInMethod], - }; - -const _$SignInMethodEnumMap = { - SignInMethod.pubkey: 'pubkey', - SignInMethod.nip55: 'nip55', -}; diff --git a/lib/models/user.dart b/lib/models/user.dart deleted file mode 100644 index 788b750..0000000 --- a/lib/models/user.dart +++ /dev/null @@ -1,285 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:collection/collection.dart'; -import 'package:flutter_data/flutter_data.dart'; -import 'package:purplebase/purplebase.dart' as base; -import 'package:http/http.dart' as http; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/nostr_adapter.dart'; -import 'package:zapstore/models/settings.dart'; -import 'package:zapstore/models/verify_reputation_dvm.dart'; -import 'package:zapstore/navigation/app_initializer.dart'; -import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/utils/nwc.dart'; - -part 'user.g.dart'; - -@DataAdapter([NostrAdapter, UserAdapter]) -class User extends base.User with DataModelMixin { - @override - Object? get id => event.id; - - User.fromJson(super.map) - : followers = hasMany(map['followers']), - following = hasMany(map['following']), - settings = belongsTo(map['settings']), - apps = hasMany(map['apps']), - super.fromJson(); - - User.fromPubkey(String pubkey) - : this.fromJson({ - 'id': pubkey, - 'kind': 0, - 'pubkey': pubkey.hexKey, - 'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000, - 'content': '{}', - 'tags': [], - }); - - Map toJson() => super.toMap(); - - @DataRelationship(inverse: 'followers') - final HasMany following; - @DataRelationship(inverse: 'following') - final HasMany followers; - @DataRelationship(inverse: 'user') - final BelongsTo settings; - - @DataRelationship(inverse: 'signer') - final HasMany apps; - - String get nameOrNpub => name ?? npub.shorten; - - // Zaps - - Future zap(int amountInSats, - {required base.Event event, String? comment, base.Signer? signer}) async { - final adapter = DataModel.adapterFor(this) as NostrAdapter; - - // First ensure connection is available - final nwcConnection = adapter.ref.read(nwcConnectionProvider).asData?.value; - if (nwcConnection == null) { - throw 'No NWC connection'; - } - - final authorPubkey = event.event.pubkey; - final author = adapter.ref.users.findOneLocalById(authorPubkey); - - if (author?.lud16 == null) { - throw 'Recipient has no Lightning address'; - } - - final amountInMillisats = amountInSats * 1000; - final partialZapRequest = base.PartialZapRequest(); - partialZapRequest - ..addLinkedEvent(event) - ..addLinkedUser(author!) - ..relays = kSocialRelays.where((r) => r != 'ndk') - ..amount = amountInMillisats - ..comment = comment; - - final zapRequest = await partialZapRequest.signWith(signer ?? amberSigner, - withPubkey: pubkey); - - // Now we fetch the invoice - final lnResponse = await author.fetchLightningAddress(); - - final recipientAllowsNostr = lnResponse['allowsNostr']; - final recipientPubkey = lnResponse['nostrPubkey']; - // All amounts are in millisats - final recipientMin = lnResponse['minSendable']; - final recipientMax = lnResponse['maxSendable']; - - if (recipientAllowsNostr != true && recipientPubkey != null) { - throw 'Recipient does not allow nostr'; - } - if ((recipientMin != null && amountInMillisats < recipientMin) || - (recipientMax != null && amountInMillisats > recipientMax)) { - throw 'Amount not between min and max sendable'; - } - - final commentLength = lnResponse['commentAllowed']; - comment = - commentLength != null ? comment?.safeSubstring(commentLength) : comment; - - var callbackUri = Uri.parse(lnResponse['callback']!); - callbackUri = callbackUri.replace( - queryParameters: { - ...callbackUri.queryParameters, - 'amount': amountInMillisats.toString(), - if (comment != null) 'comment': comment, - 'nostr': jsonEncode(zapRequest.toMap()), - }, - ); - - final invoiceResponse = await http.get(callbackUri); - final invoiceMap = jsonDecode(invoiceResponse.body); - - final invoice = invoiceMap['pr']; - if (invoice == null) { - throw 'No invoice'; - } - - // Pay the invoice via NWC (errors are thrown) - await adapter.socialRelays.ndk!.nwc.payInvoice(nwcConnection, - invoice: invoice, timeout: Duration(seconds: 20)); - } - - Future> fetchLightningAddress() async { - final [userName, domainName] = lud16!.split('@'); - final lnurl = 'https://$domainName/.well-known/lnurlp/$userName'; - final response = await http.get(Uri.parse(lnurl)); - final map = jsonDecode(response.body); - return map; - } -} - -mixin UserAdapter on NostrAdapter { - @override - Future> findAll( - {bool? remote, - bool? background, - Map? params, - Map? headers, - bool? syncLocal, - OnSuccessAll? onSuccess, - OnErrorAll? onError, - DataRequestLabel? label}) async { - final authors = params!['authors'] as Iterable; - if (authors.isEmpty) { - return []; - } - - final request = base.RelayRequest( - kinds: {0}, - authors: {...authors}, - ); - - final result = await socialRelays.queryRaw(request); - - if (onSuccess != null) { - return await onSuccess.call(DataResponse(statusCode: 200, body: result), - label ?? DataRequestLabel('findAll', type: type), this); - } - final data = await deserializeAsync(result, save: true); - return data.models; - } - - @override - bool isOfflineError(Object? error) { - return false; - } - - @override - Future findOne(Object id, - {bool remote = true, - bool background = false, - Map? params, - Map? headers, - OnSuccessOne? onSuccess, - OnErrorOne? onError, - DataRequestLabel? label}) async { - if (id.toString().isEmpty) return null; - - var publicKey = id.toString(); - - if (publicKey.startsWith('npub')) { - publicKey = publicKey.hexKey; - } else if (publicKey.contains('@')) { - // If it's not an npub and it has a @ instead, we treat the string as NIP-05 - final [username, domain] = publicKey.split('@'); - publicKey = await sendRequest( - Uri.parse('https://$domain/.well-known/nostr.json?name=$username'), - onSuccess: (response, label) { - var body = response.body; - if (body is String) { - body = jsonDecode(body); - } - return (body as Map)['names']?[username]; - }, - onError: (e, _) { - throw e; - }, - ); - } - - params ??= {}; - - final result = await socialRelays.queryRaw(base.RelayRequest( - kinds: {0}, - tags: params, - authors: {publicKey}, - )); - - final data = await deserializeAsync(result, save: true); - return data.models.firstWhereOrNull((e) { - return e.id == publicKey; - }); - } - - @override - DeserializedData deserialize(Object? data, {String? key}) { - final list = data is Iterable ? data : [data as Map]; - - for (final Map map in list) { - // if (map['kind'] == 3) { - // map['following'] = { - // for (final [name, value] in map['tags'] as Iterable) - // if (name == 'p') value - // }; - // } - map['id'] = map['pubkey']; - } - - return super.deserialize(data); - } - - Future> getRelevantWhoFollow( - String fromNpub, String toNpub) async { - final dvmReq = await PartialVerifyReputationRequest( - source: fromNpub.hexKey, target: toNpub.hexKey) - .signWith(amberSigner, withPubkey: fromNpub.hexKey); - await ref.users.nostrAdapter.vertexRelay.publish(dvmReq); - - final req = base.RelayRequest(kinds: { - 6312, - 7000 - }, tags: { - 'e': [dvmReq.event.id] - }); - final response = await ref.users.nostrAdapter.vertexRelay.queryRaw(req); - - if (response.isEmpty) { - return []; - } - if (response.first['kind'] == 7000) { - final error = (response.first['tags'] as Iterable?) - ?.firstWhereOrNull((t) => t[0] == 'status')?[2]; - if (error.toString().contains('credits')) { - throw 'Unable to check followers'; - } else { - throw error; - } - } - final result = response.first['content']; - final pubkeyList = (jsonDecode(result) as List).map((e) => e['pubkey']); - final users = await findAll(params: {'authors': pubkeyList}); - return users; - } -} - -final signedInUserProvider = Provider((ref) { - return ref.settings - .watchOne('_', alsoWatch: (_) => {_.user}) - .model - ?.user - .value; -}); - -const kZapstorePubkey = - '78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d'; - -const kFranzapPubkey = - '726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11'; diff --git a/lib/models/user.g.dart b/lib/models/user.g.dart deleted file mode 100644 index 53e9636..0000000 --- a/lib/models/user.g.dart +++ /dev/null @@ -1,101 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'user.dart'; - -// ************************************************************************** -// AdapterGenerator -// ************************************************************************** - -// ignore_for_file: non_constant_identifier_names, duplicate_ignore - -mixin _$UserAdapter on Adapter { - static final Map _kUserRelationshipMetas = { - 'following': RelationshipMeta( - name: 'following', - inverseName: 'followers', - type: 'users', - kind: 'HasMany', - instance: (_) => (_ as User).following, - ), - 'followers': RelationshipMeta( - name: 'followers', - inverseName: 'following', - type: 'users', - kind: 'HasMany', - instance: (_) => (_ as User).followers, - ), - 'settings': RelationshipMeta( - name: 'settings', - inverseName: 'user', - type: 'settings', - kind: 'BelongsTo', - instance: (_) => (_ as User).settings, - ), - 'apps': RelationshipMeta( - name: 'apps', - inverseName: 'signer', - type: 'apps', - kind: 'HasMany', - instance: (_) => (_ as User).apps, - ) - }; - - @override - Map get relationshipMetas => - _kUserRelationshipMetas; - - @override - User deserializeLocal(map, {String? key}) { - map = transformDeserialize(map); - return internalWrapStopInit(() => User.fromJson(map), key: key); - } - - @override - Map serializeLocal(model, {bool withRelationships = true}) { - final map = model.toJson(); - return transformSerialize(map, withRelationships: withRelationships); - } -} - -final _usersFinders = {}; - -class $UserAdapter = Adapter - with _$UserAdapter, NostrAdapter, UserAdapter; - -final usersAdapterProvider = Provider>( - (ref) => $UserAdapter(ref, InternalHolder(_usersFinders))); - -extension UserAdapterX on Adapter { - NostrAdapter get nostrAdapter => this as NostrAdapter; - UserAdapter get userAdapter => this as UserAdapter; -} - -extension UserRelationshipGraphNodeX on RelationshipGraphNode { - RelationshipGraphNode get following { - final meta = _$UserAdapter._kUserRelationshipMetas['following'] - as RelationshipMeta; - return meta.clone( - parent: this is RelationshipMeta ? this as RelationshipMeta : null); - } - - RelationshipGraphNode get followers { - final meta = _$UserAdapter._kUserRelationshipMetas['followers'] - as RelationshipMeta; - return meta.clone( - parent: this is RelationshipMeta ? this as RelationshipMeta : null); - } - - RelationshipGraphNode get settings { - final meta = _$UserAdapter._kUserRelationshipMetas['settings'] - as RelationshipMeta; - return meta.clone( - parent: this is RelationshipMeta ? this as RelationshipMeta : null); - } - - RelationshipGraphNode get apps { - final meta = - _$UserAdapter._kUserRelationshipMetas['apps'] as RelationshipMeta; - return meta.clone( - parent: this is RelationshipMeta ? this as RelationshipMeta : null); - } -} diff --git a/lib/models/verify_reputation_dvm.dart b/lib/models/verify_reputation_dvm.dart deleted file mode 100644 index f152cce..0000000 --- a/lib/models/verify_reputation_dvm.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:purplebase/purplebase.dart'; - -class VerifyReputationRequest extends RegularEvent { - VerifyReputationRequest.fromJson(super.map) : super.fromJson(); -} - -class PartialVerifyReputationRequest - extends RegularPartialEvent { - PartialVerifyReputationRequest({ - required String source, - required String target, - }) { - event.setTag('param', ['source', source]); - event.setTag('param', ['target', target]); - } -} diff --git a/lib/models/zap_receipt.dart b/lib/models/zap_receipt.dart deleted file mode 100644 index 7348254..0000000 --- a/lib/models/zap_receipt.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter_data/flutter_data.dart'; -import 'package:purplebase/purplebase.dart' as base; -import 'package:zapstore/models/nostr_adapter.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/utils/extensions.dart'; - -part 'zap_receipt.g.dart'; - -@DataAdapter([NostrAdapter, ZapReceiptAdapter]) -class ZapReceipt extends base.ZapReceipt with DataModelMixin { - final BelongsTo recipient; - final BelongsTo signer; - - @override - Object? get id => event.id; - - ZapReceipt.fromJson(super.map) - : recipient = belongsTo(map['recipient']), - signer = belongsTo(map['signer']), - super.fromJson(); - - Map toJson() => super.toMap(); -} - -mixin ZapReceiptAdapter on Adapter { - @override - DeserializedData deserialize(Object? data, {String? key}) { - final list = data is Iterable ? data : [data as Map]; - for (final e in list) { - final map = e as Map; - map['recipient'] = base.BaseUtil.getTagSet(map['tags'], 'p'); - } - return super.deserialize(data); - } - - List findByEventId(String eventId) { - final result = db.select( - "SELECT z.key, z.data from zapReceipts z, json_each(json_extract(data, '\$.tags')) WHERE json_extract(value, '\$[0]') = 'e' AND json_extract(value, '\$[1]') = ?", - [eventId]); - return deserializeFromResult(result); - } -} diff --git a/lib/models/zap_receipt.g.dart b/lib/models/zap_receipt.g.dart deleted file mode 100644 index a106043..0000000 --- a/lib/models/zap_receipt.g.dart +++ /dev/null @@ -1,72 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'zap_receipt.dart'; - -// ************************************************************************** -// AdapterGenerator -// ************************************************************************** - -// ignore_for_file: non_constant_identifier_names, duplicate_ignore - -mixin _$ZapReceiptAdapter on Adapter { - static final Map _kZapReceiptRelationshipMetas = { - 'recipient': RelationshipMeta( - name: 'recipient', - type: 'users', - kind: 'BelongsTo', - instance: (_) => (_ as ZapReceipt).recipient, - ), - 'signer': RelationshipMeta( - name: 'signer', - type: 'users', - kind: 'BelongsTo', - instance: (_) => (_ as ZapReceipt).signer, - ) - }; - - @override - Map get relationshipMetas => - _kZapReceiptRelationshipMetas; - - @override - ZapReceipt deserializeLocal(map, {String? key}) { - map = transformDeserialize(map); - return internalWrapStopInit(() => ZapReceipt.fromJson(map), key: key); - } - - @override - Map serializeLocal(model, {bool withRelationships = true}) { - final map = model.toJson(); - return transformSerialize(map, withRelationships: withRelationships); - } -} - -final _zapReceiptsFinders = {}; - -class $ZapReceiptAdapter = Adapter - with _$ZapReceiptAdapter, NostrAdapter, ZapReceiptAdapter; - -final zapReceiptsAdapterProvider = Provider>( - (ref) => $ZapReceiptAdapter(ref, InternalHolder(_zapReceiptsFinders))); - -extension ZapReceiptAdapterX on Adapter { - NostrAdapter get nostrAdapter => this as NostrAdapter; - ZapReceiptAdapter get zapReceiptAdapter => this as ZapReceiptAdapter; -} - -extension ZapReceiptRelationshipGraphNodeX - on RelationshipGraphNode { - RelationshipGraphNode get recipient { - final meta = _$ZapReceiptAdapter._kZapReceiptRelationshipMetas['recipient'] - as RelationshipMeta; - return meta.clone( - parent: this is RelationshipMeta ? this as RelationshipMeta : null); - } - - RelationshipGraphNode get signer { - final meta = _$ZapReceiptAdapter._kZapReceiptRelationshipMetas['signer'] - as RelationshipMeta; - return meta.clone( - parent: this is RelationshipMeta ? this as RelationshipMeta : null); - } -} diff --git a/lib/navigation/app_initializer.dart b/lib/navigation/app_initializer.dart deleted file mode 100644 index 7797f41..0000000 --- a/lib/navigation/app_initializer.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_data/flutter_data.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:zapstore/main.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/local_app.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/models/verify_reputation_dvm.dart'; -import 'package:zapstore/navigation/router.dart'; -import 'package:zapstore/utils/signers.dart'; -import 'package:zapstore/widgets/app_curation_container.dart'; -import 'package:purplebase/purplebase.dart' as base; - -AppLifecycleListener? _lifecycleListener; -SharedPreferences? sharedPreferences; -User? anonUser; -final amberSigner = AmberSigner(); - -final appInitializer = FutureProvider((ref) async { - sharedPreferences = await SharedPreferences.getInstance(); - final dbVersion = sharedPreferences!.getInt('dbVersion'); - - // Perform migration - if (dbVersion == null || dbVersion < kDbVersion) { - final storage = ref.read(localStorageProvider); - await storage.initialize(); - await storage.destroy(); - await sharedPreferences!.setInt('dbVersion', kDbVersion); - } - - // Initialize Flutter Data - await ref.read(initializeFlutterData(adapterProvidersMap).future); - - // NOTE: Do not use ignoreReturn here - if (ref.appCurationSets.countLocal == 0) { - // If we are here, local storage is empty - // Preload curation sets - await ref.appCurationSets.findAll(syncLocal: true); - // Preload updates in the background (no await) - ref.apps.appAdapter.checkForUpdates(); - } else { - // Preload curation sets in the background (no await) - ref.appCurationSets.findAll(syncLocal: true, background: true); - } - - // Preload zapstore's nostr curation set - ref.read(appCurationSetProvider(kNostrCurationSetLink).notifier).fetch(); - - // Initialize signer - await amberSigner.initialize(); - - // Set up anon user (pubkey derives from pkSigner secret key) - anonUser ??= User.fromPubkey( - 'c86eda2daae768374526bc54903f388d9a866c00740ec8db418d7ef2dca77b5b') - .init() - .saveLocal(); - - // Register event types - base.Event.types['VerifyReputationRequest'] = - (kind: 5312, constructor: VerifyReputationRequest.fromJson); - - // App-wide listeners - - // Register app lifecycle listener - _lifecycleListener = AppLifecycleListener( - onStateChange: (state) { - if (state == AppLifecycleState.resumed) { - ref.localApps.localAppAdapter.refreshUpdateStatus(); - } - }, - ); - - // Handle deep links - final appLinksSub = appLinks.uriLinkStream.listen((uri) async { - String? appId; - if (uri.scheme == 'https' && uri.hasFragment) { - appId = uri.fragment; - } else if (uri.scheme == 'zapstore') { - // zapstore://com.app.id - // zapstore://com.app.id?signer=npub1xyz - appId = uri.host; - } - if (appId != null) { - final adapter = ref.apps.appAdapter; - final apps = adapter.findWhereIdentifierInLocal({appId}); - // Filter by signer npub, if present, otherwise pick first - final appSignerNpub = uri.queryParameters['signer']; - var goToApp = - apps.firstWhereOrNull((a) => a.signer.value?.npub == appSignerNpub); - if (appSignerNpub == null) { - goToApp = apps.first; - } - if (goToApp != null) { - appRouter.go('/details', extra: goToApp); - } else { - appRouter.go('/'); - } - } - }); - - ref.onDispose(() { - _lifecycleListener?.dispose(); - appLinksSub.cancel(); - }); -}); diff --git a/lib/navigation/desktop_scaffold.dart b/lib/navigation/desktop_scaffold.dart deleted file mode 100644 index aadd57d..0000000 --- a/lib/navigation/desktop_scaffold.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:flutter/material.dart'; - -class DesktopScaffold extends StatelessWidget { - const DesktopScaffold({ - super.key, - required this.body, - required this.selectedIndex, - required this.onDestinationSelected, - }); - final Widget body; - final int selectedIndex; - final ValueChanged onDestinationSelected; - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Row( - children: [ - NavigationRail( - minWidth: 120, - selectedIndex: selectedIndex, - onDestinationSelected: onDestinationSelected, - labelType: NavigationRailLabelType.all, - destinations: [ - const NavigationRailDestination( - label: Text('Home'), - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home_filled), - ), - const NavigationRailDestination( - label: Text('Updates'), - icon: Icon(Icons.download_for_offline_outlined), - selectedIcon: Icon(Icons.download_for_offline), - ), - const NavigationRailDestination( - label: Text('Settings'), - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - ), - ], - ), - Flexible( - child: Container( - constraints: BoxConstraints( - maxWidth: 768, - ), - padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), - child: body, - ), - ), - ], - ), - ); - } -} diff --git a/lib/navigation/mobile_scaffold.dart b/lib/navigation/mobile_scaffold.dart deleted file mode 100644 index 7c101b8..0000000 --- a/lib/navigation/mobile_scaffold.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/utils/theme.dart'; -import 'package:zapstore/widgets/drawer_container.dart'; - -class MobileScaffold extends HookConsumerWidget { - const MobileScaffold({ - super.key, - required this.body, - required this.selectedIndex, - required this.onDestinationSelected, - }); - final Widget body; - final int selectedIndex; - final ValueChanged onDestinationSelected; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final appsToUpdate = ref.watch(appsToUpdateProvider); - - return Scaffold( - key: scaffoldKey, - body: Padding( - padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), - child: body, - ), - bottomNavigationBar: NavigationBar( - height: 60, - backgroundColor: kBackgroundColor, - indicatorColor: Colors.transparent, - selectedIndex: selectedIndex, - labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, - destinations: [ - NavigationDestination( - label: 'Home', - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home_filled), - ), - NavigationDestination( - label: 'Updates', - icon: appsToUpdate > 0 - ? Badge( - label: Text(appsToUpdate.toString()), - child: Icon(Icons.update_outlined), - ) - : Icon(Icons.update_outlined), - selectedIcon: appsToUpdate > 0 - ? Badge( - label: Text(appsToUpdate.toString()), - child: Icon(Icons.update), - ) - : Icon(Icons.update), - ), - NavigationDestination( - label: 'Settings', - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - ), - ], - onDestinationSelected: onDestinationSelected, - ), - drawer: DrawerContainer(), - ); - } -} - -final scaffoldKey = GlobalKey(); diff --git a/lib/navigation/router.dart b/lib/navigation/router.dart deleted file mode 100644 index e1c4172..0000000 --- a/lib/navigation/router.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:zapstore/main.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/nostr_adapter.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/navigation/app_initializer.dart'; -import 'package:zapstore/navigation/desktop_scaffold.dart'; -import 'package:zapstore/navigation/mobile_scaffold.dart'; -import 'package:zapstore/screens/app_detail_screen.dart'; -import 'package:zapstore/screens/developer_screen.dart'; -import 'package:zapstore/screens/search_screen.dart'; -import 'package:zapstore/screens/settings_screen.dart'; -import 'package:zapstore/screens/updates_screen.dart'; -import 'package:zapstore/widgets/error_container.dart'; -import 'package:zapstore/widgets/spinning_logo.dart'; - -/// Application navigation. -/// - Initializes Go Router, dispatches routes -/// - Initializes Flutter Data (database) and Purplebase (nostr library) -/// - Builds media query dependent scaffold (mobile, desktop) -final appRouter = GoRouter( - initialLocation: '/', - // * Passing a navigatorKey causes an issue on hot reload: - // * https://github.com/flutter/flutter/issues/113757#issuecomment-1518421380 - // * However it's still necessary otherwise the navigator pops back to - // * root on hot reload - navigatorKey: _rootNavigatorKey, - routes: [ - StatefulShellRoute.indexedStack( - builder: (context, state, navigationShell) { - return ScaffoldWithNestedNavigation(navigationShell: navigationShell); - }, - branches: [ - StatefulShellBranch( - navigatorKey: _searchNavigatorKey, - routes: [ - GoRoute( - path: '/', - builder: (context, state) => SearchScreen(), - routes: [appDetailsRoute, developerRoute], - ), - ], - ), - StatefulShellBranch( - navigatorKey: _updatesNavigatorKey, - routes: [ - GoRoute( - path: '/updates', - builder: (context, state) => UpdatesScreen(), - routes: [appDetailsRoute, developerRoute], - ), - ], - ), - StatefulShellBranch( - navigatorKey: _settingsNavigatorKey, - routes: [ - GoRoute( - path: '/settings', - pageBuilder: (context, state) => NoTransitionPage( - child: SettingsScreen(), - ), - ), - ], - ), - ], - ), - ], -); - -final appDetailsRoute = GoRoute( - path: 'details', - builder: (context, state) => AppDetailScreen(model: state.extra as App), -); - -final developerRoute = GoRoute( - path: 'developer', - builder: (context, state) => DeveloperScreen(model: state.extra as User), -); - -class ScaffoldWithNestedNavigation extends HookConsumerWidget { - const ScaffoldWithNestedNavigation({ - Key? key, - required this.navigationShell, - }) : super(key: key ?? const ValueKey('ScaffoldWithNestedNavigation')); - final StatefulNavigationShell navigationShell; - - void onDestinationSelected(int index) { - navigationShell.goBranch( - index, - // A common pattern when using bottom navigation bars is to support - // navigating to the initial location when tapping the item that is - // already active. This example demonstrates how to support this behavior, - // using the initialLocation parameter of goBranch. - initialLocation: index == navigationShell.currentIndex, - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final initializer = ref.watch(appInitializer); - return SafeArea( - child: initializer.when( - data: (_) => LayoutBuilder( - builder: (context, constraints) { - // At this point initialization is done - // listen to relay - ref.watch(relayListenerProvider); - - if (constraints.maxWidth < 550) { - return MobileScaffold( - body: navigationShell, - selectedIndex: navigationShell.currentIndex, - onDestinationSelected: onDestinationSelected, - ); - } else { - return DesktopScaffold( - body: navigationShell, - selectedIndex: navigationShell.currentIndex, - onDestinationSelected: onDestinationSelected, - ); - } - }, - ), - error: (e, stack) { - errorHandler(e, stack); - return ErrorContainer(exception: e, stack: stack); - }, - loading: () => Center(child: SpinningLogo()), - ), - ); - } -} - -// Keys - -final _rootNavigatorKey = GlobalKey(); -final _searchNavigatorKey = GlobalKey(debugLabel: 'search'); -final _updatesNavigatorKey = GlobalKey(debugLabel: 'updates'); -final _settingsNavigatorKey = GlobalKey(debugLabel: 'settings'); diff --git a/lib/router.dart b/lib/router.dart new file mode 100644 index 0000000..27169c9 --- /dev/null +++ b/lib/router.dart @@ -0,0 +1,127 @@ +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/screens/main_scaffold.dart'; +import 'package:zapstore/screens/app_detail_screen.dart'; +import 'package:zapstore/screens/app_stack_screen.dart'; +import 'package:zapstore/screens/user_screen.dart'; +import 'package:zapstore/screens/search_screen.dart'; +import 'package:zapstore/screens/updates_screen.dart'; +import 'package:zapstore/screens/profile_screen.dart'; + +/// Root paths for each navigation branch (used for back navigation handling) +const kBranchRoots = ['/search', '/updates', '/profile']; + +typedef _ResolvedRoute = ({String identifier, String? author}); + +_ResolvedRoute _resolveNaddrRouteId(String rawId) { + if (rawId.startsWith('naddr1')) { + try { + final decoded = Utils.decodeShareableIdentifier(rawId); + if (decoded is AddressData) { + return (identifier: decoded.identifier, author: decoded.author); + } + } catch (_) { + // Fall back to treating it as a plain identifier. + } + } + return (identifier: rawId, author: null); +} + +/// Helper to build app detail route +GoRoute _appDetailRoute() { + return GoRoute( + path: 'app/:id', + builder: (context, state) { + final rawId = state.pathParameters['id']!; + final resolved = _resolveNaddrRouteId(rawId); + return AppDetailScreen( + appId: resolved.identifier, + authorPubkey: resolved.author, + ); + }, + ); +} + +/// Helper to build stack detail route +GoRoute _stackDetailRoute() { + return GoRoute( + path: 'stack/:id', + builder: (context, state) { + final rawId = state.pathParameters['id']!; + final resolved = _resolveNaddrRouteId(rawId); + return AppStackScreen( + stackId: resolved.identifier, + authorPubkey: resolved.author, + ); + }, + ); +} + +/// Helper to build user route +GoRoute _userRoute() { + return GoRoute( + path: 'user/:pubkey', + builder: (context, state) { + final pubkey = state.pathParameters['pubkey']!; + return UserScreen(pubkey: pubkey); + }, + ); +} + +final routerProvider = Provider((ref) { + return GoRouter( + initialLocation: '/search', + routes: [ + // Top-level route for market:// intents + // Redirect to search branch to show with nav bar + GoRoute( + path: '/market/:packageId', + redirect: (context, state) { + final packageId = state.pathParameters['packageId']; + if (packageId == null || packageId.isEmpty) { + return '/search'; + } + return '/search/app/$packageId'; + }, + ), + // Single stateful shell route that handles everything + StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) => + MainScaffold(navigationShell: navigationShell), + branches: [ + // Search tab branch with nested detail routes + StatefulShellBranch( + routes: [ + GoRoute( + path: '/search', + builder: (context, state) => const SearchScreen(), + routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()], + ), + ], + ), + // Updates tab branch with nested detail routes + StatefulShellBranch( + routes: [ + GoRoute( + path: '/updates', + builder: (context, state) => const UpdatesScreen(), + routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()], + ), + ], + ), + // Profile tab branch with nested detail routes + StatefulShellBranch( + routes: [ + GoRoute( + path: '/profile', + builder: (context, state) => const ProfileScreen(), + routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()], + ), + ], + ), + ], + ), + ], + ); +}); diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index c4c3ed0..7de82a3 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -1,357 +1,712 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:easy_image_viewer/easy_image_viewer.dart'; +import 'dart:convert'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/release.dart'; +import 'package:zapstore/services/bookmarks_service.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/utils/debug_utils.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/widgets/app_detail_widgets.dart'; +import 'package:zapstore/widgets/app_header.dart'; +import 'package:zapstore/widgets/app_info_table.dart'; +import 'package:zapstore/widgets/author_container.dart'; +import 'package:zapstore/widgets/comments_section.dart'; +import 'package:zapstore/widgets/download_text_container.dart'; +import 'package:zapstore/widgets/expandable_markdown.dart'; import 'package:zapstore/widgets/install_button.dart'; -import 'package:zapstore/widgets/release_card.dart'; -import 'package:zapstore/widgets/signer_container.dart'; -import 'package:zapstore/widgets/users_rich_text.dart'; -import 'package:zapstore/widgets/versioned_app_header.dart'; -import 'package:zapstore/widgets/zap_button.dart'; -import 'package:zapstore/widgets/zap_receipts.dart'; +import 'package:zapstore/widgets/screenshots_gallery.dart'; class AppDetailScreen extends HookConsumerWidget { - final App model; - AppDetailScreen({ - required this.model, - super.key, - }); + const AppDetailScreen({super.key, required this.appId, this.authorPubkey}); + + final String appId; + final String? authorPubkey; @override Widget build(BuildContext context, WidgetRef ref) { - final scrollController = ScrollController(); + final platform = ref.read(packageManagerProvider.notifier).platform; - final snapshot = useFuture(useMemoized(() async { - // Skip cache to actually hit the remote to refresh - return await ref.apps - .findOne(model.identifier, remote: true, params: {'skipCache': true}); - })); + // Query app with relationships + final appState = ref.watch( + query( + authors: authorPubkey != null ? {authorPubkey!} : null, + tags: { + '#d': {appId}, + '#f': {platform}, + }, + limit: 1, + and: (a) => { + a.latestRelease, + a.latestRelease.value?.latestMetadata, + a.latestRelease.value?.latestAsset, + }, + // stream=true ensures cached/local results render immediately; remote + // results will merge in as they arrive. + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), + subscriptionPrefix: authorPubkey != null + ? 'app-detail-${authorPubkey!}-$appId' + : 'app-detail-$appId', + ), + ); - final state = ref.apps.watchOne(model.id!, - alsoWatch: (_) => { - _.localApp, - _.releases, - _.releases.artifacts, - _.signer, - }); + // Handle loading/error states when app not yet available + return switch (appState) { + StorageError(:final exception) => _ErrorScaffold( + message: exception.toString(), + ), + StorageData() => _AppDetailContent( + app: appState.models.firstOrNull, + appState: appState, + ), + StorageLoading() => const Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.all(16), + child: AppDetailSkeleton(), + ), + ), + ), + }; + } +} - final app = state.model ?? model; +class _ErrorScaffold extends StatelessWidget { + final String message; + const _ErrorScaffold({required this.message}); - final curatedBy = ref.appCurationSets - .findAllLocal() - .where((s) => s.appIds.contains(app.identifier)) - .map((s) => s.signer.value) - .nonNulls - .toSet(); + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Open App')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 64), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text(message, textAlign: TextAlign.center), + ), + ], + ), + ), + ); + } +} - // If request returned and result was null, go back - if (snapshot.connectionState == ConnectionState.done && - snapshot.data == null) { - return Center( - child: ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text( - 'This app is no longer available.\nPress to go back.', +/// Internal widget that displays app details +class _AppDetailContent extends HookConsumerWidget { + final App? app; + final StorageState appState; + + const _AppDetailContent({required this.app, required this.appState}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final app = this.app; + if (app == null) { + return const _ErrorScaffold(message: 'App not found'); + } + + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + final isSignedIn = signedInPubkey != null; + final showDebugSections = isDebugMode(signedInPubkey); + + // Query author profile from social relays + final authorState = ref.watch( + query( + authors: {app.pubkey}, + source: const LocalAndRemoteSource( + relays: {'social', 'vertex'}, + cachedFor: Duration(hours: 2), + ), + ), + ); + final author = switch (authorState) { + StorageData(:final models) => models.firstOrNull, + _ => null, + }; + + final latestRelease = app.latestRelease.value; + final latestMetadata = app.latestFileMetadata; + + // Check if app is installed for menu options + final installedPackage = ref.watch( + installedPackageProvider(app.identifier), + ); + final isInstalled = installedPackage != null; + + // Show skeleton while relationships are loading + if (latestRelease == null || latestMetadata == null) { + return Scaffold( + body: SafeArea( + child: Stack( + children: [ + const SingleChildScrollView( + padding: EdgeInsets.only(top: 16, bottom: 80), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: AppDetailSkeleton(), + ), + ), + InstallButton(app: app, release: latestRelease), + _buildFloatingMenu(context, ref, app, isInstalled, isSignedIn), + ], ), ), ); } - return RefreshIndicator( - onRefresh: () => ref.apps.findOne(model.identifier, remote: true), - child: Column( - children: [ - Expanded( - child: CustomScrollView( - slivers: [ - SliverList( - delegate: SliverChildListDelegate( - [ - VersionedAppHeader(app: app), - Gap(16), - if (app.hasCertificateMismatch) - Container( - decoration: BoxDecoration( - color: Colors.orange[900], - borderRadius: BorderRadius.circular(10), - ), - child: Padding( - padding: const EdgeInsets.all(10), - child: Text( - '${app.name} ${app.latestMetadata!.version} has a different Android certificate than version ${app.localApp.value!.installedVersion} installed on your device.\n\nHave you used another app store to install ${app.name}? If so, you can choose to remove it and re-install coming back to this screen.\n\nIt otherwise could be a malicious update, contact the developer for details.\n\nNew certificate hash: ${app.latestMetadata!.apkSignatureHash}', - style: TextStyle(fontWeight: FontWeight.bold), - ), - ), - ), - if (app.images.isNotEmpty) - Scrollbar( - controller: scrollController, - interactive: true, - trackVisibility: true, - child: SingleChildScrollView( - controller: scrollController, - scrollDirection: Axis.horizontal, - child: SizedBox( - height: 320, - child: Row( - children: [ - for (final i in app.images) - GestureDetector( - onTap: () { - final imageProvider = - CachedNetworkImageProvider(i, - scale: 0.6); - showImageViewer( - context, - imageProvider, - doubleTapZoomable: true, - ); - }, - child: Padding( - padding: - const EdgeInsets.only(right: 12), - child: CachedNetworkImage( - imageUrl: i, - errorWidget: (_, __, ___) => - Container(), - ), - ), - ), - ], - ), - ), - ), - ), - Divider(height: 24), - if (curatedBy.isNotEmpty) - Container( - decoration: BoxDecoration( - color: Colors.grey[900], - borderRadius: BorderRadius.circular(10), - ), - child: Padding( - padding: const EdgeInsets.all(10), - child: UsersRichText( - trailingText: ' picked this app', - users: curatedBy.toList(), - ), - ), - ), - if (curatedBy.isNotEmpty) Gap(20), - MarkdownBody( - styleSheet: MarkdownStyleSheet( - h1: TextStyle(fontWeight: FontWeight.bold), - h2: TextStyle(fontWeight: FontWeight.bold), - p: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w300, - ), - ), - selectable: false, - data: app.event.content.parseEmojis(), - ), - Gap(10), - Padding( - padding: const EdgeInsets.only(right: 14), - child: SignerContainer(app: app), - ), - if (app.latestMetadata != null && - app.signer.isPresent && - app.isSelfSigned) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Gap(10), - SizedBox( - width: double.infinity, - child: ZapButton(app: app)), - Gap(10), - ZapReceipts(app: app), - ], - ), - Gap(20), - Container( - padding: EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.grey[900], - borderRadius: BorderRadius.circular(10), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text('Source'), - Gap(10), - Flexible( - child: GestureDetector( - onTap: () { - if (app.repository != null) { - launchUrl(Uri.parse(app.repository!)); - } - }, - child: app.repository != null - ? AutoSizeText( - app.repository!, - minFontSize: 12, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) - : Text('Source code not available', - style: TextStyle( - color: Colors.red[300], - fontWeight: FontWeight.bold)), - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.all(8), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text('License'), - Text((app.license == null || - app.license == 'NOASSERTION') - ? 'Unknown' - : app.license!) - ], - ), - ), - Padding( - padding: const EdgeInsets.all(8), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text('App ID'), - Gap(10), - Flexible( - child: AutoSizeText( - app.identifier, - minFontSize: 12, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ) - ], - ), - ), - if (app.latestMetadata != null) - Padding( - padding: const EdgeInsets.all(8), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text('APK package SHA-256'), - Flexible( - child: GestureDetector( - onTap: () { - Clipboard.setData(ClipboardData( - text: app.latestMetadata!.hash!)); - context.showInfo( - 'Copied APK package SHA-256 to the clipboard'); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - app.latestMetadata!.hash!.shorten, - maxLines: 1, - ), - Gap(6), - Icon(Icons.copy_rounded, size: 18) - ], - ), - ), - ), - ], - ), - ), - if (app.latestMetadata?.apkSignatureHash != null) - Padding( - padding: const EdgeInsets.all(8), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text('APK certificate SHA-256'), - Flexible( - child: GestureDetector( - onTap: () { - Clipboard.setData(ClipboardData( - text: app.latestMetadata! - .apkSignatureHash!)); - context.showInfo( - 'Copied APK certificate SHA-256 to the clipboard'); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - app.latestMetadata! - .apkSignatureHash!.shorten, - maxLines: 1, - ), - Gap(6), - Icon(Icons.copy_rounded, size: 18) - ], - ), - ), - ), - ], - ), - ), - ], - ), - ), - Divider(height: 60), - Text( - 'Latest release'.toUpperCase(), - style: TextStyle( - fontSize: 16, - letterSpacing: 3, - fontWeight: FontWeight.w300, - ), - ), - Gap(10), - if (app.releases.isEmpty) Text('No available releases'), - if (app.releases.isNotEmpty) - ReleaseCard( - release: - app.releases.toList().sortedByLatest.first), - ], + return Scaffold( + body: SafeArea( + child: Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.only(top: 16, bottom: 80), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // App header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: AppHeader(app: app), ), - ), + + // Published by / Released at section + if (app.isRelaySigned) + Padding( + padding: const EdgeInsets.only( + left: 16, + right: 16, + bottom: 8, + ), + child: DownloadTextContainer( + url: latestMetadata.urls.first, + size: 14, + ), + ) + else + Padding( + padding: const EdgeInsets.only( + left: 16, + right: 16, + bottom: 8, + ), + child: author != null + ? AuthorContainer( + profile: author, + beforeText: 'Published by', + oneLine: true, + size: 14, + app: app, + onTap: () { + final segments = GoRouterState.of( + context, + ).uri.pathSegments; + final first = segments.isNotEmpty + ? segments.first + : 'search'; + context.push('/$first/user/${author.pubkey}'); + }, + ) + : const AuthorSkeleton(), + ), + + // Screenshots gallery + if (app.images.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ScreenshotsGallery(app: app), + ), + + // App description + if (app.description.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: 20, + right: 20, + top: 8, + ), + child: ExpandableMarkdown( + data: app.description, + styleSheet: + MarkdownStyleSheet.fromTheme( + Theme.of(context), + ).copyWith( + p: context.textTheme.bodyLarge?.copyWith( + height: 1.6, + ), + blockquoteDecoration: BoxDecoration( + color: const Color(0xFF1E3A5F), // Dark blue + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + + // Social action buttons + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + SocialActionsRow(app: app, author: author), + const SizedBox(height: 16), + ], + ), + ), + + // Latest release section + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Container( + height: 1, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.2), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Text( + 'LATEST RELEASE', + style: context.textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurface + .withValues(alpha: 0.85), + letterSpacing: 1.5, + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + child: Container( + height: 1, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.2), + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Version:', + style: context.textTheme.bodyMedium, + ), + Gap(4), + Text( + latestMetadata.version, + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Gap(4), + Text( + '(${formatDate(latestMetadata.createdAt)})', + style: context.textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface + .withValues(alpha: 0.6), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: ReleaseNotes(release: latestRelease), + ), + ], + ), + ), + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: AppInfoTable(app: app, fileMetadata: latestMetadata), + ), + + CommentsSection(app: app, fileMetadata: latestMetadata), + + // Debug section + if (showDebugSections) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: DebugVersionsSection(app: app), + ), + + const SizedBox(height: 100), + ], + ), + ), + + // Sticky install button + InstallButton(app: app, release: latestRelease), + + // Floating three-dot menu + _buildFloatingMenu(context, ref, app, isInstalled, isSignedIn), + ], + ), + ), + ); + } + + Widget _buildFloatingMenu( + BuildContext context, + WidgetRef ref, + App app, + bool isInstalled, + bool isSignedIn, + ) { + return Positioned( + top: 8, + right: 8, + child: _buildOverflowMenu(context, ref, app, isInstalled, isSignedIn), + ); + } + + Widget _buildOverflowMenu( + BuildContext context, + WidgetRef ref, + App app, + bool isInstalled, + bool isSignedIn, + ) { + // Watch saved apps to check if app is saved + final savedAppsAsync = ref.watch(bookmarksProvider); + final savedAppIds = savedAppsAsync.when( + data: (ids) => ids, + loading: () => {}, + error: (_, __) => {}, + ); + final appAddressableId = + '${app.event.kind}:${app.pubkey}:${app.identifier}'; + final isSaved = savedAppIds.contains(appAddressableId); + + return Material( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9), + shape: const CircleBorder(), + elevation: 2, + child: PopupMenuButton( + icon: const Icon(Icons.more_vert), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + onSelected: (value) { + switch (value) { + case 'share': + _shareApp(context, app); + break; + case 'copy_link': + _copyLink(context, app); + break; + case 'save_app': + _toggleSaveApp(context, ref, app, isSaved); + break; + case 'view_publisher': + _viewPublisher(context, app); + break; + case 'open_browser': + _openInBrowser(context, app); + break; + case 'open': + _openApp(context, ref, app); + break; + case 'delete': + _uninstallApp(context, ref, app); + break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'share', + child: Row( + children: [Icon(Icons.share), SizedBox(width: 12), Text('Share')], + ), + ), + const PopupMenuItem( + value: 'copy_link', + child: Row( + children: [ + Icon(Icons.link), + SizedBox(width: 12), + Text('Copy link'), ], ), ), - SizedBox( - height: 50, - child: Center( - child: InstallButton(app: app), + if (isSignedIn) + PopupMenuItem( + value: 'save_app', + child: Row( + children: [ + Icon(isSaved ? Icons.bookmark : Icons.bookmark_border), + const SizedBox(width: 12), + Text(isSaved ? 'Remove from saved' : 'Save app'), + ], + ), + ), + const PopupMenuItem( + value: 'view_publisher', + child: Row( + children: [ + Icon(Icons.person), + SizedBox(width: 12), + Text('View publisher'), + ], ), ), + const PopupMenuItem( + value: 'open_browser', + child: Row( + children: [ + Icon(Icons.open_in_browser), + SizedBox(width: 12), + Text('Open in browser'), + ], + ), + ), + if (isInstalled) ...[ + const PopupMenuItem( + value: 'open', + child: Row( + children: [ + Icon(Icons.open_in_new), + SizedBox(width: 12), + Text('Open'), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete_outline), + SizedBox(width: 12), + Text('Delete'), + ], + ), + ), + ], ], ), ); } + + String _getAppUrl(App app) { + final naddr = Utils.encodeShareableIdentifier( + AddressInput( + identifier: app.identifier, + author: app.pubkey, + kind: app.event.kind, + relays: [], + ), + ); + return 'https://zapstore.dev/apps/$naddr'; + } + + void _shareApp(BuildContext context, App app) { + try { + final shareUrl = _getAppUrl(app); + SharePlus.instance.share(ShareParams(text: shareUrl)); + } catch (e) { + if (context.mounted) { + context.showError('Failed to share app', description: '$e'); + } + } + } + + void _copyLink(BuildContext context, App app) { + try { + final shareUrl = _getAppUrl(app); + Clipboard.setData(ClipboardData(text: shareUrl)); + context.showInfo('Link copied to clipboard'); + } catch (e) { + if (context.mounted) { + context.showError('Failed to copy link', description: '$e'); + } + } + } + + Future _toggleSaveApp( + BuildContext context, + WidgetRef ref, + App app, + bool isCurrentlySaved, + ) async { + try { + final signer = ref.read(Signer.activeSignerProvider); + final signedInPubkey = ref.read(Signer.activePubkeyProvider); + + if (signer == null || signedInPubkey == null) { + if (context.mounted) { + context.showError( + 'Sign in required', + description: 'You need to sign in to save apps.', + ); + } + return; + } + + // Query for existing stack + final existingStackState = await ref.storage.query( + RequestFilter( + authors: {signedInPubkey}, + tags: { + '#d': {kAppBookmarksIdentifier}, + }, + ).toRequest(), + source: const LocalSource(), + ); + final existingStack = existingStackState.firstOrNull; + + // Get existing app IDs by decrypting if stack exists + List existingAppIds = []; + if (existingStack != null) { + try { + final decryptedContent = await signer.nip44Decrypt( + existingStack.content, + signedInPubkey, + ); + existingAppIds = (jsonDecode(decryptedContent) as List) + .cast(); + } catch (e) { + if (context.mounted) { + context.showError( + 'Could not read existing saved apps', + description: + 'Your previous saved apps could not be decrypted. Starting fresh.\n\n$e', + ); + } + } + } + + // Modify the list + final appAddressableId = + '${app.event.kind}:${app.pubkey}:${app.identifier}'; + + if (isCurrentlySaved) { + existingAppIds.remove(appAddressableId); + } else { + if (!existingAppIds.contains(appAddressableId)) { + existingAppIds.add(appAddressableId); + } + } + + // Create new partial stack with updated list + final partialStack = PartialAppStack.withEncryptedApps( + name: 'Saved Apps', + identifier: kAppBookmarksIdentifier, + apps: existingAppIds, + ); + + // Sign (encrypts the content) + final signedStack = await partialStack.signWith(signer); + + // Save to local storage and publish to relays + await ref.storage.save({signedStack}); + ref.storage.publish({ + signedStack, + }, source: RemoteSource(relays: 'social')); + + if (context.mounted) { + context.showInfo( + isCurrentlySaved ? 'App removed from saved' : 'App saved', + ); + } + } catch (e) { + if (context.mounted) { + context.showError('Failed to update bookmark', description: '$e'); + } + } + } + + void _viewPublisher(BuildContext context, App app) { + final segments = GoRouterState.of(context).uri.pathSegments; + final first = segments.isNotEmpty ? segments.first : 'search'; + context.push('/$first/user/${app.pubkey}'); + } + + Future _openInBrowser(BuildContext context, App app) async { + try { + final url = _getAppUrl(app); + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + if (context.mounted) { + context.showError('Could not open browser'); + } + } + } catch (e) { + if (context.mounted) { + context.showError('Failed to open browser', description: '$e'); + } + } + } + + Future _openApp(BuildContext context, WidgetRef ref, App app) async { + try { + final packageManager = ref.read(packageManagerProvider.notifier); + await packageManager.launchApp(app.identifier); + } catch (e) { + if (!context.mounted) return; + context.showError( + 'Failed to launch ${app.name ?? app.identifier}', + description: + 'The app may have been uninstalled or moved. Try reinstalling.\n\n$e', + ); + } + } + + Future _uninstallApp( + BuildContext context, + WidgetRef ref, + App app, + ) async { + try { + final packageManager = ref.read(packageManagerProvider.notifier); + await packageManager.uninstall(app.identifier); + // Only reaches here after successful uninstall + if (context.mounted) { + context.showInfo('${app.name ?? app.identifier} has been uninstalled'); + } + } catch (e) { + if (context.mounted) { + // Don't show error for user cancellation + final message = e.toString(); + if (!message.contains('cancelled')) { + context.showError('Uninstall failed', description: '$e'); + } + } + } + } } diff --git a/lib/screens/app_stack_screen.dart b/lib/screens/app_stack_screen.dart new file mode 100644 index 0000000..2007ceb --- /dev/null +++ b/lib/screens/app_stack_screen.dart @@ -0,0 +1,366 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/app_card.dart'; +import 'package:zapstore/widgets/author_container.dart'; +import 'package:zapstore/widgets/comments_section.dart'; +import 'package:zapstore/widgets/common/badges.dart'; +import 'package:zapstore/theme.dart'; + +class AppStackScreen extends HookConsumerWidget { + const AppStackScreen({super.key, required this.stackId, this.authorPubkey}); + + final String stackId; + final String? authorPubkey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Query stack with apps relationship + final stackState = ref.watch( + query( + authors: authorPubkey != null ? {authorPubkey!} : null, + tags: { + '#d': {stackId}, + }, + limit: 1, + and: (stack) => {stack.apps}, + source: LocalAndRemoteSource(stream: true, relays: 'social'), + andSource: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + subscriptionPrefix: authorPubkey != null + ? 'app-stack-${authorPubkey!}-$stackId' + : 'app-stack-$stackId', + ), + ); + + return switch (stackState) { + StorageError(:final exception) => _ErrorScaffold( + message: exception.toString(), + ), + StorageData(:final models) => _AppStackContentWithApps( + stack: models.firstOrNull, + ), + StorageLoading() => Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: _AppStackSkeleton(), + ), + ), + ), + }; + } +} + +/// Intermediate widget that loads apps with their release relationships +class _AppStackContentWithApps extends HookConsumerWidget { + final AppStack? stack; + + const _AppStackContentWithApps({required this.stack}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (stack == null) { + return const _ErrorScaffold(message: 'Stack not found'); + } + + // Get app identifiers from stack's apps relationship + final stackApps = stack!.apps.toList(); + final appIdentifiers = stackApps.map((app) => app.identifier).toSet(); + + if (appIdentifiers.isEmpty) { + return _AppStackContent(stack: stack!, apps: const []); + } + + // Query apps with release and metadata relationships (same pattern as search/user screens) + final appsState = ref.watch( + query( + tags: {'#d': appIdentifiers}, + and: (app) => { + app.latestRelease, + app.latestRelease.value?.latestMetadata, + app.latestRelease.value?.latestAsset, + }, + source: const LocalAndRemoteSource(relays: 'AppCatalog'), + subscriptionPrefix: 'app-stack-apps-${stack!.identifier}', + ), + ); + + // Map loaded apps by identifier for ordering + final appsMap = {for (final app in appsState.models) app.identifier: app}; + + // Preserve stack order, using loaded apps with their relationships + final orderedApps = stackApps + .map((app) => appsMap[app.identifier] ?? app) + .toList(); + + return _AppStackContent(stack: stack!, apps: orderedApps); + } +} + +class _ErrorScaffold extends StatelessWidget { + final String message; + const _ErrorScaffold({required this.message}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('App Stack')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 64), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text(message, textAlign: TextAlign.center), + ), + ], + ), + ), + ); + } +} + +/// Internal widget that displays stack details +class _AppStackContent extends HookConsumerWidget { + final AppStack stack; + final List apps; + + const _AppStackContent({required this.stack, required this.apps}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Query author profile from social relays + final authorState = ref.watch( + query( + authors: {stack.pubkey}, + source: const LocalAndRemoteSource( + relays: {'social', 'vertex'}, + cachedFor: Duration(hours: 2), + ), + ), + ); + final author = switch (authorState) { + StorageData(:final models) => models.firstOrNull, + _ => null, + }; + + // Sort apps: uninstalled first, keeping original order otherwise + final packageManager = ref.watch(packageManagerProvider.notifier); + final sortedApps = _sortAppsUninstalledFirst(apps, packageManager); + final totalApps = sortedApps.length; + + return Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.only(top: 16, bottom: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Stack header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _StackHeader(stack: stack, author: author), + ), + const SizedBox(height: 24), + // Apps section header with count badge + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Text( + 'Apps in this stack', + style: context.textTheme.titleLarge, + ), + const SizedBox(width: 8), + CountBadge( + count: totalApps, + color: AppColors.darkPillBackground, + ), + ], + ), + ), + const SizedBox(height: 12), + // Apps list + if (sortedApps.isEmpty) + _EmptyAppsPlaceholder() + else + ...sortedApps.map( + (app) => AppCard(app: app, showUpdateArrow: app.hasUpdate), + ), + // Comments section + Padding( + padding: const EdgeInsets.only(top: 24), + child: StackCommentsSection(stack: stack), + ), + ], + ), + ), + ), + ); + } + + /// Sort apps so uninstalled ones come first, keeping original order otherwise + List _sortAppsUninstalledFirst( + List apps, + PackageManager packageManager, + ) { + final uninstalled = []; + final installed = []; + + for (final app in apps) { + if (packageManager.isInstalled(app.identifier)) { + installed.add(app); + } else { + uninstalled.add(app); + } + } + + return [...uninstalled, ...installed]; + } +} + +/// Stack header with name and author +class _StackHeader extends StatelessWidget { + const _StackHeader({required this.stack, this.author}); + + final AppStack stack; + final Profile? author; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Stack name + Text( + stack.name ?? stack.identifier, + style: context.textTheme.headlineMedium, + ), + const SizedBox(height: 8), + // Published by author (same style as app_detail_screen) + if (author != null) + AuthorContainer( + profile: author!, + beforeText: 'Published by', + oneLine: true, + size: 14, + onTap: () { + final segments = GoRouterState.of(context).uri.pathSegments; + final first = segments.isNotEmpty ? segments.first : 'search'; + context.push('/$first/user/${stack.pubkey}'); + }, + ), + ], + ); + } +} + +/// Empty state when no apps in stack +class _EmptyAppsPlaceholder extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + Icon( + Icons.apps_outlined, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'No apps in this stack', + style: context.textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + +/// Skeleton loading state for the stack screen +class _AppStackSkeleton extends StatelessWidget { + @override + Widget build(BuildContext context) { + return SkeletonizerConfig( + data: AppColors.getSkeletonizerConfig(Theme.of(context).brightness), + child: Skeletonizer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header skeleton + Container( + height: 28, + width: 180, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(14), + ), + ), + const SizedBox(width: 8), + Container( + height: 16, + width: 100, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + const SizedBox(height: 24), + // Section header with count badge + Row( + children: [ + Container( + height: 20, + width: 140, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 8), + Container( + height: 20, + width: 24, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(10), + ), + ), + ], + ), + const SizedBox(height: 12), + // App cards skeleton (using AppCard skeleton) + ...List.generate(3, (_) => const AppCard(isLoading: true)), + ], + ), + ), + ); + } +} diff --git a/lib/screens/developer_screen.dart b/lib/screens/developer_screen.dart deleted file mode 100644 index 2939aa8..0000000 --- a/lib/screens/developer_screen.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/widgets/app_card.dart'; -import 'package:zapstore/widgets/relevant_who_follow_container.dart'; -import 'package:zapstore/widgets/rounded_image.dart'; - -class DeveloperScreen extends HookConsumerWidget { - final User model; - - DeveloperScreen({ - required this.model, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - useFuture(useMemoized(() async { - if (model.pubkey != kZapstorePubkey) { - await ref.apps.findAll( - params: { - 'authors': [model.pubkey], - 'ignoreReturn': false, - }, - ); - } - })); - - final state = ref.users.watchOne(model.id!, alsoWatch: (_) => {_.apps}); - - return SingleChildScrollView( - physics: AlwaysScrollableScrollPhysics(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - RoundedImage(url: model.avatarUrl, size: 80), - Gap(10), - Expanded( - child: AutoSizeText( - model.nameOrNpub, - style: TextStyle(fontSize: 34, fontWeight: FontWeight.bold), - ), - ), - ], - ), - Gap(20), - // if (model.apps.first.latestMetadata != null) - // ZapReceipts(developer: model), - RelevantWhoFollowContainer( - toNpub: model.npub, - loadingText: 'Loading connections...', - ), - Gap(10), - ElevatedButton( - onPressed: () { - final url = Uri.parse('https://npub.world/${model.npub}'); - launchUrl(url); - }, - child: Text( - 'View ${model.nameOrNpub} on nostr', - ), - ), - Gap(20), - Text('Signed apps', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), - Gap(10), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (state.hasModel) - for (final app in model.apps.where((a) => a.isSelfSigned)) - AppCard(model: app, showUpdate: true), - ], - ), - ], - ), - ); - } -} diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart new file mode 100644 index 0000000..af97816 --- /dev/null +++ b/lib/screens/main_scaffold.dart @@ -0,0 +1,365 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/router.dart'; +import 'package:zapstore/services/updates_service.dart'; +import 'package:zapstore/widgets/common/badges.dart'; +import 'package:zapstore/widgets/zapstore_update_prompt_listener.dart'; +import '../widgets/common/profile_avatar.dart'; +import '../theme.dart'; + +/// Main scaffold with bottom navigation that adapts to screen size +class MainScaffold extends StatelessWidget { + const MainScaffold({super.key, required this.navigationShell}); + + final StatefulNavigationShell navigationShell; + + /// Handle bottom navigation tap + void _onBottomNavTap( + BuildContext context, + StatefulNavigationShell navigationShell, + int index, + ) { + if (navigationShell.currentIndex == index) { + // Already on this tab, pop to root of the navigation stack + final router = GoRouter.of(context); + while (router.canPop()) { + router.pop(); + } + } else { + // Switch to the selected tab + navigationShell.goBranch(index); + } + } + + @override + Widget build(BuildContext context) { + final router = GoRouter.of(context); + + return PopScope( + canPop: false, // Never let system close the app via back gesture + onPopInvokedWithResult: (didPop, result) { + if (!didPop) { + // Check if we can pop within the current shell branch + // canPop() may return true for shell-level navigation, so we need + // to verify we're not at a branch root before popping + final currentLocation = + router.routerDelegate.currentConfiguration.uri.path; + final isAtBranchRoot = kBranchRoots.contains(currentLocation); + + if (!isAtBranchRoot && router.canPop()) { + router.pop(); // Go back within the branch + } else if (navigationShell.currentIndex != 0) { + // At root of non-home tab, go to home (search) tab + navigationShell.goBranch(0); + } + // At home tab root, do nothing (don't close the app) + } + }, + child: Stack( + children: [ + LayoutBuilder( + builder: (context, constraints) { + // Use mobile layout for screens < 550px width + if (constraints.maxWidth < 550) { + return MobileScaffold( + navigationShell: navigationShell, + onNavTap: _onBottomNavTap, + ); + } else { + return DesktopScaffold( + navigationShell: navigationShell, + onNavTap: _onBottomNavTap, + ); + } + }, + ), + const ZapstoreUpdatePromptListener(), + ], + ), + ); + } +} + +/// Mobile scaffold with bottom navigation +class MobileScaffold extends ConsumerWidget { + const MobileScaffold({ + super.key, + required this.navigationShell, + required this.onNavTap, + }); + + final StatefulNavigationShell navigationShell; + final void Function(BuildContext, StatefulNavigationShell, int) onNavTap; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pubkey = ref.watch(Signer.activePubkeyProvider); + final profile = ref.watch(Signer.activeProfileProvider(LocalSource())); + final categorized = ref.watch(categorizedAppsProvider); + final updateCount = ref.watch(updateCountProvider); + final isLoadingUpdates = categorized.isLoading; + + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: AppGradients.darkBackgroundGradient, + ), + child: SafeArea(child: navigationShell), + ), + bottomNavigationBar: Container( + decoration: BoxDecoration( + gradient: AppGradients.darkSurfaceGradient, + border: Border( + top: BorderSide( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.15), + width: 0.5, + ), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: BottomNavigationBar( + currentIndex: navigationShell.currentIndex, + type: BottomNavigationBarType.fixed, + onTap: (index) => onNavTap(context, navigationShell, index), + showSelectedLabels: false, + showUnselectedLabels: false, + elevation: 0, + backgroundColor: Colors.transparent, + selectedItemColor: Theme.of(context).colorScheme.primary, + unselectedItemColor: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), + selectedIconTheme: IconThemeData( + size: 26, + color: Theme.of(context).colorScheme.primary, + ), + unselectedIconTheme: IconThemeData( + size: 22, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), + ), + items: [ + BottomNavigationBarItem( + icon: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: navigationShell.currentIndex == 0 + ? BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(16), + ) + : null, + child: const Icon(Icons.search_rounded), + ), + label: 'Search', + ), + BottomNavigationBarItem( + icon: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: navigationShell.currentIndex == 1 + ? BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(16), + ) + : null, + child: Stack( + clipBehavior: Clip.none, + children: [ + const Icon(Icons.update), + if (isLoadingUpdates) + Positioned( + right: -8, + top: -8, + child: BadgePill( + child: const SizedBox( + width: 8, + height: 8, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + ), + ) + else if (updateCount > 0) + Positioned( + right: -8, + top: -8, + child: CountBadge(count: updateCount), + ), + ], + ), + ), + label: 'Updates', + ), + BottomNavigationBarItem( + icon: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: navigationShell.currentIndex == 2 + ? BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(16), + ) + : null, + child: pubkey != null + ? Container( + decoration: navigationShell.currentIndex == 2 + ? BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + borderRadius: BorderRadius.circular(16), + ) + : null, + child: ProfileAvatar(profile: profile, radius: 12), + ) + : const Icon(Icons.person_rounded), + ), + label: 'Profile', + ), + ], + ), + ), + ); + } +} + +/// Desktop scaffold with navigation rail +class DesktopScaffold extends ConsumerWidget { + const DesktopScaffold({ + super.key, + required this.navigationShell, + required this.onNavTap, + }); + + final StatefulNavigationShell navigationShell; + final void Function(BuildContext, StatefulNavigationShell, int) onNavTap; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pubkey = ref.watch(Signer.activePubkeyProvider); + final profile = ref.watch(Signer.activeProfileProvider(LocalSource())); + final categorized = ref.watch(categorizedAppsProvider); + final updateCount = ref.watch(updateCountProvider); + final isLoadingUpdates = categorized.isLoading; + + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: AppGradients.darkBackgroundGradient, + ), + child: SafeArea( + child: Row( + children: [ + Container( + decoration: BoxDecoration( + gradient: AppGradients.darkSurfaceGradient, + border: Border( + right: BorderSide( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.15), + width: 0.5, + ), + ), + ), + child: NavigationRail( + backgroundColor: Colors.transparent, + selectedIndex: navigationShell.currentIndex, + onDestinationSelected: (index) => + onNavTap(context, navigationShell, index), + labelType: NavigationRailLabelType.all, + destinations: [ + const NavigationRailDestination( + icon: Icon(Icons.search), + label: Text('Search'), + ), + NavigationRailDestination( + icon: Stack( + clipBehavior: Clip.none, + children: [ + const Icon(Icons.update), + if (isLoadingUpdates) + Positioned( + right: -8, + top: -8, + child: BadgePill( + child: const SizedBox( + width: 8, + height: 8, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + ), + ) + else if (updateCount > 0) + Positioned( + right: -8, + top: -8, + child: CountBadge(count: updateCount), + ), + ], + ), + label: const Text('Updates'), + ), + NavigationRailDestination( + icon: pubkey != null + ? Container( + decoration: navigationShell.currentIndex == 2 + ? BoxDecoration( + border: Border.all( + color: Theme.of( + context, + ).colorScheme.primary, + width: 2, + ), + borderRadius: BorderRadius.circular(16), + ) + : null, + child: ProfileAvatar( + profile: profile, + radius: 12, + ), + ) + : const Icon(Icons.person), + label: const Text('Profile'), + ), + ], + ), + ), + Expanded(child: navigationShell), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart new file mode 100644 index 0000000..6a4c417 --- /dev/null +++ b/lib/screens/profile_screen.dart @@ -0,0 +1,1346 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:async_button_builder/async_button_builder.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/services/app_restart_service.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:purplebase/purplebase.dart'; +import 'package:zapstore/main.dart'; +import 'package:zapstore/services/bookmarks_service.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/common/profile_avatar.dart'; +import 'package:zapstore/widgets/app_card.dart'; +import 'package:zapstore/theme.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/widgets/common/note_parser.dart'; +import 'package:zapstore/widgets/nwc_widgets.dart'; + +// Note: Relay debugging features have been removed as they depend on internal APIs +// that are no longer public in purplebase 0.3.3+ + +/// Profile screen for authentication and app settings +class ProfileScreen extends ConsumerWidget { + const ProfileScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + body: ListView( + children: [ + // Authentication Section + const _AuthenticationSection(), + + const SizedBox(height: 24), + + // Saved Apps Heading + const _SavedAppsHeading(), + + const SizedBox(height: 16), + + // Saved Apps Section + const _SavedAppsSection(), + + const SizedBox(height: 24), + + // Settings Heading + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text('Settings', style: context.textTheme.headlineSmall), + ), + + const SizedBox(height: 16), + + // Lightning Wallet Section + const NWCConnectionCard(), + + const SizedBox(height: 16), + + // Data Management Section + const _DataManagementSection(), + + const SizedBox(height: 24), + + // About Section + _AboutSection(), + + const SizedBox(height: 24), + + // Debug Messages Section + const _DebugMessagesSection(), + const SizedBox(height: 24), + ], + ), + ); + } +} + +class _AuthenticationSection extends ConsumerWidget { + const _AuthenticationSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pubkey = ref.watch(Signer.activePubkeyProvider); + + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (pubkey != null) + Consumer( + builder: (context, ref, _) { + final profileState = ref.watch( + query( + authors: {pubkey}, + limit: 1, + and: (profile) => {profile.contactList}, + source: const LocalAndRemoteSource( + relays: {'social', 'vertex'}, + stream: false, + cachedFor: Duration(hours: 2), + ), + ), + ); + final profile = profileState.models.firstOrNull; + return _buildSignedInProfile(context, ref, pubkey, profile); + }, + ) + else + _buildSignInOptions(context, ref), + ], + ), + ), + ); + } + + Widget _buildSignedInProfile( + BuildContext context, + WidgetRef ref, + String pubkey, + Profile? profile, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Profile Information + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ProfileAvatar(profile: profile, radius: 32), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + profile?.nameOrNpub ?? '', + style: context.textTheme.titleLarge, + ), + ), + // Smaller Sign Out button + FilledButton.icon( + onPressed: () => _signOut(context, ref), + icon: const Icon(Icons.logout, size: 14), + label: const Text( + 'Sign Out', + style: TextStyle(fontSize: 12), + ), + style: FilledButton.styleFrom( + backgroundColor: Colors.red.shade900, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + minimumSize: const Size(0, 0), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), + const SizedBox(height: 8), + // Profile bio with NoteParser (non-interactive) + if ((profile?.about?.trim().isNotEmpty ?? false)) + NoteParser.parse( + context, + profile!.about!, + textStyle: context.textTheme.bodyMedium, + onNostrEntity: (entity) => NostrEntityWidget( + entity: entity, + colorPair: [ + Theme.of(context).colorScheme.primary, + Theme.of(context).colorScheme.secondary, + ], + // No tap callbacks - make it non-interactive + ), + ), + const SizedBox(height: 12), + // Link to full profile view + InkWell( + onTap: () => context.push('/profile/user/$pubkey'), + child: Row( + children: [ + Text( + 'View Full Profile', + style: context.textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.arrow_forward, + size: 14, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ], + ); + } + + Widget _buildSignInOptions(BuildContext context, WidgetRef ref) { + final pmState = ref.watch(packageManagerProvider); + final isAmberInstalled = pmState.installed.containsKey(kAmberPackageId); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Benefits Section + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.info_outline, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Sign in to unlock social features', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: _SignInButtonWithAmberCheck( + isAmberInstalled: isAmberInstalled, + ), + ), + ], + ); + } + + Future _signOut(BuildContext context, WidgetRef ref) async { + try { + await ref.read(amberSignerProvider).signOut(); + } catch (e) { + if (context.mounted) { + context.showError('Sign out failed', description: '$e'); + } + } + } +} + +// Sign in button with Amber installation check +class _SignInButtonWithAmberCheck extends ConsumerWidget { + const _SignInButtonWithAmberCheck({required this.isAmberInstalled}); + + final bool isAmberInstalled; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return AsyncButtonBuilder( + onPressed: () async { + if (!isAmberInstalled) { + context.push('/profile/app/$kAmberNaddr'); + } else { + await ref.read(amberSignerProvider).signIn(); + } + }, + builder: (context, child, callback, state) { + final onPressed = state.maybeWhen( + loading: () => null, + orElse: () => callback, + ); + final loading = state.maybeWhen( + loading: () => true, + orElse: () => false, + ); + + return FilledButton( + onPressed: onPressed, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + ), + child: loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.login, size: 16), + const SizedBox(width: 8), + Text( + isAmberInstalled + ? 'Sign in with Amber' + : 'Install Amber to sign in', + style: const TextStyle(fontSize: 13), + ), + ], + ), + ); + }, + child: const SizedBox.shrink(), + ); + } +} + +/// Debug Section - shows relay connections, active requests, and debug messages +class _DebugMessagesSection extends HookConsumerWidget { + const _DebugMessagesSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final poolState = ref.watch(poolStateProvider); + final selectedTab = useState(0); // 0=Subscriptions, 1=Logs + final now = useState(DateTime.now()); + final expandedSubs = useState>({}); + + // Used to refresh the "time ago" labels + useEffect(() { + final timer = Timer.periodic(const Duration(seconds: 1), (_) { + now.value = DateTime.now(); + }); + return timer.cancel; + }, const []); + + final subscriptions = poolState?.subscriptions ?? {}; + final logs = poolState?.logs ?? const []; + + void toggleSubscription(String id) { + final next = {...expandedSubs.value}; + if (!next.remove(id)) { + next.add(id); + } + expandedSubs.value = next; + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon( + Icons.bug_report, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Debug Info', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 10), + + // Tab selector + Row( + children: [ + _TabButton( + label: 'Subscriptions (${subscriptions.length})', + isSelected: selectedTab.value == 0, + onTap: () => selectedTab.value = 0, + ), + const SizedBox(width: 8), + _TabButton( + label: 'Log (${logs.length})', + isSelected: selectedTab.value == 1, + onTap: () => selectedTab.value = 1, + ), + ], + ), + const SizedBox(height: 12), + + // Tab content + if (selectedTab.value == 0) + _buildSubscriptionsTab( + context, + subscriptions, + now.value, + expandedSubs.value, + toggleSubscription, + ), + if (selectedTab.value == 1) _buildLogsTab(context, logs), + ], + ), + ), + ); + } + + Widget _buildSubscriptionsTab( + BuildContext context, + Map subscriptions, + DateTime now, + Set expandedSubs, + void Function(String id) onToggleSub, + ) { + if (subscriptions.isEmpty) { + return _EmptyState(message: 'No active subscriptions'); + } + + final sortedSubs = subscriptions.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: sortedSubs.map((entry) { + final sub = entry.value; + final relays = sub.relays; + final isExpanded = expandedSubs.contains(sub.id); + + final totalRelays = sub.totalRelayCount; + final activeRelays = sub.activeRelayCount; + final allEose = sub.allEoseReceived; + + final relayEntries = relays.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); + + return InkWell( + onTap: () => onToggleSub(sub.id), + borderRadius: BorderRadius.circular(8), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + sub.stream ? Icons.stream : Icons.download, + size: 16, + color: allEose ? Colors.green : Colors.amber.shade700, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + entry.key, + style: const TextStyle( + fontSize: 11, + fontFamily: 'monospace', + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + _StatusChip( + icon: Icons.event, + label: '${sub.eventCount}', + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 6), + _StatusChip( + icon: Icons.cloud_done, + label: '$activeRelays/$totalRelays', + color: allEose ? Colors.green : Colors.amber.shade700, + ), + const SizedBox(width: 6), + Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + size: 16, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ], + ), + if (relayEntries.isNotEmpty) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surface.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.15), + ), + ), + child: Column( + children: relayEntries.map((relayEntry) { + final relay = relayEntry.value; + final phase = relay.phase; + final phaseColor = switch (phase) { + RelaySubPhase.streaming => Colors.green, + RelaySubPhase.loading => Colors.blue, + RelaySubPhase.connecting => Colors.orange, + RelaySubPhase.waiting => Colors.amber, + RelaySubPhase.failed => Colors.red, + RelaySubPhase.disconnected => Colors.grey, + }; + final phaseIcon = switch (phase) { + RelaySubPhase.streaming => Icons.cloud_done, + RelaySubPhase.loading => Icons.cloud_sync, + RelaySubPhase.connecting => Icons.wifi_find, + RelaySubPhase.waiting => Icons.pause_circle, + RelaySubPhase.failed => Icons.error, + RelaySubPhase.disconnected => Icons.cloud_off, + }; + + final shortUrl = relayEntry.key + .replaceAll('wss://', '') + .replaceAll('ws://', '') + .replaceAll(RegExp(r'/$'), ''); + + final streamingSince = relay.streamingSince; + final connectedFor = + (phase == RelaySubPhase.streaming || + phase == RelaySubPhase.loading) && + streamingSince != null + ? _formatDuration(now.difference(streamingSince)) + : null; + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.1), + ), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(phaseIcon, size: 16, color: phaseColor), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + shortUrl, + style: const TextStyle( + fontSize: 11, + fontFamily: 'monospace', + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + '${phase.name}' + '${relay.reconnectAttempts > 0 ? ' · retry ${relay.reconnectAttempts}' : ''}' + '${connectedFor != null ? ' · connected for $connectedFor' : ''}', + style: TextStyle( + fontSize: 10, + color: phaseColor, + ), + ), + if (relay.lastError != null) ...[ + const SizedBox(height: 2), + Text( + relay.lastError!, + style: const TextStyle( + fontSize: 10, + color: Colors.white, + fontFamily: 'monospace', + ), + ), + ], + ], + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + if (isExpanded) _buildRequestView(context, sub), + ], + ), + ), + ); + }).toList(), + ); + } + + Widget _buildLogsTab(BuildContext context, List logs) { + if (logs.isEmpty) { + return _EmptyState(message: 'No logs yet'); + } + + final reversedLogs = logs.reversed.toList(); + + return Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Column( + children: reversedLogs.map((log) { + final time = _formatTime(log.timestamp); + final levelName = log.level.name.toUpperCase(); + final color = switch (log.level) { + LogLevel.error => Colors.red, + LogLevel.warning => Colors.orange, + LogLevel.info => Theme.of(context).colorScheme.primary, + }; + + final parts = [ + if (log.subscriptionId != null) 'Sub: ${log.subscriptionId}', + if (log.relayUrl != null) 'Relay: ${log.relayUrl}', + if (log.exception != null) 'Error: ${log.exception}', + ]; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.1), + ), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + levelName, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '[$time] ${log.message}', + style: const TextStyle( + fontSize: 11, + fontFamily: 'monospace', + ), + ), + if (parts.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + parts.join(' • '), + style: TextStyle( + fontSize: 10, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.65), + ), + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.copy, size: 14), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + final text = [ + levelName, + time, + log.message, + if (log.subscriptionId != null) + 'Sub: ${log.subscriptionId}', + if (log.relayUrl != null) 'Relay: ${log.relayUrl}', + if (log.exception != null) 'Error: ${log.exception}', + ].where((e) => e.isNotEmpty).join(' | '); + Clipboard.setData(ClipboardData(text: text)); + context.showInfo('Debug info copied'); + }, + ), + ], + ), + ); + }).toList(), + ), + ); + } + + String _formatTime(DateTime timestamp) { + return '${timestamp.hour.toString().padLeft(2, '0')}:' + '${timestamp.minute.toString().padLeft(2, '0')}:' + '${timestamp.second.toString().padLeft(2, '0')}'; + } + + String _formatDuration(Duration duration) { + if (duration.inSeconds < 60) { + return '${duration.inSeconds}s'; + } + if (duration.inMinutes < 60) { + final minutes = duration.inMinutes; + final seconds = duration.inSeconds % 60; + return seconds > 0 ? '${minutes}m ${seconds}s' : '${minutes}m'; + } + if (duration.inHours < 24) { + final hours = duration.inHours; + final minutes = duration.inMinutes % 60; + return minutes > 0 ? '${hours}h ${minutes}m' : '${hours}h'; + } + final days = duration.inDays; + final hours = duration.inHours % 24; + return hours > 0 ? '${days}d ${hours}h' : '${days}d'; + } + + Widget _buildRequestView(BuildContext context, RelaySubscription sub) { + final req = _formatReq(sub); + return Container( + margin: const EdgeInsets.only(top: 10), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'REQ', + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + sub.id, + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + ), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.copy, size: 16), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + Clipboard.setData(ClipboardData(text: req)); + context.showInfo( + 'REQ filter copied', + description: + 'Paste into a Nostr client to debug this query.', + ); + }, + ), + ], + ), + const SizedBox(height: 8), + SelectableText( + req, + style: const TextStyle( + fontSize: 11, + height: 1.4, + fontFamily: 'monospace', + ), + ), + ], + ), + ); + } + + String _formatReq(RelaySubscription sub) { + final payload = ['REQ', sub.id, ...sub.request.toMaps()]; + try { + return const JsonEncoder.withIndent(' ').convert(payload); + } catch (_) { + return payload.toString(); + } + } +} + +class _StatusChip extends StatelessWidget { + const _StatusChip({ + required this.icon, + required this.label, + required this.color, + }); + + final IconData icon; + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: color), + const SizedBox(width: 2), + Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ); + } +} + +class _TabButton extends StatelessWidget { + const _TabButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(16), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected + ? Theme.of(context).colorScheme.onPrimaryContainer + : Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Icon( + Icons.check_circle_outline, + size: 36, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.3), + ), + const SizedBox(height: 12), + Text( + message, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), + ), + ); + } +} + +class _DataManagementSection extends ConsumerWidget { + const _DataManagementSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Data Management', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + ListTile( + leading: CircleAvatar( + radius: 18, + backgroundColor: Theme.of( + context, + ).colorScheme.error.withValues(alpha: 0.12), + child: Icon( + Icons.delete_sweep, + color: Theme.of(context).colorScheme.error, + ), + ), + title: Text( + 'Clear local storage', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + contentPadding: EdgeInsets.zero, + onTap: () => _showClearAllDataDialog(context, ref), + ), + ], + ), + ), + ); + } + + void _showClearAllDataDialog(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon(Icons.warning, color: Theme.of(context).colorScheme.error), + const SizedBox(width: 8), + const Text('Clear local storage'), + ], + ), + content: const Text( + 'Clears all cached data (except NWC secret) and restarts the app. ' + 'You will be signed out.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () async { + // Close the confirmation dialog first + Navigator.pop(context); + // Proceed with clearing data + await _clearAllData(context, ref); + }, + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + child: const Text('Clear storage'), + ), + ], + ), + ); + } + + Future _clearAllData(BuildContext context, WidgetRef ref) async { + try { + // Show loading dialog + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => const AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: AppColors.darkSkeletonHighlight, + backgroundColor: AppColors.darkSkeletonBase, + ), + SizedBox(height: 16), + Text('Restarting...'), + ], + ), + ), + ); + + // Native restart - storage will be cleared on next launch + await restartApp(); + } catch (e) { + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + context.showError('Restart failed', description: e.toString()); + } + } + } +} + +class _SavedAppsHeading extends ConsumerWidget { + const _SavedAppsHeading(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + + if (signedInPubkey == null) { + return const SizedBox.shrink(); + } + + final savedAppsAsync = ref.watch(bookmarksProvider); + + return savedAppsAsync.when( + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + data: (addressableIds) { + if (addressableIds.isEmpty) { + return const SizedBox.shrink(); + } + + final identifiers = addressableIds + .map((id) { + final parts = id.split(':'); + return parts.length >= 3 ? parts[2] : null; + }) + .whereType() + .toSet(); + + if (identifiers.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text('Saved Apps', style: context.textTheme.headlineSmall), + ); + }, + ); + } +} + +class _SavedAppsSection extends HookConsumerWidget { + const _SavedAppsSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + final hasLoadedOnce = useState(false); + + if (signedInPubkey == null) { + return const SizedBox.shrink(); + } + + final savedAppsAsync = ref.watch(bookmarksProvider); + + return savedAppsAsync.when( + loading: () => _savedAppsLoadingCard(context), + error: (_, __) => const SizedBox.shrink(), + data: (addressableIds) { + if (addressableIds.isEmpty) { + return const SizedBox.shrink(); + } + + final identifiers = addressableIds + .map((id) { + final parts = id.split(':'); + return parts.length >= 3 ? parts[2] : null; + }) + .whereType() + .toSet(); + + if (identifiers.isEmpty) { + return const SizedBox.shrink(); + } + + return _SavedAppsList( + identifiers: identifiers, + hasLoadedOnce: hasLoadedOnce, + ); + }, + ); + } + + Widget _savedAppsLoadingCard(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ); + } +} + +class _SavedAppsList extends ConsumerWidget { + const _SavedAppsList({ + required this.identifiers, + required this.hasLoadedOnce, + }); + + final Set identifiers; + final ValueNotifier hasLoadedOnce; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final savedAppsState = ref.watch( + query( + tags: {'#d': identifiers}, + and: (app) => {app.latestRelease}, + source: const LocalSource(), + subscriptionPrefix: 'profile-saved-apps', + ), + ); + + final savedApps = savedAppsState.models.toList() + ..sort( + (a, b) => (a.name ?? a.identifier).toLowerCase().compareTo( + (b.name ?? b.identifier).toLowerCase(), + ), + ); + + // Check if we have loaded at least once - defer state update to after build + if (savedApps.isNotEmpty && !hasLoadedOnce.value) { + WidgetsBinding.instance.addPostFrameCallback((_) { + hasLoadedOnce.value = true; + }); + } + + // Show loading state only if we haven't loaded any apps yet + if (savedApps.isEmpty && !hasLoadedOnce.value) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ); + } + + // Hide if no apps after loading + if (savedApps.isEmpty) { + return const SizedBox.shrink(); + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: savedApps + .map( + (app) => AppCard( + app: app, + showUpdateArrow: false, + showDescription: false, + ), + ) + .toList(), + ), + ), + ); + } +} + +class _AboutSection extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final pmState = ref.watch(packageManagerProvider); + final zsPackage = pmState.installed[kZapstoreAppIdentifier]; + + if (zsPackage == null) { + final isLoading = pmState.installed.isEmpty; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('About', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 16), + if (isLoading) ...[ + Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Loading Zapstore build information…', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ] else ...[ + Text( + 'Zapstore version details are unavailable right now.', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Colors.orange[700]), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: () { + unawaited( + ref + .read(packageManagerProvider.notifier) + .syncInstalledPackages(), + ); + }, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ], + ), + ), + ); + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('About', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 16), + ListTile( + leading: ClipOval( + child: Image.asset( + 'assets/images/logo.png', + width: 28, + height: 28, + fit: BoxFit.cover, + color: Colors.grey, + colorBlendMode: BlendMode.saturation, + ), + ), + title: Text('Version'), + subtitle: Text('${zsPackage.version}+${zsPackage.versionCode}'), + contentPadding: EdgeInsets.zero, + ), + ListTile( + leading: const Icon(Icons.code), + title: const Text('Source Code'), + subtitle: const Text('View on GitHub'), + trailing: const Icon(Icons.open_in_new), + contentPadding: EdgeInsets.zero, + onTap: () { + launchUrl(Uri.parse('https://github.com/zapstore/zapstore')); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 8a5153f..d902362 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -1,150 +1,259 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/widgets/app_card.dart'; -import 'package:zapstore/widgets/app_curation_container.dart'; -import 'package:zapstore/widgets/latest_releases_container.dart'; -import 'package:zapstore/widgets/user_avatar.dart'; +import 'package:models/models.dart'; +import '../widgets/app_stack_container.dart'; +import '../widgets/latest_releases_container.dart'; +import '../widgets/app_card.dart'; +import '../utils/extensions.dart'; +import '../main.dart'; +import '../services/package_manager/package_manager.dart'; + +/// Main search and app discovery screen class SearchScreen extends HookConsumerWidget { const SearchScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final controller = useTextEditingController(); - final focusNode = useFocusNode(); - final searchResultState = ref.watch(searchResultProvider); + final searchController = useTextEditingController(); final scrollController = useScrollController(); + final searchFocusNode = useFocusNode(); + final searchQuery = useState(''); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - GestureDetector( - onTap: () => Scaffold.of(context).openDrawer(), - child: UserAvatar(), - ), - Gap(20), - Expanded( - child: SearchBar( - controller: controller, - focusNode: focusNode, - onTapOutside: (_) => focusNode.unfocus(), - shape: WidgetStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(28), + // Check app initialization state + final initState = ref.watch(appInitializationProvider); + + // Get platform from package manager + final platform = ref.read(packageManagerProvider.notifier).platform; + + // Function to perform search (only with 3+ characters) + final performSearch = useCallback((String query) { + final trimmed = query.trim(); + // Keep keyboard open if less than 3 characters + if (trimmed.length < 3) { + // Re-request focus to keep keyboard open + searchFocusNode.requestFocus(); + return; + } + searchQuery.value = trimmed; + }, [searchFocusNode]); + + final trimmedQuery = searchQuery.value.trim(); + + // Authors are now loaded via profileProvider in individual AppCards with caching + + return Scaffold( + backgroundColor: Colors.transparent, + body: Column( + children: [ + // Professional search bar with better spacing + Container( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 18), + child: ValueListenableBuilder( + valueListenable: searchController, + builder: (context, value, _) { + final hasText = value.text.isNotEmpty; + + return SearchBar( + controller: searchController, + focusNode: searchFocusNode, + hintText: 'Search apps', + leading: Icon( + Icons.search_rounded, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - ), - leading: Padding( - padding: const EdgeInsets.fromLTRB(10, 8, 6, 6), - child: Icon( - Icons.search, - color: Colors.blueGrey, - size: 20, + trailing: [ + if (hasText) + IconButton( + icon: Icon( + Icons.clear_rounded, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + onPressed: () { + searchController.clear(); + searchQuery.value = ''; + searchFocusNode.requestFocus(); + }, + tooltip: 'Clear search', + ), + ], + onSubmitted: performSearch, + elevation: WidgetStateProperty.all(0), + backgroundColor: WidgetStateProperty.all( + Theme.of(context).colorScheme.surfaceContainerHighest + .withValues(alpha: 0.8), ), - ), - trailing: [ - if (searchResultState.isLoading) - Padding( - padding: const EdgeInsets.only(right: 10), - child: SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 3), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.3), + width: 1, ), ), - if (controller.text.isNotEmpty && - !searchResultState.isLoading) - IconButton( - hoverColor: Colors.transparent, - onPressed: () async { - // clear input and results, then return focus - controller.clear(); - ref.read(searchQueryProvider.notifier).state = null; - - await scrollController.animateTo( - scrollController.position.minScrollExtent, - duration: Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - focusNode.requestFocus(); - }, - icon: Icon(Icons.close), - ), - ], - hintText: 'Search for apps', - hintStyle: - WidgetStateProperty.all(TextStyle(color: Colors.grey[600])), - elevation: WidgetStateProperty.all(2.2), - onSubmitted: (query) async { - if (query.isEmpty) return; - ref.read(searchQueryProvider.notifier).state = query; - scrollController.animateTo( - scrollController.position.minScrollExtent, - duration: Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - }, - ), - ), - ], - ), - Gap(10), - if (searchResultState.hasError) - Text(searchResultState.error!.toString()), - Expanded( - child: SingleChildScrollView( - controller: scrollController, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - key: UniqueKey(), - children: [ - if ((searchResultState.value?.isEmpty ?? false) && - !searchResultState.isLoading) - Padding( - padding: const EdgeInsets.only(top: 16), - child: Center( - child: Text( - 'No results for ${ref.read(searchQueryProvider)}', - style: TextStyle(fontSize: 16), - )), ), - if (searchResultState.value?.isNotEmpty ?? false) - for (final app in searchResultState.value!) - AppCard(model: app), - Gap(20), - const AppCurationContainer(), - Gap(20), - LatestReleasesContainer(scrollController: scrollController), - Gap(10), - ], + ); + }, ), ), - ), - ], + // Scrollable content + Expanded( + child: SingleChildScrollView( + controller: scrollController, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Search Results Section + if (trimmedQuery.isNotEmpty) + _SearchResultsSection( + searchQuery: trimmedQuery, + platform: platform, + scrollController: scrollController, + ), + + // App Stacks Container + Padding( + padding: const EdgeInsets.only(bottom: 14), + child: AppStackContainer( + showSkeleton: !(initState.hasValue || initState.hasError), + ), + ), + + // Latest Releases Container + LatestReleasesContainer( + scrollController: scrollController, + showSkeleton: !(initState.hasValue || initState.hasError), + ), + + const SizedBox(height: 16), // Bottom padding + ], + ), + ), + ), + ], + ), ); } } -final searchQueryProvider = StateProvider((ref) => null); +class _SearchResultsSection extends HookConsumerWidget { + const _SearchResultsSection({ + required this.searchQuery, + required this.platform, + required this.scrollController, + }); -final searchResultProvider = - AsyncNotifierProvider?>( - SearchResultNotifier.new); + final String searchQuery; + final String platform; + final ScrollController scrollController; -class SearchResultNotifier extends AsyncNotifier?> { @override - Future?> build() async { - final query = ref.watch(searchQueryProvider); - if (query != null) { - return await ref.apps.findAll(params: {'search': query, 'limit': 16}); - } - return null; + Widget build(BuildContext context, WidgetRef ref) { + final searchResultsState = ref.watch( + query( + search: searchQuery, + limit: 20, + tags: { + '#f': {platform}, + }, + and: (app) => { + app.latestRelease, + app.latestRelease.value?.latestMetadata, + app.latestRelease.value?.latestAsset, + }, + // Force the search to hit the default relay group (relay.zapstore.dev) + // so a connection appears in Debug Info when searching. + source: const RemoteSource(relays: 'AppCatalog', stream: false), + subscriptionPrefix: 'search-results', + ), + ); + + // Auto-scroll to top when search results change from loading to data + useEffect(() { + if (searchResultsState is StorageData && scrollController.hasClients) { + WidgetsBinding.instance.addPostFrameCallback((_) { + scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 100), + curve: Curves.easeInOut, + ); + }); + } + return null; + }, [searchResultsState]); + + final results = searchResultsState is StorageData + ? (searchResultsState as StorageData).models + : []; + final isSearching = searchResultsState is StorageLoading; + final error = searchResultsState is StorageError + ? (searchResultsState as StorageError).exception.toString() + : null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isSearching) + Column( + children: List.generate(2, (_) => const AppCard(isLoading: true)), + ) + else if (error != null) + Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: Column( + children: [ + Icon(Icons.error_outline, size: 48, color: Colors.red[400]), + const SizedBox(height: 16), + Text('Search Error', style: context.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + error, + style: context.textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ), + ), + ) + else if (results.isEmpty) + Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: Column( + children: [ + Icon(Icons.search_off, size: 48, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + 'No results found', + style: context.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'No apps found for "$searchQuery"', + style: context.textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ), + ), + ) + else + Column( + children: [ + // App Cards - authors loaded via profileProvider in AppCard + ...results.map( + (app) => AppCard(app: app, showUpdateArrow: app.hasUpdate), + ), + ], + ), + const SizedBox(height: 24), + ], + ); } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart deleted file mode 100644 index 8fb7098..0000000 --- a/lib/screens/settings_screen.dart +++ /dev/null @@ -1,310 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:async_button_builder/async_button_builder.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_data/flutter_data.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_phoenix/flutter_phoenix.dart'; -import 'package:gap/gap.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:http/http.dart' as http; -import 'package:path_provider/path_provider.dart'; -import 'package:purplebase/purplebase.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/settings.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/navigation/app_initializer.dart'; -import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/utils/nwc.dart'; -import 'package:zapstore/utils/system_info.dart'; -import 'package:zapstore/widgets/sign_in_container.dart'; - -class SettingsScreen extends HookConsumerWidget { - const SettingsScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final systemInfoState = ref.watch(systemInfoNotifierProvider); - - final feedbackController = useTextEditingController(); - final signedInUser = ref.watch(signedInUserProvider); - - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Share your feedback', - style: context.theme.textTheme.headlineLarge! - .copyWith(fontWeight: FontWeight.bold), - ), - Gap(10), - Text( - 'Zapstore is free and open source software', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), - ), - Gap(10), - Text('Comments, suggestions and error reports welcome here.'), - Gap(20), - TextField( - controller: feedbackController, - maxLines: 10, - ), - Gap(20), - if (signedInUser == null || - signedInUser.settings.value?.signInMethod != SignInMethod.nip55) - Column( - children: [ - Text( - 'To send a message you must be signed in with an external signer like Amber.', - softWrap: true, - style: TextStyle( - color: Colors.red[300], fontWeight: FontWeight.bold)), - Gap(5), - SignInButton(minimal: true, requireNip55: true), - ], - ), - if (signedInUser != null && - signedInUser.settings.value!.signInMethod == SignInMethod.nip55) - AsyncButtonBuilder( - loadingWidget: SmallCircularProgressIndicator(), - onPressed: () async { - if (feedbackController.text.trim().isNotEmpty) { - final signedDirectMessage = await amberSigner.sign( - PartialDirectMessage( - content: feedbackController.text.trim(), - receiver: kZapstorePubkey.npub), - withPubkey: signedInUser.pubkey); - try { - final response = await http.post( - Uri.parse('https://relay.zapstore.dev/'), - body: jsonEncode(signedDirectMessage.toMap()), - headers: {'Content-Type': 'application/json'}); - if (response.statusCode >= 400) { - throw Exception(response.body); - } - if (context.mounted) { - context.showInfo('Thank you', - description: 'Message sent successfully'); - } - feedbackController.clear(); - } catch (e, stack) { - if (context.mounted) { - context.showError(e.toString(), - description: stack.toString()); - } - } - } - }, - builder: (context, child, callback, state) => - ElevatedButton(onPressed: callback, child: child), - child: Text('Send as ${signedInUser.nameOrNpub}'), - ), - Gap(20), - Divider(), - Gap(20), - Text( - 'Nostr Wallet Connect', - style: context.theme.textTheme.headlineLarge! - .copyWith(fontWeight: FontWeight.bold), - ), - Gap(20), - NwcContainer(), - Gap(20), - Divider(), - Gap(20), - Text( - 'Tools', - style: context.theme.textTheme.headlineLarge! - .copyWith(fontWeight: FontWeight.bold), - ), - TextButton( - onPressed: () { - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: Text('Confirm clear'), - content: Text( - 'Are you sure you want to clear the local cache and restart the app?'), - actions: [ - TextButton( - child: Text('Cancel'), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - TextButton( - child: Text('Confirm'), - onPressed: () { - ref.read(localStorageProvider).destroy().then((_) { - if (context.mounted) { - Phoenix.rebirth(context); - Navigator.of(context).pop(); - context.go('/'); - } - }); - }, - ), - ], - ); - }, - ); - }, - child: Text('Delete local cache'), - ), - Gap(40), - Row( - children: [ - Text( - 'System info', - style: context.theme.textTheme.headlineLarge! - .copyWith(fontWeight: FontWeight.bold), - ), - Gap(20), - ElevatedButton( - onPressed: () { - ref.read(systemInfoNotifierProvider.notifier).fetch(); - }, - child: Text('Refresh'), - ), - ], - ), - Gap(20), - GestureDetector( - onLongPress: () async { - final info = - await ref.read(systemInfoNotifierProvider.notifier).fetch(); - final dir = await getApplicationDocumentsDirectory(); - final errors = jsonDecode( - await File('${dir.path}/errors.json').readAsString()); - errors as Map; - errors['_'] = info.toString(); - errors['_user'] = - ref.settings.findOneLocalById('_')!.user.value?.npub; - try { - await http.post( - Uri.parse('https://cdn.zapstore.dev/upload'), - body: utf8.encode(jsonEncode(errors)), - headers: { - 'Content-Type': 'application/json', - 'X-Filename': 'errors.json', - }, - ); - if (context.mounted) { - context.showInfo('System info sent', - description: 'Thank you'); - } - } catch (e) { - if (context.mounted) { - context.showError('Unable to send system info', - description: e.toString()); - } - } - }, - child: Text(switch (systemInfoState) { - AsyncData(:final value) => value.toString(), - _ => '', - }), - ), - ], - ), - ); - } -} - -class SmallCircularProgressIndicator extends StatelessWidget { - const SmallCircularProgressIndicator({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return SizedBox(width: 14, height: 14, child: CircularProgressIndicator()); - } -} - -class NwcContainer extends HookConsumerWidget { - final bool dialogMode; - const NwcContainer({ - super.key, - this.dialogMode = false, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final nwcConnection = ref.watch(nwcConnectionProvider); - final controller = useTextEditingController(text: ''); - final isTextFieldEmpty = useState(true); - - return Padding( - padding: const EdgeInsets.all(4), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!nwcConnection.hasValue && nwcConnection.isLoading) - SmallCircularProgressIndicator(), - if (nwcConnection.isPresent) Text('Wallet connected'), - if (nwcConnection.hasError) - Text('Error connecting: ${nwcConnection.error}'), - if (!nwcConnection.isPresent && !nwcConnection.isLoading) - TextField( - autocorrect: false, - keyboardType: TextInputType.none, - controller: controller, - onChanged: (value) { - isTextFieldEmpty.value = value.isEmpty; - }, - decoration: InputDecoration( - hintText: 'nostr+walletconnect://...', - suffixIcon: AsyncButtonBuilder( - disabled: isTextFieldEmpty.value, - loadingWidget: SmallCircularProgressIndicator(), - onPressed: () async { - final nwcUri = controller.text.trim(); - await ref - .read(nwcConnectionProvider.notifier) - .connectWallet(nwcUri); - if (dialogMode && context.mounted) { - Navigator.of(context).pop(); - } - }, - builder: (context, child, callback, buttonState) { - return Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SizedBox( - child: ElevatedButton( - onPressed: callback, - style: ElevatedButton.styleFrom( - disabledBackgroundColor: Colors.transparent, - backgroundColor: Colors.transparent), - child: child, - ), - ), - ); - }, - child: Text('Connect'), - ), - ), - ), - if (nwcConnection.isPresent && !dialogMode) - ElevatedButton( - onPressed: () async { - controller.clear(); - await ref - .read(nwcConnectionProvider.notifier) - .disconnectWallet(); - }, - style: ElevatedButton.styleFrom( - disabledBackgroundColor: Colors.transparent, - backgroundColor: Colors.transparent), - child: Text('Disconnect'), - ), - ], - ), - ); - } -} diff --git a/lib/screens/updates_screen.dart b/lib/screens/updates_screen.dart index 9f9dd8a..1886a6d 100644 --- a/lib/screens/updates_screen.dart +++ b/lib/screens/updates_screen.dart @@ -1,121 +1,626 @@ -import 'package:collection/collection.dart'; +import 'package:async_button_builder/async_button_builder.dart'; import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/app.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/services/updates_service.dart'; +import 'package:zapstore/theme.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/common/badges.dart'; import 'package:zapstore/widgets/app_card.dart'; -import 'package:zapstore/widgets/spinning_logo.dart'; +/// Screen for managing app updates class UpdatesScreen extends HookConsumerWidget { const UpdatesScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(updatesProvider); + final categorized = ref.watch(categorizedAppsProvider); - final (updatedApps, updatableApps, unupdatableApps) = - state.value ?? ([], [], []); + if (categorized.isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } - return SingleChildScrollView( - physics: AlwaysScrollableScrollPhysics(), - child: Column( - key: UniqueKey(), - crossAxisAlignment: CrossAxisAlignment.start, + return Scaffold( + body: Padding( + padding: const EdgeInsets.only(top: 16), + child: _UpdatesContent(categorized: categorized), + ), + ); + } +} + +/// Item types for the updates list +enum _UpdatesItemType { + installingHeader, + installingApp, + automaticHeader, + automaticApp, + manualHeader, + manualInfoBox, + manualApp, + upToDateHeader, + upToDateApp, + uncatalogedHeader, + uncatalogedApp, +} + +class _UpdatesItem { + final _UpdatesItemType type; + final App? app; + final PackageInfo? packageInfo; + + const _UpdatesItem(this.type, {this.app, this.packageInfo}); +} + +class _UpdatesContent extends HookConsumerWidget { + const _UpdatesContent({required this.categorized}); + + final CategorizedApps categorized; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final automaticUpdates = categorized.automaticUpdates; + final manualUpdates = categorized.manualUpdates; + final upToDateApps = categorized.upToDateApps; + final uncatalogedApps = categorized.uncatalogedApps; + + // Watch operations from PackageManager + final operations = ref.watch( + packageManagerProvider.select((s) => s.operations), + ); + final activeAppIds = operations.entries + .where((entry) => entry.value.isActive) + .map((entry) => entry.key) + .toSet(); + + if (activeAppIds.isEmpty) { + return _UpdatesListBody( + automaticUpdates: automaticUpdates, + manualUpdates: manualUpdates, + installingApps: const [], + upToDateApps: upToDateApps, + uncatalogedApps: uncatalogedApps, + ); + } + + return _UpdatesListBodyWithInstallingAppIds( + installingAppIds: activeAppIds, + automaticUpdates: automaticUpdates, + manualUpdates: manualUpdates, + upToDateApps: upToDateApps, + uncatalogedApps: uncatalogedApps, + ); + } +} + +class _UpdatesListBodyWithInstallingAppIds extends ConsumerWidget { + const _UpdatesListBodyWithInstallingAppIds({ + required this.installingAppIds, + required this.automaticUpdates, + required this.manualUpdates, + required this.upToDateApps, + required this.uncatalogedApps, + }); + + final Set installingAppIds; + final List automaticUpdates; + final List manualUpdates; + final List upToDateApps; + final List uncatalogedApps; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final installingAppsState = ref.watch( + query( + tags: {'#d': installingAppIds}, + and: (app) => {app.latestRelease}, + source: const LocalAndRemoteSource(relays: 'AppCatalog'), + subscriptionPrefix: 'installing-apps', + ), + ); + + final updateAppIds = { + ...automaticUpdates.map((a) => a.identifier), + ...manualUpdates.map((a) => a.identifier), + }; + + final installingApps = installingAppsState.models + .where( + (app) => + installingAppIds.contains(app.identifier) && + !updateAppIds.contains(app.identifier), + ) + .toList(); + + return _UpdatesListBody( + automaticUpdates: automaticUpdates, + manualUpdates: manualUpdates, + installingApps: installingApps, + upToDateApps: upToDateApps, + uncatalogedApps: uncatalogedApps, + ); + } +} + +class _UpdatesListBody extends StatelessWidget { + const _UpdatesListBody({ + required this.automaticUpdates, + required this.manualUpdates, + required this.installingApps, + required this.upToDateApps, + required this.uncatalogedApps, + }); + + final List automaticUpdates; + final List manualUpdates; + final List installingApps; + final List upToDateApps; + final List uncatalogedApps; + + @override + Widget build(BuildContext context) { + if (automaticUpdates.isEmpty && + manualUpdates.isEmpty && + installingApps.isEmpty && + upToDateApps.isEmpty && + uncatalogedApps.isEmpty) { + final theme = Theme.of(context); + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + ColorFiltered( + colorFilter: const ColorFilter.matrix([ + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + child: const Text('🎉', style: TextStyle(fontSize: 48)), + ), + const SizedBox(height: 12), + Text( + 'No apps installed yet', + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + 'Install some apps to get started!', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + // Build flat list of items for ListView.builder + final items = <_UpdatesItem>[]; + + if (installingApps.isNotEmpty) { + items.add(const _UpdatesItem(_UpdatesItemType.installingHeader)); + for (final app in installingApps) { + items.add(_UpdatesItem(_UpdatesItemType.installingApp, app: app)); + } + } + + if (automaticUpdates.isNotEmpty) { + items.add(const _UpdatesItem(_UpdatesItemType.automaticHeader)); + for (final app in automaticUpdates) { + items.add(_UpdatesItem(_UpdatesItemType.automaticApp, app: app)); + } + } + + if (manualUpdates.isNotEmpty) { + items.add(const _UpdatesItem(_UpdatesItemType.manualHeader)); + items.add(const _UpdatesItem(_UpdatesItemType.manualInfoBox)); + for (final app in manualUpdates) { + items.add(_UpdatesItem(_UpdatesItemType.manualApp, app: app)); + } + } + + if (upToDateApps.isNotEmpty) { + items.add(const _UpdatesItem(_UpdatesItemType.upToDateHeader)); + for (final app in upToDateApps) { + items.add(_UpdatesItem(_UpdatesItemType.upToDateApp, app: app)); + } + } + + if (uncatalogedApps.isNotEmpty) { + items.add(const _UpdatesItem(_UpdatesItemType.uncatalogedHeader)); + for (final pkg in uncatalogedApps) { + items.add( + _UpdatesItem(_UpdatesItemType.uncatalogedApp, packageInfo: pkg), + ); + } + } + + return ListView.builder( + key: const PageStorageKey('updates_list'), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + switch (item.type) { + case _UpdatesItemType.installingHeader: + return _InstallingHeader(count: installingApps.length); + case _UpdatesItemType.installingApp: + return AppCard( + key: ValueKey('installing_${item.app?.identifier}'), + app: item.app, + showUpdateArrow: false, + showUpdateButton: true, + showZapEncouragement: true, + showDescription: false, + ); + case _UpdatesItemType.automaticHeader: + return _UpdatesSectionHeader( + icon: Icons.system_update, + title: 'Updates', + count: automaticUpdates.length, + apps: automaticUpdates, + ); + case _UpdatesItemType.automaticApp: + return AppCard( + key: ValueKey('automatic_${item.app?.identifier}'), + app: item.app, + showUpdateArrow: true, + showUpdateButton: true, + showZapEncouragement: true, + showDescription: false, + ); + case _UpdatesItemType.manualHeader: + return _UpdatesSectionHeader( + icon: Icons.touch_app, + title: 'Manual Updates', + count: manualUpdates.length, + apps: manualUpdates, + ); + case _UpdatesItemType.manualInfoBox: + return _ManualUpdatesInfoBox(); + case _UpdatesItemType.manualApp: + return AppCard( + key: ValueKey('manual_${item.app?.identifier}'), + app: item.app, + showUpdateArrow: true, + showUpdateButton: true, + showZapEncouragement: true, + showDescription: false, + ); + case _UpdatesItemType.upToDateHeader: + return _UpToDateHeader(count: upToDateApps.length); + case _UpdatesItemType.upToDateApp: + return AppCard( + key: ValueKey('uptodate_${item.app?.identifier}'), + app: item.app, + showUpdateArrow: false, + showDescription: false, + ); + case _UpdatesItemType.uncatalogedHeader: + return _UncatalogedHeader(count: uncatalogedApps.length); + case _UpdatesItemType.uncatalogedApp: + return _UncatalogedAppCard( + key: ValueKey('uncataloged_${item.packageInfo?.appId}'), + packageInfo: item.packageInfo!, + ); + } + }, + ); + } +} + +class _InstallingHeader extends StatelessWidget { + const _InstallingHeader({required this.count}); + + final int count; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Row( children: [ - Text('Installed apps', - style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold)), - if (updatedApps.isEmpty && updatableApps.isEmpty) - SpinningLogo(size: 80), - if (updatableApps.isNotEmpty) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Gap(20), - Text( - (updatableApps.length > 1 - ? '${updatableApps.length} updates available' - : 'One update available') - .toUpperCase(), - style: TextStyle( - fontSize: 16, - letterSpacing: 3, - fontWeight: FontWeight.w300, - ), - ), - Gap(10), - for (final app in updatableApps) - AppCard(model: app, showUpdate: true), - ], - ), - Gap(20), - if (unupdatableApps.isNotEmpty) - Text( - 'Disabled updates'.toUpperCase(), - style: TextStyle( - fontSize: 16, - letterSpacing: 3, - fontWeight: FontWeight.w300, - ), - ), - Gap(10), - for (final app in unupdatableApps) - AppCard(model: app, showUpdate: true), - Gap(20), - if (updatedApps.isNotEmpty) - Text( - 'Up to date'.toUpperCase(), - style: TextStyle( - fontSize: 16, - letterSpacing: 3, - fontWeight: FontWeight.w300, - ), - ), - Gap(10), - for (final app in updatedApps) AppCard(model: app), + Icon(Icons.downloading, size: 20, color: AppColors.darkActionPrimary), + const SizedBox(width: 8), + Text('Installing', style: context.textTheme.titleMedium), + const SizedBox(width: 8), + CountBadge(count: count, color: AppColors.darkPillBackground), ], ), ); } } -// Provider +class _UpdatesSectionHeader extends ConsumerWidget { + const _UpdatesSectionHeader({ + required this.icon, + required this.title, + required this.count, + required this.apps, + }); + + final IconData icon; + final String title; + final int count; + final List apps; -class UpdatesNotifier extends AsyncNotifier<(List, List, List)> { @override - Future<(List, List, List)> build() async { - // This provider is quite inefficient, as it watches everything - // and always triggers as there's no proper equality for the return type - // There is also no good way to manage state since returning a value - // is necessary. Should migrate to a StateNotifier but there's an - // issue with Flutter Data's autodipose providers in this context. - final appsState = ref.apps.watchAll(); - final localAppsState = ref.localApps.watchAll(); + Widget build(BuildContext context, WidgetRef ref) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 20, color: AppColors.darkActionPrimary), + const SizedBox(width: 8), + Flexible( + child: Text(title, style: context.textTheme.titleMedium), + ), + const SizedBox(width: 8), + CountBadge(count: count, color: AppColors.darkPillBackground), + ], + ), + AsyncButtonBuilder( + onPressed: () async { + final pm = ref.read(packageManagerProvider.notifier); + final items = apps + .where((app) => app.latestFileMetadata != null) + .map( + (app) => ( + appId: app.identifier, + target: app.latestFileMetadata!, + displayName: app.name, + ), + ) + .toList(); + await pm.queueDownloads(items); + }, + builder: (context, child, callback, buttonState) { + const pillBg = AppColors.darkPillBackground; + const pillText = Colors.white; - if (!appsState.hasModel || !localAppsState.hasModel) { - return ([], [], []); - } - - final updatableApps = appsState.model - .where((app) => app.canUpdate && !app.hasCertificateMismatch) - .sortByName(); - - final updatedApps = appsState.model - .where((app) => app.isUpdated || app.isDowngrade) - .sortByName(); - - final unupdatableApps = - appsState.model.where((app) => app.hasCertificateMismatch).sortByName(); - - return (updatedApps, updatableApps, unupdatableApps); + return TextButton.icon( + onPressed: buttonState.maybeWhen( + loading: () => null, + orElse: () => callback, + ), + icon: buttonState.maybeWhen( + loading: () => const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + color: pillText, + ), + ), + orElse: () => + const Icon(Icons.download, size: 14, color: pillText), + ), + label: const Text( + 'Update All', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: pillText, + ), + ), + style: TextButton.styleFrom( + backgroundColor: pillBg, + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ); + }, + child: const SizedBox.shrink(), + ), + ], + ), + ); } } -extension on Iterable { - List sortByName() => toList().sorted((a, b) => - (a.name?.toLowerCase() ?? '').compareTo(b.name?.toLowerCase() ?? '')); +class _ManualUpdatesInfoBox extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.info_outline, + size: 18, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Apps not installed or updated by the latest Zapstore will show here ' + 'and require manual confirmation of the Android system prompt once per app.', + style: context.textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ), + ], + ), + ); + } } -final updatesProvider = - AsyncNotifierProvider, List, List)>( - UpdatesNotifier.new); +class _UpToDateHeader extends StatelessWidget { + const _UpToDateHeader({required this.count}); + + final int count; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Icon( + Icons.check_circle, + size: 20, + color: AppColors.darkActionPrimary, + ), + const SizedBox(width: 8), + Text('Up to date', style: context.textTheme.titleMedium), + const SizedBox(width: 8), + CountBadge(count: count, color: AppColors.darkPillBackground), + ], + ), + ); + } +} + +class _UncatalogedHeader extends StatelessWidget { + const _UncatalogedHeader({required this.count}); + + final int count; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Icon( + Icons.help_outline, + size: 20, + color: AppColors.darkOnSurfaceSecondary, + ), + const SizedBox(width: 8), + Text('Other installed', style: context.textTheme.titleMedium), + const SizedBox(width: 8), + CountBadge(count: count, color: AppColors.darkPillBackground), + ], + ), + ); + } +} + +class _UncatalogedAppCard extends StatelessWidget { + const _UncatalogedAppCard({super.key, required this.packageInfo}); + + final PackageInfo packageInfo; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + // Generic app icon + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + Icons.android, + color: AppColors.darkOnSurfaceSecondary, + size: 28, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + packageInfo.name ?? packageInfo.appId, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + packageInfo.appId, + style: context.textTheme.bodySmall?.copyWith( + color: AppColors.darkOnSurfaceSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + // Version pill + Container( + padding: const EdgeInsets.symmetric( + horizontal: 9, + vertical: 5, + ), + decoration: BoxDecoration( + color: AppColors.darkPillBackground, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + packageInfo.version, + style: context.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/user_screen.dart b/lib/screens/user_screen.dart new file mode 100644 index 0000000..078b365 --- /dev/null +++ b/lib/screens/user_screen.dart @@ -0,0 +1,463 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/utils/extensions.dart'; +import '../theme.dart'; +import '../widgets/common/note_parser.dart'; +import '../widgets/common/profile_avatar.dart'; +import '../widgets/app_card.dart'; +import '../widgets/zap_widgets.dart'; + +/// User profile screen - shows any user/developer profile +class UserScreen extends HookConsumerWidget { + const UserScreen({super.key, required this.pubkey}); + + final String pubkey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Load user profile + final profileState = ref.watch( + query( + authors: {pubkey}, + source: const LocalAndRemoteSource( + relays: {'social', 'vertex'}, + cachedFor: Duration(hours: 2), + ), + ), + ); + final profile = profileState.models.firstOrNull; + + // Query user's apps + final userAppsState = ref.watch( + query( + authors: {pubkey}, + tags: { + '#f': {'android-arm64-v8a'}, + }, + limit: 20, + and: (app) => { + app.latestRelease, + app.latestRelease.value?.latestMetadata, + app.latestRelease.value?.latestAsset, + }, + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), + subscriptionPrefix: 'user-apps', + ), + ); + + // For Zapstore pubkey, only show Zapstore's own apps (not relay-signed ones) + final apps = pubkey == kZapstorePubkey + ? userAppsState.models.where((a) => a.isZapstoreApp).toList() + : userAppsState.models; + + // Query user's app stacks + final appStacksState = ref.watch( + query( + authors: {pubkey}, + limit: 20, + and: (pack) => {pack.apps}, + source: LocalAndRemoteSource(stream: false, relays: 'social'), + andSource: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + subscriptionPrefix: 'user-stacks', + schemaFilter: appStackEventFilter, + ), + ); + final stacks = appStacksState.models.toList() + ..sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); + + return Scaffold( + body: SafeArea( + child: CustomScrollView( + slivers: [ + // Header with avatar and name + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 16), + child: _UserHeader(profile: profile, pubkey: pubkey), + ), + ), + + // Zaps widget + SliverToBoxAdapter(child: _UserZapsList(apps: apps)), + + // Bio section with max height + if (profile?.about != null && profile!.about!.isNotEmpty) + SliverToBoxAdapter(child: _UserBio(profile: profile)), + + // Apps section - only show if apps exist + if (apps.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: Text( + 'Published Apps', + style: context.textTheme.titleLarge, + ), + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final app = apps[index]; + return AppCard(app: app, showSignedBy: false); + }, childCount: apps.length), + ), + ], + + // App stacks section - only show if stacks exist + if (stacks.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 32, 16, 8), + child: Text( + 'App Stacks', + style: context.textTheme.headlineMedium, + ), + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final stack = stacks[index]; + return _StackLinkCard(stack: stack, pubkey: pubkey); + }, childCount: stacks.length), + ), + ], + + // Bottom padding + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ), + ); + } +} + +class _UserHeader extends StatelessWidget { + const _UserHeader({required this.profile, required this.pubkey}); + + final Profile? profile; + final String pubkey; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + ProfileAvatar(profile: profile, radius: 40), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile?.nameOrNpub.abbreviateNpub() ?? + Utils.encodeShareableFromString(pubkey, type: 'npub') + .abbreviateNpub(), + style: Theme.of(context).textTheme.headlineSmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (profile?.nip05 != null) ...[ + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.verified, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + profile!.nip05!, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _UserZapsList extends HookConsumerWidget { + const _UserZapsList({required this.apps}); + + final List apps; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Don't query zaps if user has no apps + if (apps.isEmpty) { + return const SizedBox.shrink(); + } + + // Collect addressable tags for apps and metadata IDs + final allAppTags = >{}; + final metadataIds = {}; + for (final app in apps) { + final appTags = app.event.addressableIdTagMap; + for (final entry in appTags.entries) { + allAppTags[entry.key] = {...?allAppTags[entry.key], ...entry.value}; + } + final metadata = app.latestFileMetadata; + if (metadata != null) { + metadataIds.add(metadata.id); + } + } + + // Query zaps on apps (via #a tag) + final appZapsState = ref.watch( + query( + tags: allAppTags, + source: const LocalAndRemoteSource(relays: 'social'), + subscriptionPrefix: 'user-app-zaps', + ), + ); + + // Query zaps on metadata (via #e tag) - for legacy compatibility + final metadataZapsState = metadataIds.isNotEmpty + ? ref.watch( + query( + tags: {'#e': metadataIds}, + source: const LocalAndRemoteSource(relays: 'social'), + subscriptionPrefix: 'user-metadata-zaps', + ), + ) + : null; + + // Combine zaps from both queries + final allZaps = { + ...appZapsState.models, + if (metadataZapsState != null) ...metadataZapsState.models, + }; + + if (allZaps.isEmpty) { + return const SizedBox.shrink(); + } + + // Collect zapper pubkeys from metadata (already extracted from description tag) + final zapperPubkeys = {}; + for (final zap in allZaps) { + // The zapper's pubkey is in event.metadata['author'], extracted from description + final zapperPubkey = zap.event.metadata['author'] as String?; + if (zapperPubkey != null) { + zapperPubkeys.add(zapperPubkey); + } + } + + // Query profiles separately with cachedFor + final profilesState = ref.watch( + query( + authors: zapperPubkeys, + source: const LocalAndRemoteSource( + relays: {'social', 'vertex'}, + cachedFor: Duration(hours: 2), + ), + ), + ); + final profilesMap = { + for (final p in profilesState.models) p.pubkey: p, + }; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: ZappersHorizontalList( + zaps: allZaps.toList(), + profilesMap: profilesMap, + ), + ); + } +} + +class _StackLinkCard extends StatelessWidget { + const _StackLinkCard({required this.stack, required this.pubkey}); + + final AppStack stack; + final String pubkey; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: InkWell( + onTap: () { + final segments = GoRouterState.of(context).uri.pathSegments; + final first = segments.isNotEmpty ? segments.first : 'search'; + // Build naddr for the stack + final naddr = Utils.encodeShareableIdentifier( + AddressInput( + identifier: stack.identifier, + author: pubkey, + kind: stack.event.kind, + relays: const [], + ), + ); + context.push('/$first/stack/$naddr'); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + stack.name ?? stack.identifier, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 12), + Icon( + Icons.chevron_right, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ); + } +} + +class _UserBio extends HookWidget { + const _UserBio({required this.profile}); + + final Profile profile; + + @override + Widget build(BuildContext context) { + final expanded = useState(false); + const maxHeight = 120.0; + + bool isLikelyLong(String text) { + final trimmed = text.trim(); + if (trimmed.isEmpty) return false; + + final wordCount = trimmed.split(RegExp(r'\s+')).length; + final newlineCount = '\n'.allMatches(trimmed).length; + final charCount = trimmed.length; + + return wordCount > 50 || newlineCount > 4 || charCount > 350; + } + + final about = profile.about ?? ''; + final shouldCollapse = !expanded.value && isLikelyLong(about); + + final bioContent = NoteParser.parse( + context, + about, + textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + height: 1.5, + color: AppColors.darkOnSurfaceSecondary, + ), + linkStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + height: 1.5, + color: Theme.of(context).colorScheme.primary, + ), + onNostrEntity: (entity) => NostrEntityWidget( + entity: entity, + colorPair: [ + Theme.of(context).colorScheme.primary, + Theme.of(context).colorScheme.secondary, + ], + onProfileTap: (pubkey) { + final segments = GoRouterState.of(context).uri.pathSegments; + final first = segments.isNotEmpty ? segments.first : 'search'; + context.push('/$first/user/$pubkey'); + }, + onHashtagTap: (hashtag) => context.push('/search?q=$hashtag'), + ), + ); + + if (!shouldCollapse) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: bioContent, + ); + } + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: maxHeight), + child: ShaderMask( + shaderCallback: (Rect bounds) { + return const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.white, Colors.white, Colors.transparent], + stops: [0.0, 0.7, 1.0], + ).createShader(bounds); + }, + blendMode: BlendMode.dstIn, + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: bioContent, + ), + ), + ), + const SizedBox(height: 8), + Center( + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.08), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => expanded.value = true, + child: Text( + 'Read more', + style: Theme.of(context).textTheme.labelSmall, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/services/app_restart_service.dart b/lib/services/app_restart_service.dart new file mode 100644 index 0000000..f9a5179 --- /dev/null +++ b/lib/services/app_restart_service.dart @@ -0,0 +1,29 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; + +const _channel = MethodChannel('dev.zapstore/app_restart'); +const _markerFileName = '.clear_on_restart'; + +/// Checks if storage should be cleared on this launch, and clears the marker. +/// Call this BEFORE initializing storage. +Future maybeClearStorage(String dbPath) async { + final dir = await getApplicationDocumentsDirectory(); + final marker = File('${dir.path}/$_markerFileName'); + if (await marker.exists()) { + final dbFile = File(dbPath); + if (await dbFile.exists()) { + await dbFile.delete(); + } + await marker.delete(); + } +} + +/// Sets a marker file and triggers a native app restart. +Future restartApp() async { + final dir = await getApplicationDocumentsDirectory(); + final marker = File('${dir.path}/$_markerFileName'); + await marker.create(); + await _channel.invokeMethod('restart'); +} diff --git a/lib/services/background_update_service.dart b/lib/services/background_update_service.dart new file mode 100644 index 0000000..f09b9a7 --- /dev/null +++ b/lib/services/background_update_service.dart @@ -0,0 +1,411 @@ +import 'dart:io' show Directory, Platform; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:purplebase/purplebase.dart'; +import 'package:workmanager/workmanager.dart'; +import 'package:zapstore/services/package_manager/android_package_manager.dart'; +import 'package:zapstore/services/package_manager/dummy_package_manager.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/utils/extensions.dart'; + +/// Unique task name for background update checking +const kBackgroundUpdateTaskName = 'dev.zapstore.backgroundUpdateCheck'; + +/// Unique task name for weekly cleanup +const kWeeklyCleanupTaskName = 'dev.zapstore.weeklyCleanup'; + +/// Unique task identifier +const kBackgroundUpdateTaskId = 'backgroundUpdateCheck'; + +/// Unique task identifier for weekly cleanup +const kWeeklyCleanupTaskId = 'weeklyCleanup'; + +/// Notification channel for update notifications +const kUpdateNotificationChannelId = 'zapstore_updates'; +const kUpdateNotificationChannelName = 'App Updates'; +const kUpdateNotificationChannelDescription = + 'Notifications for available app updates'; + +/// Stale download threshold for cleanup +const _staleDownloadThreshold = Duration(days: 7); + +/// Input data key for AppCatalog relay URLs +const kAppCatalogRelaysKey = 'appCatalogRelays'; + +/// The entry point for WorkManager background tasks. +/// This MUST be a top-level function (not a class method). +@pragma('vm:entry-point') +void callbackDispatcher() { + Workmanager().executeTask((task, inputData) async { + switch (task) { + case kBackgroundUpdateTaskName: + final relayUrls = (inputData?[kAppCatalogRelaysKey] as List?) + ?.cast() + .toSet(); + return await _checkForUpdatesInBackground(relayUrls); + case kWeeklyCleanupTaskName: + return await _performWeeklyCleanup(); + default: + return Future.value(false); + } + }); +} + +/// Perform weekly cleanup of stale downloads +Future _performWeeklyCleanup() async { + try { + final downloader = FileDownloader(); + + // Get all tracked records from background_downloader's database + final trackedRecords = await downloader.database.allRecords( + group: FileDownloader.defaultGroup, + ); + + for (final record in trackedRecords) { + final task = record.task; + if (task is! DownloadTask) continue; + + final taskAge = DateTime.now().difference(record.task.creationTime); + if (taskAge > _staleDownloadThreshold) { + try { + // Cancel if active + await downloader.cancelTaskWithId(task.taskId); + } catch (_) {} + + try { + // Delete the file if it exists + final filePath = await task.filePath(); + final file = Directory(filePath).parent.listSync().firstWhere( + (f) => f.path.endsWith(task.filename), + orElse: () => Directory(''), + ); + if (file.path.isNotEmpty) { + await file.delete(); + } + } catch (_) {} + + try { + // Remove from database + await downloader.database.deleteRecordWithId(task.taskId); + } catch (_) {} + } + } + + // Also clean up orphaned APK files in download directory + try { + final cacheDir = await getApplicationCacheDirectory(); + final downloadDir = Directory( + path.join(cacheDir.path, 'flutter_background_downloader'), + ); + + if (await downloadDir.exists()) { + final files = downloadDir.listSync(); + final cutoff = DateTime.now().subtract(_staleDownloadThreshold); + + for (final file in files) { + if (file is! Directory) { + final stat = await file.stat(); + if (stat.modified.isBefore(cutoff)) { + await file.delete(); + } + } + } + } + } catch (_) { + // Ignore cleanup failures for orphaned files + } + + return true; + } catch (e) { + // Cleanup failed - return false for retry + return false; + } +} + +/// Background update check logic - runs in a separate isolate. +/// Note: Cannot directly reuse [CategorizedAppsNotifier] since this runs +/// in an isolated WorkManager context without the main app's Riverpod setup. +/// +/// [appCatalogRelays] - Relay URLs resolved from main isolate. Falls back to +/// default relay if not provided. +Future _checkForUpdatesInBackground(Set? appCatalogRelays) async { + try { + // Use provided relays or fall back to default + final relays = appCatalogRelays ?? {'wss://relay.zapstore.dev'}; + + // Create a fresh provider container for background work + final container = ProviderContainer( + overrides: [ + storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new), + packageManagerProvider.overrideWith( + (ref) => Platform.isAndroid + ? AndroidPackageManager(ref) + : DummyPackageManager(ref), + ), + ], + ); + + try { + // Initialize Purplebase with same DB path as main app + final dir = await getApplicationDocumentsDirectory(); + final dbPath = path.join(dir.path, 'zapstore.db'); + + await container.read( + initializationProvider( + StorageConfiguration( + databasePath: dbPath, + defaultRelays: {'AppCatalog': relays}, + ), + ).future, + ); + + // Get installed packages directly from Android system + final packageManager = container.read(packageManagerProvider.notifier); + await packageManager.syncInstalledPackages(); + + final pmState = container.read(packageManagerProvider); + if (pmState.installed.isEmpty) { + return true; // No installed apps to check + } + + final installedIds = pmState.installed.keys.toSet(); + + // Query for apps with updates from relay + final storage = container.read(storageNotifierProvider.notifier); + final apps = await storage.query( + RequestFilter( + tags: { + '#d': installedIds, + '#f': {packageManager.platform}, + }, + ).toRequest(), + source: const RemoteSource(relays: 'AppCatalog', stream: false), + ); + + // Load releases for all apps in a single query + if (apps.isNotEmpty) { + final addressableIds = apps + .map((app) => app.event.addressableId) + .toSet(); + await storage.query( + RequestFilter(tags: {'#a': addressableIds}).toRequest(), + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + ); + } + + // Re-query apps from local to ensure relationships are loaded + final appsWithRelations = await storage.query( + RequestFilter( + tags: { + '#d': installedIds, + '#f': {packageManager.platform}, + }, + ).toRequest(), + source: const LocalSource(), + ); + + // Count updates + int updateCount = 0; + final updatableAppNames = []; + + for (final app in appsWithRelations) { + if (app.hasUpdate) { + updateCount++; + updatableAppNames.add(app.name ?? app.identifier); + } + } + + // Show notification if updates found + if (updateCount > 0) { + await _showUpdateNotification(updateCount, updatableAppNames); + } + + return true; + } finally { + container.dispose(); + } + } catch (e) { + // Background task failed - return false to indicate failure + // WorkManager may retry based on configuration + return false; + } +} + +/// Show a local notification for available updates +Future _showUpdateNotification( + int updateCount, + List appNames, +) async { + final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + + // Initialize notifications (needed in background isolate) + const initializationSettingsAndroid = AndroidInitializationSettings( + '@mipmap/ic_launcher', + ); + const initializationSettings = InitializationSettings( + android: initializationSettingsAndroid, + ); + await flutterLocalNotificationsPlugin.initialize(initializationSettings); + + // Build notification content + final title = updateCount == 1 + ? '1 app update available' + : '$updateCount app updates available'; + + final body = appNames.length <= 3 + ? appNames.join(', ') + : '${appNames.take(3).join(', ')} and ${appNames.length - 3} more'; + + const androidDetails = AndroidNotificationDetails( + kUpdateNotificationChannelId, + kUpdateNotificationChannelName, + channelDescription: kUpdateNotificationChannelDescription, + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + showWhen: true, + autoCancel: true, + ); + + const notificationDetails = NotificationDetails(android: androidDetails); + + await flutterLocalNotificationsPlugin.show( + 0, // Notification ID + title, + body, + notificationDetails, + ); +} + +/// Service for managing background update checks +class BackgroundUpdateService { + BackgroundUpdateService(this.ref); + + final Ref ref; + + /// Initialize WorkManager and register periodic task + Future initialize() async { + if (!Platform.isAndroid) { + // WorkManager only works on Android/iOS, skip on other platforms + return; + } + + // Initialize WorkManager + await Workmanager().initialize(callbackDispatcher); + + // Initialize local notifications + await _initializeNotifications(); + + // Resolve AppCatalog relays from main isolate to pass to background task + final appCatalogRelays = await ref + .read(storageNotifierProvider.notifier) + .resolveRelays('AppCatalog'); + + // Register periodic task (minimum 15 minutes on Android) + // We use 6 hours for battery efficiency + await Workmanager().registerPeriodicTask( + kBackgroundUpdateTaskId, + kBackgroundUpdateTaskName, + frequency: const Duration(hours: 6), + constraints: Constraints( + networkType: NetworkType.connected, + requiresBatteryNotLow: true, + ), + existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, + backoffPolicy: BackoffPolicy.exponential, + initialDelay: const Duration(minutes: 15), + inputData: {kAppCatalogRelaysKey: appCatalogRelays.toList()}, + ); + + // Register weekly cleanup task + await Workmanager().registerPeriodicTask( + kWeeklyCleanupTaskId, + kWeeklyCleanupTaskName, + frequency: const Duration(days: 7), + constraints: Constraints( + requiresBatteryNotLow: true, + requiresCharging: true, // Only run when charging for cleanup + ), + existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, + backoffPolicy: BackoffPolicy.exponential, + initialDelay: const Duration(hours: 24), // Start first cleanup after 24h + ); + } + + /// Initialize local notifications plugin and request permission + Future _initializeNotifications() async { + // Request notification permission on Android 13+ (API 33+) + if (Platform.isAndroid) { + final status = await Permission.notification.status; + if (!status.isGranted) { + await Permission.notification.request(); + } + } + + final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + + const initializationSettingsAndroid = AndroidInitializationSettings( + '@mipmap/ic_launcher', + ); + const initializationSettings = InitializationSettings( + android: initializationSettingsAndroid, + ); + + await flutterLocalNotificationsPlugin.initialize( + initializationSettings, + onDidReceiveNotificationResponse: (response) { + // Notification tapped - app will open to main screen + // Navigation is handled by the app's normal launch flow + }, + ); + + // Create notification channel on Android + final androidPlugin = flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + + if (androidPlugin != null) { + await androidPlugin.createNotificationChannel( + const AndroidNotificationChannel( + kUpdateNotificationChannelId, + kUpdateNotificationChannelName, + description: kUpdateNotificationChannelDescription, + importance: Importance.defaultImportance, + ), + ); + } + } + + /// Cancel background update checks + Future cancelBackgroundChecks() async { + await Workmanager().cancelByUniqueName(kBackgroundUpdateTaskId); + } + + /// Trigger an immediate background check (for testing) + Future triggerImmediateCheck() async { + final appCatalogRelays = await ref + .read(storageNotifierProvider.notifier) + .resolveRelays('AppCatalog'); + + await Workmanager().registerOneOffTask( + '${kBackgroundUpdateTaskId}_immediate', + kBackgroundUpdateTaskName, + constraints: Constraints(networkType: NetworkType.connected), + inputData: {kAppCatalogRelaysKey: appCatalogRelays.toList()}, + ); + } +} + +/// Provider for the background update service +final backgroundUpdateServiceProvider = Provider( + BackgroundUpdateService.new, +); diff --git a/lib/services/bookmarks_service.dart b/lib/services/bookmarks_service.dart new file mode 100644 index 0000000..889bee9 --- /dev/null +++ b/lib/services/bookmarks_service.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/utils/extensions.dart'; + +/// Provider that watches the user's saved apps pack and provides decrypted saved apps state +/// Fetches from remote once on boot, then uses local storage only +final bookmarksProvider = FutureProvider>((ref) async { + final signer = ref.watch(Signer.activeSignerProvider); + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + + if (signedInPubkey == null || signer == null) { + return {}; + } + + // Query saved apps stack - stream enabled to auto-update when saved apps change locally + // Initial fetch from remote, then watches local storage for changes + final stackState = ref.watch( + query( + authors: {signedInPubkey}, + tags: { + '#d': {kAppBookmarksIdentifier}, + }, + source: const LocalAndRemoteSource( + relays: 'social', + stream: false, + ), + subscriptionPrefix: 'user-saved-apps', + ), + ); + + // Handle different storage states + final stack = switch (stackState) { + StorageLoading() => null, + StorageError() => null, + StorageData(:final models) => models.firstOrNull, + }; + + if (stack == null || stack.content.isEmpty) { + return {}; + } + + try { + // Content is always encrypted after signing - must explicitly decrypt + final decryptedContent = await signer.nip44Decrypt( + stack.content, + signedInPubkey, + ); + + // Parse the JSON array of app IDs + final appIds = (jsonDecode(decryptedContent) as List) + .cast() + .toSet(); + + return appIds; + } catch (e) { + return {}; + } +}); + +/// Extension to check if an app is saved +extension SavedAppsChecker on WidgetRef { + /// Check if the given app is saved + bool isAppSaved(App app) { + final savedAppsState = watch(bookmarksProvider); + final appAddressableId = + '${app.event.kind}:${app.pubkey}:${app.identifier}'; + + return savedAppsState.when( + data: (savedApps) => savedApps.contains(appAddressableId), + loading: () => false, + error: (_, __) => false, + ); + } + + /// Get all saved app addressable IDs + Set getSavedAppIds() { + final savedAppsState = watch(bookmarksProvider); + + return savedAppsState.when( + data: (savedApps) => savedApps, + loading: () => {}, + error: (_, __) => {}, + ); + } +} diff --git a/lib/services/error_reporting_service.dart b/lib/services/error_reporting_service.dart new file mode 100644 index 0000000..7ff271d --- /dev/null +++ b/lib/services/error_reporting_service.dart @@ -0,0 +1,108 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/utils/extensions.dart'; + +/// Service for reporting errors via NIP-44 encrypted DMs to Zapstore team. +/// +/// Generates an ephemeral signer per report so errors can be sent even when +/// the user is not signed in. +class ErrorReportingService { + ErrorReportingService(this.ref); + + final Ref ref; + + /// Track reported errors to avoid spam (error hash -> timestamp) + final Map _reportedErrors = {}; + + /// Rate limit: one report per error type per session + static const _rateLimitDuration = Duration(minutes: 30); + + /// Maximum reports per session to prevent abuse + static const _maxReportsPerSession = 10; + int _sessionReportCount = 0; + + /// Report an error to the Zapstore team via encrypted DM. + /// + /// Rate-limited to prevent spam. Only sends one report per unique + /// error type per session. + Future reportError(Object exception, StackTrace? stackTrace) async { + // Don't report in debug mode + if (kDebugMode) { + return; + } + + // Check session limit + if (_sessionReportCount >= _maxReportsPerSession) { + return; + } + + // Rate limit by error type + final errorHash = exception.toString().hashCode; + final lastReport = _reportedErrors[errorHash]; + if (lastReport != null && + DateTime.now().difference(lastReport) < _rateLimitDuration) { + return; + } + + try { + // Create ephemeral signer for error reporting + final signer = Bip340PrivateKeySigner(Utils.generateRandomHex64(), ref); + await signer.signIn(setAsActive: false, registerSigner: false); + + // Format error report + final report = _formatErrorReport(exception, stackTrace); + + // Create encrypted DM to Zapstore pubkey (uses NIP-44 by default) + final dm = PartialDirectMessage( + content: report, + receiver: kZapstorePubkey, + ); + + // Sign and publish + final signedDm = await dm.signWith(signer); + await ref.read(storageNotifierProvider.notifier).publish({ + signedDm, + }, source: const RemoteSource(relays: 'social')); + + // Update rate limiting + _reportedErrors[errorHash] = DateTime.now(); + _sessionReportCount++; + } catch (e) { + // Silently fail - we don't want error reporting to cause more errors + } + } + + String _formatErrorReport(Object exception, StackTrace? stackTrace) { + final buffer = StringBuffer(); + + buffer.writeln('=== ZAPSTORE ERROR REPORT ==='); + buffer.writeln('Timestamp: ${DateTime.now().toUtc().toIso8601String()}'); + buffer.writeln('Platform: ${Platform.operatingSystem}'); + buffer.writeln('OS Version: ${Platform.operatingSystemVersion}'); + buffer.writeln(''); + buffer.writeln('Exception:'); + buffer.writeln(exception.toString()); + buffer.writeln(''); + + if (stackTrace != null) { + buffer.writeln('Stack Trace:'); + // Limit stack trace length to avoid huge messages + final stackLines = stackTrace.toString().split('\n'); + final limitedStack = stackLines.take(30).join('\n'); + buffer.writeln(limitedStack); + if (stackLines.length > 30) { + buffer.writeln('... (${stackLines.length - 30} more lines)'); + } + } + + return buffer.toString(); + } +} + +/// Provider for the error reporting service +final errorReportingServiceProvider = Provider( + ErrorReportingService.new, +); diff --git a/lib/services/market_intent_service.dart b/lib/services/market_intent_service.dart new file mode 100644 index 0000000..e802ad0 --- /dev/null +++ b/lib/services/market_intent_service.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:app_links/app_links.dart'; +import 'package:flutter/foundation.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:zapstore/router.dart'; + +/// Provider that initializes market:// intent handling +/// +/// Listens for incoming market:// URIs (e.g., market://details?id=com.example.app) +/// and navigates to the app detail screen. +final marketIntentServiceProvider = Provider((ref) { + return MarketIntentService(ref); +}); + +class MarketIntentService { + final Ref _ref; + final AppLinks _appLinks = AppLinks(); + StreamSubscription? _subscription; + bool _initialized = false; + + MarketIntentService(this._ref); + + GoRouter get _router => _ref.read(routerProvider); + + /// Initialize the service - call once after app is ready + Future initialize() async { + if (_initialized) return; + _initialized = true; + + // Handle initial link (app launched via intent) + final initialUri = await _appLinks.getInitialLink(); + _handleUri(initialUri); + + // Handle links while app is running + _subscription = _appLinks.uriLinkStream.listen(_handleUri); + } + + void _handleUri(Uri? uri) { + final packageId = _extractPackageId(uri); + if (packageId != null) { + _router.go('/search/app/$packageId'); + } + } + + void dispose() { + _subscription?.cancel(); + } +} + +/// Extract package ID from a URI +/// +/// Handles: +/// - market://details?id=com.example.app +/// - market://search?q=search+query +String? _extractPackageId(Uri? uri) { + if (uri == null) return null; + + if (uri.scheme != 'market') return null; + + // market://details?id=com.example.app + if (uri.host == 'details' || uri.path == '/details') { + final id = uri.queryParameters['id']; + if (id != null && id.isNotEmpty) { + debugPrint('Market intent: app ID = $id'); + return id; + } + } + + // market://search?q=query + if (uri.host == 'search' || uri.path == '/search') { + final query = uri.queryParameters['q']; + if (query != null && query.isNotEmpty) { + debugPrint('Market intent: search query = $query'); + return query; + } + } + + debugPrint('Market intent: unhandled URI = $uri'); + return null; +} + diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart new file mode 100644 index 0000000..57541b1 --- /dev/null +++ b/lib/services/notification_service.dart @@ -0,0 +1,453 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:zapstore/theme.dart'; + +extension ContextX on BuildContext { + ThemeData get theme => Theme.of(this); + + void showInfo( + String title, { + String? description, + IconData? icon, + List<(String, Future Function())> actions = const [], + }) { + _showCustomToast( + context: this, + title: title, + description: description, + icon: icon ?? Icons.info_outline_rounded, + type: _ToastType.info, + actions: actions, + ); + } + + void showError( + String title, { + String? description, + IconData? icon, + List<(String, Future Function())> actions = const [], + }) { + // Always add Copy action for errors with description + final allActions = <(String, Future Function())>[ + ...actions, + if (description != null) + ('Copy', () async { + await Clipboard.setData(ClipboardData(text: '$title\n\n$description')); + }), + ]; + + _showCustomToast( + context: this, + title: title, + description: description, + icon: icon ?? Icons.error_outline_rounded, + type: _ToastType.error, + actions: allActions, + ); + } +} + +enum _ToastType { info, error } + +OverlayEntry? _currentToast; + +void _showCustomToast({ + required BuildContext context, + required String title, + String? description, + required IconData icon, + required _ToastType type, + List<(String, Future Function())> actions = const [], +}) { + // Dismiss existing toast + _currentToast?.remove(); + _currentToast = null; + + final overlay = Overlay.of(context); + + late OverlayEntry entry; + entry = OverlayEntry( + builder: (context) => _ToastOverlay( + title: title, + description: description, + icon: icon, + type: type, + actions: actions, + onDismiss: () { + entry.remove(); + if (_currentToast == entry) _currentToast = null; + }, + ), + ); + + _currentToast = entry; + overlay.insert(entry); +} + +class _ToastOverlay extends StatefulWidget { + final String title; + final String? description; + final IconData icon; + final _ToastType type; + final List<(String, Future Function())> actions; + final VoidCallback onDismiss; + + const _ToastOverlay({ + required this.title, + this.description, + required this.icon, + required this.type, + required this.actions, + required this.onDismiss, + }); + + @override + State<_ToastOverlay> createState() => _ToastOverlayState(); +} + +class _ToastOverlayState extends State<_ToastOverlay> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _fadeAnimation; + late Animation _slideAnimation; + bool _isHovered = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + _fadeAnimation = CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + ); + + _slideAnimation = Tween( + begin: const Offset(0, -1), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + )); + + _controller.forward(); + _startAutoClose(); + } + + void _startAutoClose() { + // Longer duration when there are actions, or for errors + final hasActions = widget.actions.isNotEmpty; + final duration = hasActions + ? const Duration(seconds: 10) + : widget.type == _ToastType.error + ? const Duration(seconds: 8) + : const Duration(seconds: 6); + + Future.delayed(duration, () { + if (mounted && !_isHovered) { + _dismiss(); + } + }); + } + + void _dismiss() async { + await _controller.reverse(); + widget.onDismiss(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final screenHeight = MediaQuery.of(context).size.height; + final maxHeight = screenHeight * 0.7; + final topPadding = MediaQuery.of(context).padding.top; + + final isError = widget.type == _ToastType.error; + + // Colors - info: darker light blue, error: red + final backgroundColor = isError + ? const Color(0xFFDC2626) // Red + : const Color(0xFF0284C7); // Darker light blue (sky-600) + + final borderColor = isError + ? const Color(0xFFB91C1C).withValues(alpha: 0.8) // Darker red border + : const Color(0xFF0369A1).withValues(alpha: 0.8); // Even darker blue border (sky-700) + + final accentColor = Colors.white; + + final iconBgColor = Colors.white.withValues(alpha: 0.2); + + return Positioned( + top: topPadding + 8, + left: 12, + right: 12, + child: SlideTransition( + position: _slideAnimation, + child: FadeTransition( + opacity: _fadeAnimation, + child: MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) { + setState(() => _isHovered = false); + _startAutoClose(); + }, + child: GestureDetector( + onTap: _dismiss, + onVerticalDragEnd: (details) { + if (details.primaryVelocity != null && + details.primaryVelocity! < -100) { + _dismiss(); + } + }, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: Material( + color: Colors.transparent, + child: Container( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: borderColor, width: 1), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 24, + offset: const Offset(0, 8), + spreadRadius: 0, + ), + BoxShadow( + color: accentColor.withValues(alpha: 0.08), + blurRadius: 40, + offset: const Offset(0, 4), + spreadRadius: 0, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Accent line at top + Container( + height: 3, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + accentColor.withValues(alpha: 0.8), + accentColor.withValues(alpha: 0.4), + accentColor.withValues(alpha: 0.1), + ], + ), + ), + ), + // Content + Flexible( + child: Padding( + padding: const EdgeInsets.all(16), + child: _ToastContent( + icon: widget.icon, + iconBgColor: iconBgColor, + accentColor: accentColor, + title: widget.title, + description: widget.description, + actions: widget.actions, + onDismiss: _dismiss, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ToastContent extends StatelessWidget { + final IconData icon; + final Color iconBgColor; + final Color accentColor; + final String title; + final String? description; + final List<(String, Future Function())> actions; + final VoidCallback onDismiss; + + const _ToastContent({ + required this.icon, + required this.iconBgColor, + required this.accentColor, + required this.title, + this.description, + required this.actions, + required this.onDismiss, + }); + + @override + Widget build(BuildContext context) { + final hasExtraContent = description != null || actions.isNotEmpty; + + // Use Stack to position X at top-right always + return Stack( + children: [ + // Main content with padding for X button + Padding( + padding: const EdgeInsets.only(right: 36), // Space for X button + child: Row( + // Center align when no description, top align when there's extra content + crossAxisAlignment: hasExtraContent + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + children: [ + // Icon + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: iconBgColor, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + icon, + color: accentColor, + size: 20, + ), + ), + const SizedBox(width: 14), + // Text content + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + Text( + title, + style: const TextStyle( + fontFamily: kFontFamily, + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + height: 1.35, + ), + ), + // Description + if (description != null) ...[ + const SizedBox(height: 6), + Text( + description!, + style: TextStyle( + fontFamily: kFontFamily, + fontSize: 13, + fontWeight: FontWeight.w400, + color: Colors.white.withValues(alpha: 0.7), + height: 1.45, + ), + ), + ], + // Actions + if (actions.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: actions + .map((action) => _ToastActionButton( + label: action.$1, + onPressed: () async { + await action.$2(); + onDismiss(); + }, + accentColor: accentColor, + )) + .toList(), + ), + ], + ], + ), + ), + ], + ), + ), + // Close button - always top-right + Positioned( + top: 0, + right: 0, + child: GestureDetector( + onTap: onDismiss, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.close_rounded, + size: 16, + color: Colors.white.withValues(alpha: 0.5), + ), + ), + ), + ), + ], + ); + } +} + +class _ToastActionButton extends StatelessWidget { + final String label; + final VoidCallback onPressed; + final Color accentColor; + + const _ToastActionButton({ + required this.label, + required this.onPressed, + required this.accentColor, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: accentColor.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Text( + label, + style: TextStyle( + fontFamily: kFontFamily, + fontSize: 13, + fontWeight: FontWeight.w500, + color: accentColor, + ), + ), + ), + ), + ); + } +} diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart new file mode 100644 index 0000000..7c60d88 --- /dev/null +++ b/lib/services/package_manager/android_package_manager.dart @@ -0,0 +1,632 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; + +/// Install status values from native side. +/// +/// IMPORTANT: The native side sends these as strings via EventChannel, so we +/// parse them into this enum for type-safe switching. +enum InstallStatus { + started, + verifying, + pendingUserAction, + alreadyInProgress, + success, + failed, + cancelled, +} + +extension InstallStatusX on InstallStatus { + static InstallStatus? tryParse(String? raw) { + return switch (raw) { + 'started' => InstallStatus.started, + 'verifying' => InstallStatus.verifying, + 'pendingUserAction' => InstallStatus.pendingUserAction, + 'alreadyInProgress' => InstallStatus.alreadyInProgress, + 'success' => InstallStatus.success, + 'failed' => InstallStatus.failed, + 'cancelled' => InstallStatus.cancelled, + _ => null, + }; + } +} + +/// Error codes from native side (structured, reliable) +class NativeErrorCode { + static const downloadFailed = 'downloadFailed'; + static const hashMismatch = 'hashMismatch'; + static const invalidFile = 'invalidFile'; + static const installFailed = 'installFailed'; + static const certMismatch = 'certMismatch'; + static const permissionDenied = 'permissionDenied'; + static const insufficientStorage = 'insufficientStorage'; + static const incompatible = 'incompatible'; + static const blocked = 'blocked'; + static const alreadyInProgress = 'alreadyInProgress'; + static const installTimeout = 'installTimeout'; +} + +/// Android implementation of PackageManager using event-driven architecture. +/// +/// The native side streams install status events via EventChannel. +/// Foreground state is auto-detected by ProcessLifecycleOwner on the native side. +/// No polling, no probing, no hanging awaits. +final class AndroidPackageManager extends PackageManager { + AndroidPackageManager(super.ref) { + _setupEventStream(); + } + + static const _methodChannel = MethodChannel('android_package_manager'); + static const _eventChannel = EventChannel('android_package_manager/events'); + + bool _supportsSilentInstall = false; + int _syncGeneration = 0; + StreamSubscription? _eventSubscription; + + @override + String get platform => 'android-arm64-v8a'; + + @override + String get packageExtension => '.apk'; + + @override + bool get supportsSilentInstall => _supportsSilentInstall; + + // ═══════════════════════════════════════════════════════════════════════════ + // EVENT STREAM HANDLING + // ═══════════════════════════════════════════════════════════════════════════ + + void _setupEventStream() { + _eventSubscription = _eventChannel.receiveBroadcastStream().listen( + _handleInstallEvent, + onError: (_) {}, // Events will resume when stream reconnects + ); + } + + /// Handle install status events from native side. + /// Events arrive sequentially (Dart is single-threaded), no lock needed. + void _handleInstallEvent(dynamic event) { + if (event is! Map) { + debugPrint('[PackageManager] Ignoring non-map event: $event'); + return; + } + + final appId = event['appId'] as String?; + final statusRaw = event['status'] as String?; + final message = event['message'] as String?; + final errorCode = event['errorCode'] as String?; + final description = event['description'] as String?; + final status = InstallStatusX.tryParse(statusRaw); + + debugPrint( + '[PackageManager] Received event: appId=$appId, status=$status, msg=$message, errorCode=$errorCode, desc=$description', + ); + + if (appId == null || statusRaw == null) { + debugPrint('[PackageManager] Ignoring event with null appId or status'); + return; + } + + if (status == null) { + debugPrint( + '[PackageManager] Ignoring event with unknown status: $statusRaw', + ); + return; + } + + // Get target from existing operation state - no separate tracking needed + final existingOp = getOperation(appId); + if (existingOp == null) { + debugPrint( + '[PackageManager] WARNING: No tracked operation for appId=$appId, ignoring $status event', + ); + debugPrint( + '[PackageManager] Current operations: ${state.operations.keys.toList()}', + ); + return; + } + + debugPrint( + '[PackageManager] Processing $status for $appId (current state: ${existingOp.runtimeType})', + ); + + final target = existingOp.target; + final filePath = existingOp.filePath; + + switch (status) { + case InstallStatus.verifying: + // Kotlin started hash verification - show Verifying state + if (filePath != null) { + setOperation(appId, Verifying(target: target, filePath: filePath)); + } + break; + + case InstallStatus.started: + // Install session started - transition to Installing + if (filePath != null) { + final pkg = state.installed[appId]; + final isSilent = pkg?.canInstallSilently ?? false; + setOperation( + appId, + Installing(target: target, filePath: filePath, isSilent: isSilent), + ); + } + break; + + case InstallStatus.pendingUserAction: + // User action required. Ensure we don't get stuck in Verifying if the + // STARTED event was missed; show Installing state. + if (filePath != null && existingOp is! Installing) { + final pkg = state.installed[appId]; + final isSilent = pkg?.canInstallSilently ?? false; + setOperation( + appId, + Installing(target: target, filePath: filePath, isSilent: isSilent), + ); + } + break; + + case InstallStatus.alreadyInProgress: + // Real pending dialog exists - Kotlin will also send pendingUserAction event + // which transitions to Installing. Nothing to do here. + break; + + case InstallStatus.success: + _deleteFile(filePath); + // CRITICAL: Update installed package info DIRECTLY from target metadata. + // We cannot rely on syncInstalledPackages() here because Android's package + // database may not have committed yet, causing a race condition where + // we get stale data and show "Update" instead of "Open". + _updateInstalledPackage(appId, target); + clearOperation(appId); + // Sync in background to get accurate info (signature hash, etc.) + // but our state machine doesn't depend on it. + unawaited(syncInstalledPackages()); + // Advance to next queued install + _advanceAfterDelay(); + break; + + case InstallStatus.failed: + setOperation( + appId, + OperationFailed( + target: target, + type: _errorCodeToFailureType(errorCode, message), + message: message ?? 'Installation failed', + description: description, + filePath: filePath, + ), + ); + // Advance to next queued install + _advanceAfterDelay(); + break; + + case InstallStatus.cancelled: + if (filePath != null) { + setOperation( + appId, + AwaitingUserAction(target: target, filePath: filePath), + ); + } else { + clearOperation(appId); + } + // Advance to next queued install + _advanceAfterDelay(); + break; + } + } + + @override + void onInstallReady(String appId) { + _tryAdvanceNextInstall(); + } + + /// Advance to next install after a delay, giving Android time to clean up. + void _advanceAfterDelay() { + Future.delayed(const Duration(seconds: 1), _tryAdvanceNextInstall); + } + + /// Try to start the next app in ReadyToInstall state. + /// Only advances if no other app is currently installing (one dialog at a time). + void _tryAdvanceNextInstall() { + // Check if any app is currently in an active install state + final hasActiveInstall = state.operations.values.any( + (op) => op is Verifying || op is Installing || op is Uninstalling, + ); + + if (hasActiveInstall) { + debugPrint('[PackageManager] Not advancing - install already active'); + return; + } + + // Get next app ready to install + final readyToInstall = getReadyToInstall(); + if (readyToInstall.isEmpty) { + debugPrint('[PackageManager] No apps waiting in ReadyToInstall'); + return; + } + + final nextAppId = readyToInstall.first; + debugPrint('[PackageManager] Advancing to next install: $nextAppId'); + + // Trigger install (fire-and-forget, events will drive state) + unawaited(triggerInstall(nextAppId)); + } + + /// Convert native error code to FailureType. + /// Uses structured error code when available, falls back to message parsing. + FailureType _errorCodeToFailureType(String? errorCode, String? message) { + // Use structured error code when available (reliable) + if (errorCode != null) { + return switch (errorCode) { + NativeErrorCode.downloadFailed => FailureType.downloadFailed, + NativeErrorCode.hashMismatch => FailureType.hashMismatch, + NativeErrorCode.invalidFile => FailureType.invalidFile, + NativeErrorCode.installFailed => FailureType.installFailed, + NativeErrorCode.certMismatch => FailureType.certMismatch, + NativeErrorCode.permissionDenied => FailureType.permissionDenied, + NativeErrorCode.insufficientStorage => FailureType.insufficientStorage, + NativeErrorCode.incompatible => FailureType.installFailed, + NativeErrorCode.blocked => FailureType.permissionDenied, + NativeErrorCode.installTimeout => FailureType.installFailed, + _ => FailureType.installFailed, + }; + } + + // Fallback: categorize by message content (legacy, less reliable) + if (message == null) return FailureType.installFailed; + final lower = message.toLowerCase(); + if (lower.contains('signature') || + lower.contains('certificate') || + lower.contains('update_incompatible')) { + return FailureType.certMismatch; + } + if (lower.contains('storage') || lower.contains('space')) { + return FailureType.insufficientStorage; + } + if (lower.contains('hash') || lower.contains('verification')) { + return FailureType.hashMismatch; + } + if (lower.contains('invalid') || lower.contains('corrupt')) { + return FailureType.invalidFile; + } + if (lower.contains('permission') || lower.contains('denied')) { + return FailureType.permissionDenied; + } + return FailureType.installFailed; + } + + void _deleteFile(String? path) { + if (path == null) return; + try { + final file = File(path); + if (file.existsSync()) file.deleteSync(); + } catch (_) {} + } + + /// Directly update installed package from target metadata. + /// This ensures we don't depend on Android's package DB timing. + void _updateInstalledPackage(String appId, FileMetadata target) { + final existingPkg = state.installed[appId]; + final newPkg = PackageInfo( + appId: appId, + name: existingPkg?.name, + version: target.version, + versionCode: target.versionCode, + // Keep existing signature hash if available, will be updated by sync + signatureHash: existingPkg?.signatureHash ?? '', + installTime: DateTime.now(), + canInstallSilently: existingPkg?.canInstallSilently ?? false, + ); + state = state.copyWith(installed: {...state.installed, appId: newPkg}); + } + + @override + void dispose() { + _eventSubscription?.cancel(); + super.dispose(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // INSTALLATION (Fire-and-forget - results come via events) + // ═══════════════════════════════════════════════════════════════════════════ + + @override + Future install( + String appId, + String filePath, { + required String expectedHash, + required int expectedSize, + required FileMetadata target, + }) async { + // Permission is already checked by _proceedToInstall in base class + // No need for redundant _ensureInstallPermission() call here + + final file = File(filePath); + if (!file.existsSync()) { + throw Exception('APK file not found: $filePath'); + } + + // Native side is the source of truth for install state; it will emit + // VERIFYING/STARTED/PENDING_USER_ACTION/SUCCESS/FAILED/CANCELLED events. + + try { + final result = await _methodChannel + .invokeMethod>('install', { + 'filePath': filePath, + 'packageName': appId, + 'expectedHash': expectedHash, + 'expectedSize': expectedSize, + }) + .timeout(const Duration(seconds: 30), onTimeout: () => null); + + final resultMap = Map.from(result ?? {}); + + if (resultMap['alreadyInProgress'] == true) { + // Real pending dialog exists - Kotlin re-launched it and sent pendingUserAction + // event which transitions to Installing. Nothing to do here. + return; + } + + if (resultMap['started'] != true) { + final error = + resultMap['error'] as String? ?? 'Failed to start install'; + final errorCode = resultMap['errorCode'] as String?; + setOperation( + appId, + OperationFailed( + target: target, + type: _errorCodeToFailureType(errorCode, error), + message: error, + filePath: filePath, + ), + ); + // Don't auto-advance after failure + } + // If started, wait for events via EventChannel + } catch (e) { + setOperation( + appId, + OperationFailed( + target: target, + type: FailureType.installFailed, + message: e.toString(), + filePath: filePath, + ), + ); + // Don't auto-advance after failure + } + } + + @override + Future uninstall(String appId) async { + final result = await _methodChannel + .invokeMethod>('uninstall', { + 'packageName': appId, + }) + .timeout( + const Duration(seconds: 60), // Uninstall needs user confirmation + onTimeout: () => { + 'isSuccess': false, + 'errorMessage': 'Uninstall timed out', + }, + ); + + final resultMap = Map.from(result ?? {}); + + if (resultMap['isSuccess'] != true) { + if (resultMap['cancelled'] == true) { + throw Exception('Uninstall was cancelled'); + } + throw Exception(resultMap['errorMessage'] ?? 'Uninstallation failed'); + } + + // Remove from installed immediately (don't wait for sync) + state = state.copyWith(installed: Map.from(state.installed)..remove(appId)); + + // Background sync for consistency + syncInstalledPackages(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // PERMISSIONS + // ═══════════════════════════════════════════════════════════════════════════ + + @override + Future requestPermission() async { + try { + final result = await _methodChannel + .invokeMethod>('requestInstallPermission') + .timeout( + const Duration(seconds: 5), + onTimeout: () => { + 'success': false, + 'message': 'Request timed out', + }, + ); + final resultMap = Map.from(result ?? {}); + + if (resultMap['success'] != true) { + throw Exception(resultMap['message'] ?? 'Failed to request permission'); + } + } catch (e) { + throw Exception( + 'CRITICAL: "Install unknown apps" permission is required.\n\n' + 'Please:\n' + '1. Go to Android Settings\n' + '2. Apps > ZapStore > Install unknown apps\n' + '3. Enable "Allow from this source"\n' + '4. Try installation again', + ); + } + } + + @override + Future hasPermission() async { + try { + return await _methodChannel + .invokeMethod('hasUnknownSourcesPermission') + .timeout(const Duration(seconds: 5), onTimeout: () => false) ?? + false; + } catch (_) { + return false; + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // APP LAUNCH + // ═══════════════════════════════════════════════════════════════════════════ + + @override + Future launchApp(String appId) async { + try { + final result = await _methodChannel + .invokeMethod>('launchApp', { + 'packageName': appId, + }) + .timeout( + const Duration(seconds: 10), + onTimeout: () => { + 'isSuccess': false, + 'errorMessage': 'App launch timed out', + }, + ); + + final resultMap = Map.from(result ?? {}); + + if (resultMap['isSuccess'] != true) { + throw Exception(resultMap['errorMessage'] ?? 'Failed to launch app'); + } + } catch (e) { + throw Exception('Failed to launch app: $e'); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // SYNC INSTALLED PACKAGES + // ═══════════════════════════════════════════════════════════════════════════ + + @override + Future syncInstalledPackages() async { + final syncGen = ++_syncGeneration; + try { + // Single native call - getInstalledApps already returns canInstallSilently per-app + final installedApps = + await _methodChannel + .invokeMethod>('getInstalledApps', { + 'includeSystemApps': false, + }) + .timeout( + const Duration(seconds: 10), + onTimeout: () => [], + ) ?? + []; + + if (syncGen != _syncGeneration) return; + + final packages = {}; + var anyCanInstallSilently = false; + + for (final appObj in installedApps) { + final app = Map.from(appObj as Map); + final appId = + app['bundleId'] as String? ?? app['packageName'] as String? ?? ''; + final name = app['name'] as String?; + final version = app['versionName'] as String? ?? '0.0.0'; + final versionCode = app['versionCode'] as int?; + final signatureHash = app['signatureHash'] as String? ?? ''; + final canInstallSilently = app['canInstallSilently'] as bool? ?? false; + + if (canInstallSilently) anyCanInstallSilently = true; + + if (appId.isNotEmpty) { + packages[appId] = PackageInfo( + appId: appId, + name: name, + version: version, + versionCode: versionCode, + signatureHash: signatureHash, + installTime: null, + canInstallSilently: canInstallSilently, + ); + } + } + + if (syncGen != _syncGeneration) return; + + // Derive general silent install capability from per-app data + _supportsSilentInstall = anyCanInstallSilently; + + // Preserve any packages we've directly updated (they have installTime set) + // This prevents race conditions where sync returns stale data + final preserved = {}; + for (final entry in state.installed.entries) { + if (entry.value.installTime != null && + !packages.containsKey(entry.key)) { + // We directly updated this but sync doesn't have it yet - keep ours + preserved[entry.key] = entry.value; + } else if (entry.value.installTime != null && + packages.containsKey(entry.key)) { + // Both have it - use sync data but preserve installTime marker + final syncPkg = packages[entry.key]!; + preserved[entry.key] = PackageInfo( + appId: syncPkg.appId, + name: syncPkg.name, + version: syncPkg.version, + versionCode: syncPkg.versionCode, + signatureHash: syncPkg.signatureHash, + installTime: entry.value.installTime, + canInstallSilently: syncPkg.canInstallSilently, + ); + } + } + + state = state.copyWith(installed: {...packages, ...preserved}); + + // Clear operations for apps where the installed version matches the target version + // This catches installs that succeeded but we missed the event + // IMPORTANT: Don't clear operations where we're updating to a NEWER version + for (final appId in packages.keys) { + final op = getOperation(appId); + if (op == null) continue; + if (op is! Installing && + op is! Verifying && + op is! AwaitingUserAction) { + continue; + } + + final installedPkg = packages[appId]; + final targetVc = op.target.versionCode; + final installedVc = installedPkg?.versionCode; + final targetV = op.target.version; + final installedV = installedPkg?.version; + + final completed = + (targetVc != null && + installedVc != null && + installedVc >= targetVc) || + (installedV != null && installedV == targetV); + + // Only clear if we can establish completion reliably. + if (completed) { + debugPrint( + '[PackageManager] Sync: clearing completed operation for $appId ' + '(installedVc=$installedVc, targetVc=$targetVc, installedV=$installedV, targetV=$targetV)', + ); + clearOperation(appId); + } else { + debugPrint( + '[PackageManager] Sync: keeping operation for $appId ' + '(installedVc=$installedVc, targetVc=$targetVc, installedV=$installedV, targetV=$targetV)', + ); + } + } + } catch (_) { + // Don't clobber state on transient errors + } + } +} diff --git a/lib/services/package_manager/dummy_package_manager.dart b/lib/services/package_manager/dummy_package_manager.dart new file mode 100644 index 0000000..0f41b3a --- /dev/null +++ b/lib/services/package_manager/dummy_package_manager.dart @@ -0,0 +1,98 @@ +import 'package:models/models.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; + +/// Dummy implementation of PackageManager for testing and non-Android platforms +final class DummyPackageManager extends PackageManager { + DummyPackageManager(super.ref) { + state = PackageManagerState( + installed: { + 'com.example.test': const PackageInfo( + appId: 'com.example.test', + name: 'Test App', + version: '1.0.0', + versionCode: 1, + signatureHash: 'dummy_signature_1', + ), + 'dev.zapstore.alpha': const PackageInfo( + appId: 'dev.zapstore.alpha', + name: 'Zapstore', + version: '1.0.0', + versionCode: 1, + signatureHash: 'dummy_signature_2', + ), + 'com.dummy.browser': const PackageInfo( + appId: 'com.dummy.browser', + name: 'Dummy Browser', + version: '2.1.0', + versionCode: 210, + signatureHash: 'dummy_signature_3', + ), + }, + operations: const {}, + ); + } + + @override + String get platform => 'android-arm64-v8a'; + + @override + String get packageExtension => '.apk'; + + @override + bool get supportsSilentInstall => false; + + @override + Future install( + String appId, + String filePath, { + required String expectedHash, + required int expectedSize, + required FileMetadata target, + }) async { + // Mock: simulate install delay then add to installed + await Future.delayed(const Duration(milliseconds: 500)); + + final newInstalled = Map.from(state.installed); + newInstalled[appId] = PackageInfo( + appId: appId, + version: target.version, + versionCode: target.versionCode, + signatureHash: 'mock_signature', + installTime: DateTime.now(), + ); + state = state.copyWith(installed: newInstalled); + + // Clear the operation since install completed + clearOperation(appId); + } + + @override + Future uninstall(String appId) async { + await Future.delayed(const Duration(milliseconds: 300)); + + final newInstalled = Map.from(state.installed); + newInstalled.remove(appId); + state = state.copyWith(installed: newInstalled); + } + + @override + Future launchApp(String appId) async { + if (!state.installed.containsKey(appId)) { + throw Exception('App not installed: $appId'); + } + await Future.delayed(const Duration(milliseconds: 500)); + } + + @override + Future requestPermission() async { + // Mock: always succeeds + } + + @override + Future hasPermission() async => true; + + @override + Future syncInstalledPackages() async { + // No-op for dummy implementation + } +} diff --git a/lib/services/package_manager/install_operation.dart b/lib/services/package_manager/install_operation.dart new file mode 100644 index 0000000..817a884 --- /dev/null +++ b/lib/services/package_manager/install_operation.dart @@ -0,0 +1,204 @@ +import 'package:models/models.dart'; + +/// Stale download threshold - operations older than this will be cleaned up +const staleOperationThreshold = Duration(days: 7); + +/// Maximum concurrent downloads allowed +const maxConcurrentDownloads = 3; + +// ═══════════════════════════════════════════════════════════════════════════════ +// INSTALL OPERATION STATE MACHINE +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Represents an active install operation for an app. +/// When there's no operation, the app simply has no entry in the operations map. +sealed class InstallOperation { + /// The target file metadata being installed (works for both FileMetadata and SoftwareAsset) + final FileMetadata target; + + const InstallOperation({required this.target}); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// DOWNLOAD PHASE (Cancel allowed) +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Waiting for download slot (max concurrent reached) +class DownloadQueued extends InstallOperation { + final String? displayName; + + const DownloadQueued({required super.target, this.displayName}); +} + +/// Actively downloading +class Downloading extends InstallOperation { + final double progress; + final String taskId; + + const Downloading({ + required super.target, + required this.progress, + required this.taskId, + }); + + Downloading copyWith({double? progress}) { + return Downloading( + target: target, + progress: progress ?? this.progress, + taskId: taskId, + ); + } +} + +/// Download paused by user +class DownloadPaused extends InstallOperation { + final double progress; + final String taskId; + + const DownloadPaused({ + required super.target, + required this.progress, + required this.taskId, + }); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// VERIFICATION PHASE (Kotlin is verifying hash) +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Verifying downloaded file hash (happens in Kotlin, visible to UI) +class Verifying extends InstallOperation { + final String filePath; + + const Verifying({required super.target, required this.filePath}); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// PERMISSION PHASE (Explicit for UX feedback) +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Waiting for user to grant install permission +class AwaitingPermission extends InstallOperation { + final String filePath; + + const AwaitingPermission({required super.target, required this.filePath}); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// INSTALL PHASE (No cancel - Android controls) +// ═══════════════════════════════════════════════════════════════════════════════ + +/// File verified, waiting for install to be triggered +class ReadyToInstall extends InstallOperation { + final String filePath; + + const ReadyToInstall({required super.target, required this.filePath}); +} + +/// Native installation in progress +class Installing extends InstallOperation { + final String filePath; + final bool isSilent; + final DateTime startedAt; + + Installing({ + required super.target, + required this.filePath, + this.isSilent = false, + DateTime? startedAt, + }) : startedAt = startedAt ?? DateTime.now(); +} + +/// System dialog was dismissed/backgrounded - user can retry +/// Different from Failed: this is recoverable with a tap +class AwaitingUserAction extends InstallOperation { + final String filePath; + + const AwaitingUserAction({required super.target, required this.filePath}); +} + +/// Uninstalling app (for force update: uninstall → install) +class Uninstalling extends InstallOperation { + final String filePath; + + const Uninstalling({required super.target, required this.filePath}); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// FAILURE STATE (Dismiss available) +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Operation failed - may be retryable depending on type +class OperationFailed extends InstallOperation { + final FailureType type; + final String message; + final String? description; + final String? filePath; + + const OperationFailed({ + required super.target, + required this.type, + required this.message, + this.description, + this.filePath, + }); + + /// Whether this requires force update (uninstall + install) + bool get needsForceUpdate => type == FailureType.certMismatch; +} + +/// Types of failures that can occur during install operations +enum FailureType { + /// Network error, timeout, server error during download + downloadFailed, + + /// Hash doesn't match expected - can retry with reckless mode + hashMismatch, + + /// File is corrupted or not a valid APK + invalidFile, + + /// Generic installation error + installFailed, + + /// Certificate/signature mismatch - needs force update (uninstall + install) + certMismatch, + + /// User doesn't have install permission + permissionDenied, + + /// Not enough storage space + insufficientStorage, +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// HELPER EXTENSIONS +// ═══════════════════════════════════════════════════════════════════════════════ + +extension InstallOperationX on InstallOperation { + /// Whether this operation is in a download state (cancel allowed) + bool get isDownloading => + this is DownloadQueued || this is Downloading || this is DownloadPaused; + + /// Whether this operation is actively processing (not waiting for user) + bool get isActive => + this is Downloading || + this is Verifying || + this is Installing || + this is Uninstalling; + + /// Whether this operation is in the verification phase + bool get isVerifying => this is Verifying; + + /// Get file path if available + String? get filePath => switch (this) { + Verifying(:final filePath) => filePath, + AwaitingPermission(:final filePath) => filePath, + ReadyToInstall(:final filePath) => filePath, + Installing(:final filePath) => filePath, + AwaitingUserAction(:final filePath) => filePath, + Uninstalling(:final filePath) => filePath, + OperationFailed(:final filePath) => filePath, + _ => null, + }; +} diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart new file mode 100644 index 0000000..bf5dfd6 --- /dev/null +++ b/lib/services/package_manager/package_manager.dart @@ -0,0 +1,1097 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/services/package_manager/dummy_package_manager.dart'; +import 'package:zapstore/services/package_manager/install_operation.dart'; +import 'package:zapstore/utils/version_utils.dart'; + +export 'install_operation.dart'; + +// ═══════════════════════════════════════════════════════════════════════════════ +// PACKAGE INFO +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Information about an installed package +class PackageInfo extends Equatable { + const PackageInfo({ + required this.appId, + required this.version, + required this.versionCode, + required this.signatureHash, + this.name, + this.installTime, + this.canInstallSilently = false, + }); + + final String appId; + final String? name; + final String version; + final int? versionCode; + final String signatureHash; + final DateTime? installTime; + final bool canInstallSilently; + + @override + List get props => [ + appId, + name, + version, + versionCode, + signatureHash, + installTime, + canInstallSilently, + ]; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// PACKAGE MANAGER STATE +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Combined state for all package-related data +class PackageManagerState extends Equatable { + const PackageManagerState({ + this.installed = const {}, + this.operations = const {}, + }); + + /// Map of appId → installed package info + final Map installed; + + /// Map of appId → active install operation + final Map operations; + + PackageManagerState copyWith({ + Map? installed, + Map? operations, + }) { + return PackageManagerState( + installed: installed ?? this.installed, + operations: operations ?? this.operations, + ); + } + + @override + List get props => [installed, operations]; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// PACKAGE MANAGER BASE CLASS +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Package management interface - the single source of truth for: +/// 1. Installed packages +/// 2. Active install operations (download/verify/install) +/// +/// Architecture: +/// - Download phase: Managed by background_downloader (can pause/resume/cancel) +/// - Install phase: Platform-specific, event-driven (no hanging awaits) +abstract class PackageManager extends StateNotifier { + PackageManager(this.ref) : super(const PackageManagerState()) { + _downloaderInit = _initializeDownloader(); + } + + final Ref ref; + late final FileDownloader _downloader; + late final Future _downloaderInit; + + Future _ensureDownloaderReady() => _downloaderInit; + + /// Hook called when an app transitions into [ReadyToInstall]. + /// + /// Default behavior is to immediately start installation. Platforms that must + /// serialize installs (Android PackageInstaller UI) should override this to + /// queue/advance one install at a time. + @protected + void onInstallReady(String appId) { + unawaited(triggerInstall(appId)); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // INITIALIZATION + // ═══════════════════════════════════════════════════════════════════════════ + + Future _initializeDownloader() async { + _downloader = FileDownloader(); + + try { + await _downloader.configure( + globalConfig: [ + (Config.requestTimeout, const Duration(seconds: 20)), + (Config.resourceTimeout, const Duration(minutes: 30)), + (Config.checkAvailableSpace, Config.never), + ], + androidConfig: [(Config.useCacheDir, false)], + ); + } catch (_) {} + + _downloader.configureNotificationForGroup( + FileDownloader.defaultGroup, + running: const TaskNotification( + 'Downloading {displayName}', + '{progress}', + ), + // No 'complete' notification - download completion immediately triggers install + // so showing "Download complete" is redundant and creates notification clutter + complete: null, + error: const TaskNotification('Download failed', '{displayName}'), + paused: const TaskNotification('Download paused', '{displayName}'), + progressBar: true, + ); + + _downloader.registerCallbacks( + taskStatusCallback: _handleDownloadUpdate, + taskProgressCallback: _handleDownloadUpdate, + ); + + await _restoreOperations(); + } + + @override + void dispose() { + _downloader.unregisterCallbacks(); + super.dispose(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // QUERIES + // ═══════════════════════════════════════════════════════════════════════════ + + bool isInstalled(String appId) => state.installed.containsKey(appId); + + PackageInfo? getInstalled(String appId) => state.installed[appId]; + + InstallOperation? getOperation(String appId) => state.operations[appId]; + + bool hasOperation(String appId) => state.operations.containsKey(appId); + + int countOperations() => + state.operations.values.whereType().length; + + List getReadyToInstall() => state.operations.entries + .where((e) => e.value is ReadyToInstall) + .map((e) => e.key) + .toList(); + + List getAwaitingUserAction() => state.operations.entries + .where((e) => e.value is AwaitingUserAction) + .map((e) => e.key) + .toList(); + + // ═══════════════════════════════════════════════════════════════════════════ + // STATE MANAGEMENT (Public for subclass use) + // ═══════════════════════════════════════════════════════════════════════════ + + void setOperation(String appId, InstallOperation op) { + state = state.copyWith(operations: {...state.operations, appId: op}); + } + + void clearOperation(String appId) { + state = state.copyWith( + operations: Map.from(state.operations)..remove(appId), + ); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // DOWNLOAD OPERATIONS + // ═══════════════════════════════════════════════════════════════════════════ + + /// Start download - returns false if operation already exists for this app + /// [displayName] is shown in system notification (defaults to appId if null) + Future startDownload( + String appId, + FileMetadata target, { + String? displayName, + }) async { + await _ensureDownloaderReady(); + if (hasOperation(appId)) return false; + + final downloadUrl = target.urls.firstOrNull; + if (downloadUrl == null || downloadUrl.isEmpty) { + setOperation( + appId, + OperationFailed( + target: target, + type: FailureType.downloadFailed, + message: 'No download URL available', + ), + ); + return false; + } + + // Check download slots - only count active downloads, not queued + final activeDownloads = countOperations(); + + if (activeDownloads >= maxConcurrentDownloads) { + setOperation( + appId, + DownloadQueued(target: target, displayName: displayName), + ); + return true; + } + + await _startDownloadTask( + appId, + target, + downloadUrl, + displayName: displayName, + ); + return true; + } + + /// Queue multiple downloads at once - immediately marks all as queued, + /// then starts up to [maxConcurrentDownloads] actual downloads. + /// This prevents UI confusion when "Update All" is tapped. + Future queueDownloads( + List<({String appId, FileMetadata target, String? displayName})> items, + ) async { + await _ensureDownloaderReady(); + + // Filter out items that already have operations + final toQueue = items.where((item) => !hasOperation(item.appId)).toList(); + if (toQueue.isEmpty) return; + + // First, mark ALL items as queued immediately for responsive UI + for (final item in toQueue) { + final downloadUrl = item.target.urls.firstOrNull; + if (downloadUrl == null || downloadUrl.isEmpty) { + setOperation( + item.appId, + OperationFailed( + target: item.target, + type: FailureType.downloadFailed, + message: 'No download URL available', + ), + ); + } else { + setOperation( + item.appId, + DownloadQueued(target: item.target, displayName: item.displayName), + ); + } + } + + // Now process the queue to start actual downloads + _processQueuedDownload(); + } + + Future pauseDownload(String appId) async { + await _ensureDownloaderReady(); + final op = getOperation(appId); + if (op is! Downloading) return; + + try { + final task = await _downloader.taskForId(op.taskId); + if (task is DownloadTask) { + await _downloader.pause(task); + } + } catch (_) {} + } + + Future resumeDownload(String appId) async { + await _ensureDownloaderReady(); + final op = getOperation(appId); + if (op is! DownloadPaused) return; + + try { + final task = await _downloader.taskForId(op.taskId); + if (task is DownloadTask) { + await _downloader.resume(task); + setOperation( + appId, + Downloading( + target: op.target, + progress: op.progress, + taskId: op.taskId, + ), + ); + } + } catch (_) {} + } + + Future cancelDownload(String appId) async { + await _ensureDownloaderReady(); + final op = getOperation(appId); + if (op == null || !op.isDownloading) return; + + if (op is Downloading) { + try { + await _downloader.cancelTaskWithId(op.taskId); + } catch (_) {} + } else if (op is DownloadPaused) { + try { + await _downloader.cancelTaskWithId(op.taskId); + } catch (_) {} + } + + clearOperation(appId); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // INSTALL OPERATIONS + // ═══════════════════════════════════════════════════════════════════════════ + + /// Trigger install from ReadyToInstall state + Future triggerInstall(String appId) async { + final op = getOperation(appId); + if (op is! ReadyToInstall) return; + + if (!await File(op.filePath).exists()) { + setOperation( + appId, + OperationFailed( + target: op.target, + type: FailureType.downloadFailed, + message: 'Downloaded file not found. Please download again.', + ), + ); + return; + } + + // Directly perform install - permission was already checked when + // transitioning to ReadyToInstall state. Don't call _proceedToInstall + // here as that would re-set ReadyToInstall and call onInstallReady again, + // causing an infinite loop. + await _performInstall(appId, op.target, op.filePath); + } + + /// Retry install from AwaitingUserAction state + Future retryInstall(String appId) async { + final op = getOperation(appId); + if (op is! AwaitingUserAction) return; + + if (!await File(op.filePath).exists()) { + setOperation( + appId, + OperationFailed( + target: op.target, + type: FailureType.downloadFailed, + message: 'Downloaded file not found. Please download again.', + ), + ); + return; + } + + await _performInstall(appId, op.target, op.filePath); + } + + /// Force update (uninstall + install) from OperationFailed with certMismatch + Future forceUpdate(String appId) async { + final op = getOperation(appId); + if (op is! OperationFailed || !op.needsForceUpdate) return; + + final filePath = op.filePath; + if (filePath == null || !await File(filePath).exists()) { + setOperation( + appId, + OperationFailed( + target: op.target, + type: FailureType.downloadFailed, + message: 'Downloaded file not found. Please download again.', + ), + ); + return; + } + + setOperation(appId, Uninstalling(target: op.target, filePath: filePath)); + + try { + await uninstall(appId); + await _performInstall(appId, op.target, filePath); + } catch (e) { + final message = e.toString(); + if (!message.contains('cancelled')) { + setOperation( + appId, + OperationFailed( + target: op.target, + type: FailureType.installFailed, + message: 'Force update failed: $message', + filePath: filePath, + ), + ); + } else { + setOperation( + appId, + OperationFailed( + target: op.target, + type: FailureType.certMismatch, + message: + 'Certificate mismatch. Uninstall current version to update.', + filePath: filePath, + ), + ); + } + } + } + + /// Dismiss error and clean up + void dismissError(String appId) { + final op = getOperation(appId); + if (op is! OperationFailed) return; + + final filePath = op.filePath; + if (filePath != null) { + _deleteFile(filePath); + } + clearOperation(appId); + } + + /// Called when permission is granted. + /// Advances the specified app AND all other apps awaiting permission. + Future onPermissionGranted(String appId) async { + // Collect all apps that need to advance (AwaitingPermission or permissionDenied failures) + final toAdvance = {}; + + for (final entry in state.operations.entries) { + final id = entry.key; + final op = entry.value; + switch (op) { + case AwaitingPermission(:final target, :final filePath): + toAdvance[id] = (target, filePath); + case OperationFailed(:final target, :final type, :final filePath) + when type == FailureType.permissionDenied && filePath != null: + toAdvance[id] = (target, filePath); + default: + continue; + } + } + + if (toAdvance.isEmpty) return; + + // Advance the requested app first (for responsive UX) + if (toAdvance.containsKey(appId)) { + final (target, filePath) = toAdvance.remove(appId)!; + // Permission is granted now: transition to ReadyToInstall and let the + // platform decide whether to auto-start or queue. + setOperation(appId, ReadyToInstall(target: target, filePath: filePath)); + onInstallReady(appId); + } + + // Advance remaining apps + for (final entry in toAdvance.entries) { + final (target, filePath) = entry.value; + setOperation( + entry.key, + ReadyToInstall(target: target, filePath: filePath), + ); + onInstallReady(entry.key); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // DOWNLOAD INTERNALS + // ═══════════════════════════════════════════════════════════════════════════ + + Future _startDownloadTask( + String appId, + FileMetadata target, + String downloadUrl, { + String? displayName, + }) async { + final fileName = '${target.hash}$packageExtension'; + final metaData = _encodeTaskMetadata(appId, target.id); + + final task = DownloadTask( + taskId: + '${appId}_${DateTime.now().millisecondsSinceEpoch}_${UniqueKey()}', + url: downloadUrl, + filename: fileName, + updates: Updates.statusAndProgress, + requiresWiFi: false, + retries: 3, + allowPause: true, + metaData: metaData, + displayName: displayName ?? appId, + ); + + setOperation( + appId, + Downloading(target: target, progress: 0.0, taskId: task.taskId), + ); + + try { + final result = await _downloader.enqueue(task); + if (!result) { + setOperation( + appId, + OperationFailed( + target: target, + type: FailureType.downloadFailed, + message: 'Failed to start download', + ), + ); + } + } catch (e) { + setOperation( + appId, + OperationFailed( + target: target, + type: FailureType.downloadFailed, + message: 'Failed to start download: $e', + ), + ); + } + } + + void _handleDownloadUpdate(TaskUpdate update) { + // Fast path: decode appId from task metadata (no O(n) scan on each tick). + String? appId; + InstallOperation? operation; + + final metaData = update.task.metaData; + if (metaData.isNotEmpty) { + final (decodedAppId, _) = _parseTaskMetadata(metaData); + if (decodedAppId != null) { + final op = getOperation(decodedAppId); + // Ensure the operation actually matches this taskId (metadata could be stale). + if (op is Downloading && op.taskId == update.task.taskId) { + appId = decodedAppId; + operation = op; + } else if (op is DownloadPaused && op.taskId == update.task.taskId) { + appId = decodedAppId; + operation = op; + } + } + } + + // Fallback: old tasks without metadata, or mismatched metadata. + if (appId == null || operation == null) { + for (final entry in state.operations.entries) { + final op = entry.value; + if (op is Downloading && op.taskId == update.task.taskId) { + appId = entry.key; + operation = op; + break; + } else if (op is DownloadPaused && op.taskId == update.task.taskId) { + appId = entry.key; + operation = op; + break; + } + } + } + if (appId == null || operation == null) return; + + if (update is TaskStatusUpdate) { + _handleDownloadStatusUpdate(appId, operation, update); + } else if (update is TaskProgressUpdate) { + _handleDownloadProgressUpdate(appId, operation, update); + } + } + + void _handleDownloadStatusUpdate( + String appId, + InstallOperation operation, + TaskStatusUpdate update, + ) { + final target = operation.target; + + switch (update.status) { + case TaskStatus.running: + if (operation is DownloadPaused) { + setOperation( + appId, + Downloading( + target: target, + progress: operation.progress, + taskId: operation.taskId, + ), + ); + } + break; + + case TaskStatus.paused: + if (operation is Downloading) { + setOperation( + appId, + DownloadPaused( + target: target, + progress: operation.progress, + taskId: operation.taskId, + ), + ); + } + break; + + case TaskStatus.complete: + unawaited( + _handleDownloadComplete(appId, target, update.task as DownloadTask), + ); + break; + + case TaskStatus.failed: + String error = 'Download failed'; + if (update.exception != null) { + error = update.exception.toString(); + if (error.length > 200) error = '${error.substring(0, 197)}...'; + } + setOperation( + appId, + OperationFailed( + target: target, + type: FailureType.downloadFailed, + message: error, + ), + ); + _processQueuedDownload(); + break; + + case TaskStatus.canceled: + clearOperation(appId); + _processQueuedDownload(); + break; + + default: + break; + } + } + + void _handleDownloadProgressUpdate( + String appId, + InstallOperation operation, + TaskProgressUpdate update, + ) { + if (operation is! Downloading) return; + + double progress = operation.progress; + if (update.progress.isFinite && + !update.progress.isNaN && + update.progress >= 0.0) { + progress = update.progress.clamp(0.0, 1.0); + } + + // Throttle: update state only when the displayed percentage changes. + final oldPercent = (operation.progress * 100).floor(); + final newPercent = (progress * 100).floor(); + if (newPercent == oldPercent) return; + + setOperation(appId, operation.copyWith(progress: progress)); + } + + Future _handleDownloadComplete( + String appId, + FileMetadata target, + DownloadTask task, + ) async { + try { + final filePath = await task.filePath(); + _processQueuedDownload(); + await _proceedToInstall(appId, target, filePath); + } catch (e) { + setOperation( + appId, + OperationFailed( + target: target, + type: FailureType.downloadFailed, + message: 'Failed to access downloaded file: $e', + ), + ); + _processQueuedDownload(); + } + } + + void _processQueuedDownload() { + var activeDownloads = countOperations(); + if (activeDownloads >= maxConcurrentDownloads) return; + + // Start downloads until we fill all available slots + for (final entry in state.operations.entries) { + if (activeDownloads >= maxConcurrentDownloads) break; + + if (entry.value is DownloadQueued) { + final queued = entry.value as DownloadQueued; + final downloadUrl = queued.target.urls.firstOrNull; + if (downloadUrl != null) { + _startDownloadTask( + entry.key, + queued.target, + downloadUrl, + displayName: queued.displayName, + ); + activeDownloads++; + } + } + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // INSTALL FLOW + // ═══════════════════════════════════════════════════════════════════════════ + + /// Check permission and proceed to install + Future _proceedToInstall( + String appId, + FileMetadata target, + String filePath, + ) async { + if (!await hasPermission()) { + setOperation( + appId, + AwaitingPermission(target: target, filePath: filePath), + ); + try { + await requestPermission(); + } catch (e) { + setOperation( + appId, + OperationFailed( + target: target, + type: FailureType.permissionDenied, + message: e.toString().replaceFirst('Exception: ', ''), + filePath: filePath, + ), + ); + return; + } + + if (!await hasPermission()) return; + + // Permission was just granted - advance ALL waiting apps, not just this one + await onPermissionGranted(appId); + return; + } + + // Permission was already granted before we checked - just advance this app + setOperation(appId, ReadyToInstall(target: target, filePath: filePath)); + onInstallReady(appId); + } + + /// Perform the actual installation + Future _performInstall( + String appId, + FileMetadata target, + String filePath, + ) async { + try { + await install( + appId, + filePath, + expectedHash: target.hash, + expectedSize: target.size ?? 0, + target: target, + ); + // For event-driven platforms, install() returns immediately + // and results come via events. For sync platforms, it completes here. + } catch (e) { + final message = e.toString().replaceFirst('Exception: ', ''); + + if (message.contains('cancelled') || message.contains('ABORTED')) { + setOperation( + appId, + AwaitingUserAction(target: target, filePath: filePath), + ); + return; + } + + final isCertMismatch = + message.contains('signatures do not match') || + message.contains('INSTALL_FAILED_UPDATE_INCOMPATIBLE') || + message.contains('UPDATE_INCOMPATIBLE'); + + final isHashMismatch = message.contains('Hash verification failed'); + final isInvalidFile = message.contains('Invalid APK file'); + + setOperation( + appId, + OperationFailed( + target: target, + type: isInvalidFile + ? FailureType.invalidFile + : isHashMismatch + ? FailureType.hashMismatch + : isCertMismatch + ? FailureType.certMismatch + : FailureType.installFailed, + message: isCertMismatch + ? 'Certificate mismatch. Uninstall current version to update.' + : message, + filePath: filePath, + ), + ); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // RESTORATION + // ═══════════════════════════════════════════════════════════════════════════ + + Future _restoreOperations() async { + try { + final records = await _downloader.database.allRecords( + group: FileDownloader.defaultGroup, + ); + + for (final record in records) { + final task = record.task; + if (task is! DownloadTask) continue; + + final metaData = task.metaData; + if (metaData.isEmpty) { + await _cleanupTask(task); + continue; + } + + final (appId, metadataId) = _parseTaskMetadata(metaData); + if (appId == null) { + await _cleanupTask(task); + continue; + } + + final taskAge = DateTime.now().difference(task.creationTime); + if (taskAge > staleOperationThreshold) { + await _cleanupTask(task); + continue; + } + + final fileMetadata = await _loadFileMetadata(metadataId, task.filename); + if (fileMetadata == null) { + await _cleanupTask(task); + continue; + } + + await _restoreOperation(appId, record, task, fileMetadata); + } + } catch (e) { + debugPrint('Failed to restore operations: $e'); + } + } + + Future _restoreOperation( + String appId, + TaskRecord record, + DownloadTask task, + FileMetadata fileMetadata, + ) async { + switch (record.status) { + case TaskStatus.complete: + final filePath = await task.filePath(); + if (await File(filePath).exists()) { + await syncInstalledPackages(); + if (state.installed.containsKey(appId)) { + _deleteFile(filePath); + } else { + await _proceedToInstall(appId, fileMetadata, filePath); + } + } + break; + + case TaskStatus.running: + case TaskStatus.enqueued: + case TaskStatus.waitingToRetry: + setOperation( + appId, + Downloading( + target: fileMetadata, + progress: record.progress, + taskId: task.taskId, + ), + ); + try { + await _downloader.resume(task); + } catch (_) {} + break; + + case TaskStatus.paused: + setOperation( + appId, + DownloadPaused( + target: fileMetadata, + progress: record.progress, + taskId: task.taskId, + ), + ); + break; + + default: + await _cleanupTask(task); + break; + } + } + + Future _cleanupTask(DownloadTask task) async { + try { + await _downloader.cancelTaskWithId(task.taskId); + } catch (_) {} + + try { + final filePath = await task.filePath(); + _deleteFile(filePath); + } catch (_) {} + + try { + await _downloader.database.deleteRecordWithId(task.taskId); + } catch (_) {} + } + + // ═══════════════════════════════════════════════════════════════════════════ + // HELPERS + // ═══════════════════════════════════════════════════════════════════════════ + + void _deleteFile(String filePath) { + try { + final file = File(filePath); + if (file.existsSync()) { + file.deleteSync(); + } + } catch (_) {} + } + + String _encodeTaskMetadata(String appId, String metadataId) { + return '$appId|$metadataId'; + } + + (String? appId, String? metadataId) _parseTaskMetadata(String metaData) { + final parts = metaData.split('|'); + if (parts.length >= 2) { + return (parts[0], parts[1]); + } + return (metaData.isNotEmpty ? metaData : null, null); + } + + Future _loadFileMetadata( + String? metadataId, + String filename, + ) async { + final storage = ref.read(storageNotifierProvider.notifier); + + if (metadataId != null) { + try { + final results = storage.querySync( + RequestFilter(ids: {metadataId}).toRequest(), + ); + if (results.isNotEmpty) return results.first; + } catch (_) {} + } + + final dotIndex = filename.lastIndexOf('.'); + final hash = dotIndex > 0 ? filename.substring(0, dotIndex) : filename; + if (hash.isNotEmpty) { + try { + final results = storage.querySync( + RequestFilter(search: hash).toRequest(), + ); + if (results.isNotEmpty) return results.first; + } catch (_) {} + } + + return null; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // PLATFORM ABSTRACT METHODS + // ═══════════════════════════════════════════════════════════════════════════ + + /// Install a package from file path. + /// For event-driven platforms (Android), this returns immediately and + /// results come via EventChannel. The `target` parameter is passed through + /// for state management. + Future install( + String appId, + String filePath, { + required String expectedHash, + required int expectedSize, + required FileMetadata target, + }); + + Future uninstall(String appId); + + Future launchApp(String appId); + + Future requestPermission(); + + Future hasPermission(); + + bool get supportsSilentInstall; + + String get platform; + + String get packageExtension; + + Future syncInstalledPackages(); + + bool canInstall(FileMetadata m, String version, {int? versionCode}) { + final installed = state.installed[m.appIdentifier]; + if (installed == null) return true; + + return canUpgrade( + installed.versionCode?.toString() ?? installed.version, + versionCode?.toString() ?? version, + ); + } + + bool canUpdate(FileMetadata m) { + final installed = state.installed[m.appIdentifier]; + if (installed == null) return false; + return canUpgrade( + installed.versionCode?.toString() ?? installed.version, + m.versionCode?.toString() ?? m.version, + ); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// PROVIDERS +// ═══════════════════════════════════════════════════════════════════════════════ + +final packageManagerProvider = + StateNotifierProvider( + DummyPackageManager.new, + ); + +final installedPackageProvider = Provider.family(( + ref, + appId, +) { + return ref.watch(packageManagerProvider.select((s) => s.installed[appId])); +}); + +final installOperationProvider = Provider.family(( + ref, + appId, +) { + return ref.watch(packageManagerProvider.select((s) => s.operations[appId])); +}); + +final activeOperationsCountProvider = Provider((ref) { + return ref.watch(packageManagerProvider.select((s) => s.operations.length)); +}); + +final readyToInstallCountProvider = Provider((ref) { + return ref.watch( + packageManagerProvider.select( + (s) => s.operations.values.whereType().length, + ), + ); +}); + +final awaitingUserActionCountProvider = Provider((ref) { + return ref.watch( + packageManagerProvider.select( + (s) => s.operations.values.whereType().length, + ), + ); +}); + +/// Returns all installed packages as a list +final allInstalledPackagesProvider = Provider>((ref) { + return ref.watch( + packageManagerProvider.select((s) => s.installed.values.toList()), + ); +}); + +/// Returns packages installed on device but not tracked in relay data. +/// [knownAppIds] should be the set of app identifiers from relay storage. +final systemOnlyPackagesProvider = + Provider.family, Set>((ref, knownAppIds) { + return ref.watch( + packageManagerProvider.select( + (s) => s.installed.values + .where((pkg) => !knownAppIds.contains(pkg.appId)) + .toList(), + ), + ); + }); diff --git a/lib/services/secure_storage_service.dart b/lib/services/secure_storage_service.dart new file mode 100644 index 0000000..7af1ae1 --- /dev/null +++ b/lib/services/secure_storage_service.dart @@ -0,0 +1,52 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Service for securely storing sensitive data (NWC connection strings) +/// using platform-native secure storage (Keychain on iOS, KeyStore on Android). +/// +/// This does NOT require user authentication - data is encrypted at rest +/// by the platform's secure storage mechanism. +class SecureStorageService { + SecureStorageService(); + + static const _storage = FlutterSecureStorage(); + + static const _nwcKey = 'nwc_connection_string'; + + /// Get the stored NWC connection string + Future getNWCString() async { + final value = await _storage.read(key: _nwcKey); + return (value?.isNotEmpty == true) ? value : null; + } + + /// Store an NWC connection string + Future setNWCString(String connectionString) async { + await _storage.write(key: _nwcKey, value: connectionString); + } + + /// Clear the stored NWC connection string + Future clearNWCString() async { + await _storage.delete(key: _nwcKey); + } + + /// Check if an NWC connection string is stored + Future hasNWCString() async { + final value = await _storage.read(key: _nwcKey); + return value?.isNotEmpty == true; + } + + /// Check if the install permission dialog has been shown +} + +final secureStorageServiceProvider = Provider( + (ref) => SecureStorageService(), +); + +/// Whether an NWC connection string is currently stored. +/// +/// Use `ref.invalidate(hasNwcStringProvider)` after updating or clearing the +/// stored string to refresh UI. +final hasNwcStringProvider = FutureProvider.autoDispose((ref) async { + final secureStorage = ref.watch(secureStorageServiceProvider); + return secureStorage.hasNWCString(); +}); diff --git a/lib/services/trusted_signers_service.dart b/lib/services/trusted_signers_service.dart new file mode 100644 index 0000000..20d8ec0 --- /dev/null +++ b/lib/services/trusted_signers_service.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; + +/// Identifier used to store trusted signer preferences +const String kTrustedSignersIdentifier = 'trusted-signers'; + +/// Simple helper service to manage trusted signers persisted via CustomData +class TrustedSignersService { + const TrustedSignersService(this.ref); + + final Ref ref; + + /// Returns whether a signer pubkey is trusted by the active user + Future isSignerTrusted(String signerPubkey) async { + final trusted = await _loadTrustedSigners(); + return trusted.contains(signerPubkey); + } + + /// Adds a signer pubkey to the trusted list for the active user. + /// If no signer is available (not signed in), this method is a no-op. + Future addTrustedSigner(String signerPubkey) async { + final signer = ref.read(Signer.activeSignerProvider); + final activePubkey = ref.read(Signer.activePubkeyProvider); + if (signer == null || activePubkey == null) return; + + final trusted = await _loadTrustedSigners(); + if (trusted.contains(signerPubkey)) return; + trusted.add(signerPubkey); + + final content = jsonEncode({'trusted': trusted.toList()}); + + final partial = PartialCustomData( + identifier: kTrustedSignersIdentifier, + content: content, + ); + + // Sign with the active signer so the data is addressable under the user's pubkey + final model = await partial.signWith(signer); + + // Save locally only (do not publish) + await model.save(); + } + + Future> _loadTrustedSigners() async { + final activePubkey = ref.read(Signer.activePubkeyProvider); + if (activePubkey == null) return {}; + + try { + final request = Request([ + RequestFilter( + authors: {activePubkey}, + tags: { + '#d': {kTrustedSignersIdentifier}, + }, + limit: 1, + ), + ]); + + final storage = ref.read(storageNotifierProvider.notifier); + final List models = await storage.query( + request, + source: const LocalSource(), + ); + + if (models.isEmpty) return {}; + final model = models.first; + final map = jsonDecode(model.content) as Map; + final list = + (map['trusted'] as List?)?.cast() ?? const []; + return list.toSet(); + } catch (_) { + return {}; + } + } +} + +final trustServiceProvider = Provider( + (ref) => TrustedSignersService(ref), +); diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart new file mode 100644 index 0000000..b6f1a39 --- /dev/null +++ b/lib/services/updates_service.dart @@ -0,0 +1,212 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/main.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/version_utils.dart'; + +/// Categorized apps state +class CategorizedApps { + const CategorizedApps({ + required this.automaticUpdates, + required this.manualUpdates, + required this.upToDateApps, + required this.uncatalogedApps, + this.isLoading = false, + }); + + final List automaticUpdates; + final List manualUpdates; + final List upToDateApps; + + /// Apps installed on device but not found in any relay catalog + final List uncatalogedApps; + final bool isLoading; + + CategorizedApps copyWith({ + List? automaticUpdates, + List? manualUpdates, + List? upToDateApps, + List? uncatalogedApps, + bool? isLoading, + }) { + return CategorizedApps( + automaticUpdates: automaticUpdates ?? this.automaticUpdates, + manualUpdates: manualUpdates ?? this.manualUpdates, + upToDateApps: upToDateApps ?? this.upToDateApps, + uncatalogedApps: uncatalogedApps ?? this.uncatalogedApps, + isLoading: isLoading ?? this.isLoading, + ); + } + + static const empty = CategorizedApps( + automaticUpdates: [], + manualUpdates: [], + upToDateApps: [], + uncatalogedApps: [], + isLoading: true, + ); +} + +class CategorizedAppsNotifier extends Notifier { + bool _hasLoadedOnce = false; + + @override + CategorizedApps build() { + // Wait for app initialization + final initState = ref.watch(appInitializationProvider); + if (initState is! AsyncData) { + return CategorizedApps.empty; + } + + // Watch installed packages - this is the source of truth + final pmState = ref.watch(packageManagerProvider); + final installedPackages = pmState.installed.values.toList(); + final installedIds = pmState.installed.keys.toSet(); + + if (installedIds.isEmpty) { + _hasLoadedOnce = true; + return const CategorizedApps( + automaticUpdates: [], + manualUpdates: [], + upToDateApps: [], + uncatalogedApps: [], + isLoading: false, + ); + } + + final platform = ref.read(packageManagerProvider.notifier).platform; + + // Query apps with relationships loaded via `and:` + final appsState = ref.watch( + query( + tags: { + '#d': installedIds, + '#f': {platform}, + }, + and: (app) => { + app.latestRelease, + app.latestRelease.value?.latestMetadata, + app.latestRelease.value?.latestAsset, + }, + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), + andSource: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + subscriptionPrefix: 'updates', + ), + ); + + return switch (appsState) { + StorageLoading() => CategorizedApps.empty.copyWith( + isLoading: !_hasLoadedOnce, + ), + StorageError() => CategorizedApps.empty.copyWith(isLoading: false), + StorageData(:final models) => _categorize(models, installedPackages), + }; + } + + CategorizedApps _categorize( + List apps, + List installedPackages, + ) { + _hasLoadedOnce = true; + + final automaticUpdates = []; + final manualUpdates = []; + final upToDateApps = []; + + // Build lookup map from passed-in data (avoids separate provider reads) + final installedMap = {for (final pkg in installedPackages) pkg.appId: pkg}; + + // Track ALL apps returned from relay query as "cataloged" + final catalogedAppIds = apps.map((a) => a.identifier).toSet(); + + // Only process apps that are actually installed (check against our map) + final installedApps = apps.where((a) => installedMap.containsKey(a.identifier)); + + for (final app in installedApps) { + final pkg = installedMap[app.identifier]!; + final latest = app.latestFileMetadata; + + // Determine if update available using local data + final hasUpdate = latest != null && _hasUpdate(pkg, latest); + + if (hasUpdate) { + if (pkg.canInstallSilently) { + automaticUpdates.add(app); + } else { + manualUpdates.add(app); + } + } else { + // No update available - app is up to date + upToDateApps.add(app); + } + } + + // Find installed packages without catalog metadata + final uncatalogedApps = installedPackages + .where((pkg) => !catalogedAppIds.contains(pkg.appId)) + .toList() + ..sort((a, b) => (a.name ?? a.appId) + .toLowerCase() + .compareTo((b.name ?? b.appId).toLowerCase())); + + int byName(App a, App b) => (a.name ?? a.identifier) + .toLowerCase() + .compareTo((b.name ?? b.identifier).toLowerCase()); + + automaticUpdates.sort(byName); + manualUpdates.sort(byName); + upToDateApps.sort(byName); + + // Fetch author profiles in background (fire and forget) + _fetchAuthors(installedApps); + + return CategorizedApps( + automaticUpdates: automaticUpdates, + manualUpdates: manualUpdates, + upToDateApps: upToDateApps, + uncatalogedApps: uncatalogedApps, + isLoading: false, + ); + } + + /// Check if an update is available by comparing versions + bool _hasUpdate(PackageInfo installed, FileMetadata latest) { + if (latest.versionCode != null && installed.versionCode != null) { + return latest.versionCode! > installed.versionCode!; + } + return canUpgrade(installed.version, latest.version); + } + + void _fetchAuthors(Iterable apps) { + final authorPubkeys = apps.map((a) => a.event.pubkey).toSet(); + if (authorPubkeys.isEmpty) return; + unawaited( + ref.storage.query( + RequestFilter(authors: authorPubkeys).toRequest(), + source: const LocalAndRemoteSource( + relays: {'social', 'vertex'}, + cachedFor: Duration(hours: 2), + stream: false, + ), + ), + ); + } +} + +final categorizedAppsProvider = + NotifierProvider( + CategorizedAppsNotifier.new, + ); + +/// Provider that calculates the total number of apps with available updates +final updateCountProvider = Provider((ref) { + final categorized = ref.watch(categorizedAppsProvider); + if (categorized.isLoading) return 0; + return categorized.automaticUpdates.length + categorized.manualUpdates.length; +}); diff --git a/lib/theme.dart b/lib/theme.dart new file mode 100644 index 0000000..789a4b3 --- /dev/null +++ b/lib/theme.dart @@ -0,0 +1,379 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +/// Base font family used for body text and UI elements +const kFontFamily = 'Inter'; + +/// Display and headline font family +const kHeadlineFontFamily = 'Inter Display'; + +// Professional color constants (dark theme only) +class AppColors { + // Dark theme professional blues + static const Color darkPrimary = Color(0xFF3A6FCC); + static const Color darkSecondary = Color(0xFF4A8FCC); + static const Color darkSurface = Color( + 0xFF0F141B, + ); // slightly darker for cards/pills + static const Color darkSurfaceVariant = Color( + 0xFF161C27, + ); // darker container variant + static const Color darkBackground = Color(0xFF0A0F1A); // from palette + static const Color darkBackgroundSecondary = Color( + 0xFF111723, + ); // from palette + static const Color darkOnSurface = Color(0xFFE8EAED); + static const Color darkOnSurfaceVariant = Color(0xFFB8BCC8); + static const Color darkOnSurfaceSecondary = Color( + 0xFFC1C7CD, + ); // Lighter text for descriptions and "Published by" + static const Color darkOutline = Color(0xFF2D3748); + + // Dark skeleton loader colors (dark blue theme) + static const Color darkSkeletonBase = Color(0xFF1E3A5F); + static const Color darkSkeletonHighlight = Color(0xFF2B4A73); + + // App-specific pill backgrounds (slightly bluish) + static const Color darkPillBackground = Color(0xFF213A60); // darker bluish + + // Primary action colors (used for buttons, badges, info notifications) + static const Color darkActionPrimary = Color( + 0xFF2D5FAA, + ); // darker professional blue for dark mode + + // Helper method to get skeleton colors + static SkeletonizerConfigData getSkeletonizerConfig(Brightness brightness) { + return SkeletonizerConfigData( + effect: ShimmerEffect( + baseColor: darkSkeletonBase, + highlightColor: darkSkeletonHighlight, + duration: const Duration(milliseconds: 1000), + ), + ); + } +} + +// Professional gradient decorations +class AppGradients { + static const LinearGradient darkBackgroundGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF0A0F1A), Color(0xFF141A22), Color(0xFF111723)], + stops: [0.0, 0.6, 1.0], + ); + + static const LinearGradient darkSurfaceGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.darkSurface, AppColors.darkSurfaceVariant], + stops: [0.0, 1.0], + ); +} + +// Professional typography system using Inter +TextTheme _createProfessionalTextTheme() { + const baseColor = AppColors.darkOnSurface; + const secondaryColor = AppColors.darkOnSurfaceVariant; + + return const TextTheme().copyWith( + // Display styles - for main headings + displayLarge: const TextStyle( + fontFamily: kHeadlineFontFamily, + fontSize: 62, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + color: baseColor, + height: 1.1, + ), + displayMedium: const TextStyle( + fontFamily: kHeadlineFontFamily, + fontSize: 48, + fontWeight: FontWeight.w800, + letterSpacing: -0.25, + color: baseColor, + height: 1.15, + ), + displaySmall: const TextStyle( + fontFamily: kHeadlineFontFamily, + fontSize: 40, + fontWeight: FontWeight.w800, + letterSpacing: 0, + color: baseColor, + height: 1.2, + ), + + // Headline styles - for section titles + headlineLarge: const TextStyle( + fontFamily: kHeadlineFontFamily, + fontSize: 35, + fontWeight: FontWeight.w800, + letterSpacing: 0, + color: baseColor, + height: 1.25, + ), + headlineMedium: const TextStyle( + fontFamily: kHeadlineFontFamily, + fontSize: 31, + fontWeight: FontWeight.w800, + letterSpacing: 0.15, + color: baseColor, + height: 1.3, + ), + headlineSmall: const TextStyle( + fontFamily: kHeadlineFontFamily, + fontSize: 26, + fontWeight: FontWeight.w800, + letterSpacing: 0.15, + color: baseColor, + height: 1.35, + ), + + // Title styles - for card titles and prominent text + titleLarge: const TextStyle( + fontFamily: kHeadlineFontFamily, + fontSize: 24, + fontWeight: FontWeight.w700, + letterSpacing: 0.1, + color: baseColor, + height: 1.4, + ), + titleMedium: const TextStyle( + fontFamily: kHeadlineFontFamily, + fontSize: 20, + fontWeight: FontWeight.w700, + letterSpacing: 0.15, + color: baseColor, + height: 1.45, + ), + titleSmall: const TextStyle( + fontFamily: kHeadlineFontFamily, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: 0.15, + color: baseColor, + height: 1.45, + ), + + // Body styles - for main content + bodyLarge: const TextStyle( + fontFamily: kFontFamily, + fontSize: 16, + fontWeight: FontWeight.w400, + letterSpacing: 0.1, + color: baseColor, + height: 1.5, + ), + bodyMedium: const TextStyle( + fontFamily: kFontFamily, + fontSize: 14, + fontWeight: FontWeight.w400, + letterSpacing: 0.15, + color: secondaryColor, + height: 1.55, + ), + bodySmall: const TextStyle( + fontFamily: kFontFamily, + fontSize: 12, + fontWeight: FontWeight.w400, + letterSpacing: 0.25, + color: secondaryColor, + height: 1.6, + ), + + // Label styles - for UI elements + labelLarge: const TextStyle( + fontFamily: kFontFamily, + fontSize: 14, + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + color: baseColor, + height: 1.4, + ), + labelMedium: const TextStyle( + fontFamily: kFontFamily, + fontSize: 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + color: baseColor, + height: 1.35, + ), + labelSmall: const TextStyle( + fontFamily: kFontFamily, + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + color: secondaryColor, + height: 1.3, + ), + ); +} + +// Professional Dark Theme with Gradient Backgrounds +final darkTheme = ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + scaffoldBackgroundColor: Colors.transparent, + colorScheme: const ColorScheme.dark( + primary: AppColors.darkPrimary, + secondary: AppColors.darkSecondary, + surface: AppColors.darkSurface, + surfaceContainerHighest: AppColors.darkSurfaceVariant, + onSurface: AppColors.darkOnSurface, + onSurfaceVariant: AppColors.darkOnSurfaceVariant, + outline: AppColors.darkOutline, + error: Color(0xFFEF4444), + onError: Colors.white, + ), + textTheme: _createProfessionalTextTheme(), + + // Professional dark card design with subtle elevation + cardTheme: CardThemeData( + elevation: 0, + color: AppColors.darkSurface, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: AppColors.darkOutline.withValues(alpha: 0.3), + width: 0.5, + ), + ), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + ), + + // Professional dark chip design + chipTheme: ChipThemeData( + backgroundColor: AppColors.darkSurfaceVariant, + selectedColor: AppColors.darkPrimary.withValues(alpha: 0.25), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + labelPadding: const EdgeInsets.symmetric(horizontal: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + labelStyle: const TextStyle( + fontFamily: kFontFamily, + fontWeight: FontWeight.w500, + fontSize: 14, + letterSpacing: 0.15, + color: AppColors.darkOnSurface, + ), + elevation: 0, + pressElevation: 0, + ), + + // Professional dark button designs + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.darkActionPrimary, + foregroundColor: Colors.white, + elevation: 0, + shadowColor: Colors.transparent, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + textStyle: const TextStyle( + fontFamily: kFontFamily, + fontWeight: FontWeight.w600, + fontSize: 16, + letterSpacing: 0.15, + ), + ), + ), + + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.darkActionPrimary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + textStyle: const TextStyle( + fontFamily: kFontFamily, + fontWeight: FontWeight.w600, + fontSize: 16, + letterSpacing: 0.15, + ), + ), + ), + + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.darkPrimary, + side: const BorderSide(color: AppColors.darkPrimary, width: 1.5), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + textStyle: const TextStyle( + fontFamily: kFontFamily, + fontWeight: FontWeight.w600, + fontSize: 16, + letterSpacing: 0.15, + ), + ), + ), + + // Professional dark input design + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.darkOutline, width: 1.5), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.darkOutline, width: 1.5), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.darkPrimary, width: 2), + ), + filled: true, + fillColor: AppColors.darkSurfaceVariant, + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + hintStyle: const TextStyle( + fontFamily: kFontFamily, + color: AppColors.darkOnSurfaceVariant, + fontSize: 16, + fontWeight: FontWeight.w400, + ), + ), + + // Professional dark app bar + appBarTheme: const AppBarTheme( + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.darkBackground, + surfaceTintColor: Colors.transparent, + foregroundColor: AppColors.darkOnSurface, + titleTextStyle: TextStyle( + fontFamily: kHeadlineFontFamily, + fontSize: 22, + fontWeight: FontWeight.w800, + color: AppColors.darkOnSurface, + letterSpacing: 0.1, + ), + systemOverlayStyle: SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + ), + ), + + // Professional navigation theming + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: AppColors.darkSurface, + selectedItemColor: AppColors.darkPrimary, + unselectedItemColor: AppColors.darkOnSurfaceVariant, + elevation: 0, + type: BottomNavigationBarType.fixed, + ), + + navigationRailTheme: const NavigationRailThemeData( + backgroundColor: AppColors.darkSurface, + selectedIconTheme: IconThemeData(color: AppColors.darkPrimary), + unselectedIconTheme: IconThemeData(color: AppColors.darkOnSurfaceVariant), + selectedLabelTextStyle: TextStyle(color: AppColors.darkPrimary), + unselectedLabelTextStyle: TextStyle(color: AppColors.darkOnSurfaceVariant), + ), + + // Dark blue progress indicators + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.darkPrimary, + linearTrackColor: AppColors.darkSurfaceVariant, + circularTrackColor: AppColors.darkSurfaceVariant, + ), +); diff --git a/lib/utils/debounce.dart b/lib/utils/debounce.dart deleted file mode 100644 index 3ad57ef..0000000 --- a/lib/utils/debounce.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'dart:async'; - -const _debounceTime = 1200; -Timer? timer; -final acc = []; - -void debounce(T input, Future Function(List) cb) { - if (timer?.isActive ?? false) { - timer!.cancel(); - } - acc.add(input); - timer = Timer(Duration(milliseconds: _debounceTime), () async { - await cb(acc.cast()); - acc.clear(); - }); -} diff --git a/lib/utils/debug_utils.dart b/lib/utils/debug_utils.dart new file mode 100644 index 0000000..6db324b --- /dev/null +++ b/lib/utils/debug_utils.dart @@ -0,0 +1,36 @@ +/// Debug utilities for development and testing +library; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; + +/// Check if the current user is in debug mode based on their pubkey +bool isDebugMode(String? pubkey) { + if (pubkey == null) return false; + + const targetHex = + '726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11'; + + // Try direct comparison first (hex format) + if (pubkey == targetHex) { + return true; + } + + // Try converting from npub if needed + try { + if (pubkey.startsWith('npub')) { + final decoded = Utils.decodeShareableIdentifier(pubkey); + if (decoded is ProfileData && decoded.pubkey == targetHex) { + return true; + } + } + } catch (e) { + // Failed to decode npub + } + + return false; +} + +/// Provider that exposes a Ref for use in functions that need consistent Ref type +/// Usage: ref.read(refProvider) to get a Ref from WidgetRef context +final refProvider = Provider((ref) => ref); diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index b3e2ca6..9c81e0c 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -1,172 +1,129 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:dart_emoji/dart_emoji.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_data/flutter_data.dart'; -import 'package:intl/intl.dart'; -import 'package:toastification/toastification.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/constants/app_constants.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/utils/version_utils.dart'; -extension ContextX on BuildContext { - ThemeData get theme => Theme.of(this); - void showInfo(String message, {String? description, Icon? icon}) { - toastification.show( - context: this, - type: ToastificationType.info, - icon: icon ?? Icon(Icons.info), - style: ToastificationStyle.fillColored, - alignment: Alignment.topCenter, - title: _ToastTitleWidget(message), - description: description != null - ? _ToastDescriptionWidget(description: description) - : null, - autoCloseDuration: const Duration(seconds: 4), - showProgressBar: false, - closeOnClick: true, - ); - } +export 'package:zapstore/constants/app_constants.dart'; - void showError(String title, - {String? description, - Icon? icon, - List<(String, Future Function())> actions = const []}) { - toastification.show( - context: this, - type: ToastificationType.error, - style: ToastificationStyle.fillColored, - alignment: Alignment.topCenter, - icon: icon ?? Icon(Icons.error), - title: _ToastTitleWidget(title), - showProgressBar: false, - closeOnClick: true, - description: description != null - ? _ToastDescriptionWidget( - description: description, - actions: actions, - ) - : null, - ); - } +extension WidgetExt on WidgetRef { + StorageNotifier get storage => read(storageNotifierProvider.notifier); + PackageManager get packageManager => read(packageManagerProvider.notifier); } -class _ToastTitleWidget extends StatelessWidget { - final String text; - const _ToastTitleWidget(this.text); - - @override - Widget build(BuildContext context) { - return Text( - text, - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - overflow: TextOverflow.ellipsis, - maxLines: 4, - ); - } +extension ContextExt on BuildContext { + TextTheme get textTheme => Theme.of(this).textTheme; } -class _ToastDescriptionWidget extends StatelessWidget { - final String? description; - final List<(String, Future Function())> actions; +extension AppExt on App { + /// Whether this app is one of Zapstore's own apps + bool get isZapstoreApp => identifier == kZapstoreAppIdentifier; - _ToastDescriptionWidget({this.description, this.actions = const []}); + /// Whether this app is signed by Zapstore pubkey + bool get isSignedByZapstore => pubkey == kZapstorePubkey; - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RichText( - text: TextSpan( - text: description, - style: TextStyle(fontSize: 16), - ), - ), - for (final (text, fn) in actions) - ElevatedButton( - style: ElevatedButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: Colors.transparent), - onPressed: () async { - await fn.call(); - toastification.dismissAll(delayForAnimation: false); - }, - child: Text(text), - ), - ], - ); - } -} + /// Whether this app is "relay signed" - indexed by Zapstore but not a Zapstore app itself + bool get isRelaySigned => isSignedByZapstore && !isZapstoreApp; -BelongsTo belongsTo>(Map? map) { - return map != null ? BelongsTo.fromJson(map) : BelongsTo(); -} + /// Returns PackageInfo if installed, otherwise null + PackageInfo? get installedPackage => + ref.read(packageManagerProvider).installed[identifier]; -HasMany hasMany>(Map? map) { - return map != null ? HasMany.fromJson(map) : HasMany(); -} + /// Whether the app is installed on the device + bool get isInstalled => + ref.read(packageManagerProvider.notifier).isInstalled(identifier); -final emojiParser = EmojiParser(); + /// Latest file metadata associated to the latest release + /// Prefers SoftwareAsset (new format) over FileMetadata (old format) + /// Note: assumes latest metadata has been loaded for the current platform + FileMetadata? get latestFileMetadata => + latestRelease.value?.latestAsset.value ?? + latestRelease.value?.latestMetadata.value; -extension StringWidget on String { - String parseEmojis() { - return replaceAllMapped(RegExp(':([a-z]*):'), (m) { - return emojiParser.hasName(m[1]!) ? emojiParser.get(m[1]!).code : m[0]!; - }); - } + /// Whether there is an update available for the installed app + /// Compares versionCode first when available, otherwise falls back to + /// semantic version comparison. + bool get hasUpdate { + final installed = installedPackage; + final latest = latestFileMetadata; + if (installed == null || latest == null) return false; - String safeSubstring(int size) { - return substring(0, min(length, size)) + (size < length ? '...' : ''); - } - - String get shorten { - // npub1wf4puf...43dgh9 - if (length < 18) return this; - final leading = substring(0, 9); - final trailing = substring(length - 6, length - 1); - return '$leading...$trailing'; - } - - String removeParenthesis() { - return replaceAll(RegExp(r'\([^()]*\)'), ''); - } -} - -extension TextExt on Text { - Widget get bold { - return Text(data!, style: TextStyle(fontWeight: FontWeight.bold)); - } -} - -const kZapstoreAppIdentifier = 'dev.zapstore.app'; - -final kNumberFormatter = NumberFormat('#,###'); - -// stream utils - -Stream> bufferByTime(Stream source, Duration duration) { - final controller = StreamController>(); - - final buffer = []; - - Timer? timer; - - // Listen to the source stream - source.listen((data) { - buffer.add(data); - - // If there's no active timer, start one - timer ??= Timer(duration, () { - controller.add(List.from(buffer)); // Emit buffered data - buffer.clear(); // Clear the buffer - timer = null; // Reset the timer - }); - }, onDone: () { - // Emit remaining items in the buffer when the source is done - if (buffer.isNotEmpty) { - controller.add(List.from(buffer)); + if (latest.versionCode != null && installed.versionCode != null) { + return latest.versionCode! > installed.versionCode!; } - controller.close(); // Close the controller - }); - return controller.stream; // Return the buffered stream + return canUpgrade(installed.version, latest.version); + } + + /// Whether the relay version would be a downgrade from the installed version + /// Compares versionCode first when available, otherwise falls back to + /// semantic version comparison. + bool get hasDowngrade { + final installed = installedPackage; + final latest = latestFileMetadata; + if (installed == null || latest == null) return false; + + if (latest.versionCode != null && installed.versionCode != null) { + return latest.versionCode! < installed.versionCode!; + } + + return canUpgrade(latest.version, installed.version); + } + + /// Whether the installed app is up to date (installed and no update) + bool get isUpdated => isInstalled && !hasUpdate; +} + +extension WidgetRefExt on WidgetRef { + Ref get ref => read(Provider((ref) => ref)); +} + +/// Extension to handle both old and new format certificate hashes +extension FileMetadataExt on FileMetadata { + /// Returns the APK certificate hash, checking new format first then old + /// New format (SoftwareAsset): apkCertificateHashes (Set) + /// Old format (FileMetadata): apkSignatureHash (String?) + String? get certificateHash { + // Try new format first (SoftwareAsset has apkCertificateHashes) + if (this is SoftwareAsset) { + final hashes = (this as SoftwareAsset).apkCertificateHashes; + if (hashes.isNotEmpty) return hashes.first; + } + // Fall back to old format + return apkSignatureHash; + } + + /// Returns all APK certificate hashes + Set get certificateHashes { + // Try new format first + if (this is SoftwareAsset) { + final hashes = (this as SoftwareAsset).apkCertificateHashes; + if (hashes.isNotEmpty) return hashes; + } + // Fall back to old format (single hash as set) + return apkSignatureHash != null ? {apkSignatureHash!} : {}; + } +} + +/// String abbreviation utilities +extension StringAbbreviationExt on String { + /// Abbreviates a string to show beginning and end with ellipsis + /// Default: shows first 6 and last 6 chars if string is longer than 12 + String abbreviate({int start = 6, int end = 6}) { + final t = trim(); + final minLength = start + end; + if (t.length <= minLength) return t; + return '${t.substring(0, start)}...${t.substring(t.length - end)}'; + } + + /// Abbreviates npub identifiers to "npub1abc...xyz" format + /// Shows first 9 chars (npub1 + 4 chars) and last 7 chars + /// Returns original string if not an npub identifier + String abbreviateNpub() { + if (!startsWith('npub1') || length < 20) { + return this; + } + return '${substring(0, 9)}...${substring(length - 7)}'; + } } diff --git a/lib/utils/notifier.dart b/lib/utils/notifier.dart deleted file mode 100644 index a6a850e..0000000 --- a/lib/utils/notifier.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter_data/flutter_data.dart'; - -abstract class PreloadingStateNotifier extends StateNotifier> { - final Ref ref; - PreloadingStateNotifier(this.ref) : super(AsyncLoading()) { - state = fetchLocal(); - fetchRemote(); - } - - AsyncValue fetchLocal(); - Future fetchRemote(); -} diff --git a/lib/utils/nwc.dart b/lib/utils/nwc.dart deleted file mode 100644 index a6cc54a..0000000 --- a/lib/utils/nwc.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter_data/flutter_data.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart'; -import 'package:ndk/ndk.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/user.dart'; - -const kNwcSecretKey = 'nwc_secret'; - -class NwcConnectionNotifier extends StateNotifier> { - Ref ref; - late Ndk ndk; - - final _storage = FlutterSecureStorage( - aOptions: AndroidOptions(encryptedSharedPreferences: true), - ); - - NwcConnectionNotifier(this.ref) : super(AsyncLoading()) { - ndk = ref.users.nostrAdapter.socialRelays.ndk!; - // Attempt to load from local storage on initialization - _storage - .read(key: kNwcSecretKey) - .then(connectWallet) - .catchError((e, stack) { - state = AsyncError(e, stack); - }); - } - - Future connectWallet([String? nwcSecret]) async { - if (nwcSecret == null) { - // We get here when storage has no secret, do nothing - state = AsyncData(null); - return; - } - - state = AsyncValue.loading(); - - // If secret is supplied and a connection is still active, - // it means it is a new connection string so disconnect NWC - if (state.value != null) { - await ndk.nwc.disconnect(state.value!); - } - - // Write secret to storage and connect - await _storage.write(key: kNwcSecretKey, value: nwcSecret); - - try { - final connection = await ndk.nwc.connect( - nwcSecret, - doGetInfoMethod: false, - ); - if (connection.permissions.contains(NwcMethod.PAY_INVOICE.name)) { - state = AsyncValue.data(connection); - } else { - state = AsyncError('No permission to zap', StackTrace.current); - } - } catch (e, stack) { - state = AsyncValue.error(e, stack); - } - } - - Future disconnectWallet() async { - await _storage.delete(key: kNwcSecretKey); - state = AsyncData(null); - } -} - -final nwcConnectionProvider = - StateNotifierProvider>( - (ref) => NwcConnectionNotifier(ref)); - -extension NwcExt on AsyncValue { - bool get isPresent { - return hasValue && value != null && !hasError; - } -} diff --git a/lib/utils/signers.dart b/lib/utils/signers.dart deleted file mode 100644 index b20af08..0000000 --- a/lib/utils/signers.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'dart:convert'; - -import 'package:purplebase/purplebase.dart'; -import 'package:signer_plugin/signer_plugin.dart'; - -class AmberSigner extends Signer { - final _signerPlugin = SignerPlugin(); - bool isAvailable = false; - - @override - Future initialize() async { - final hasExternalSigner = await _signerPlugin - .isExternalSignerInstalled('com.greenart7c3.nostrsigner'); - if (hasExternalSigner) { - await _signerPlugin.setPackageName('com.greenart7c3.nostrsigner'); - isAvailable = true; - } - return this; - } - - @override - Future getPublicKey() async { - final map = await _signerPlugin.getPublicKey(); - return map['npub'] ?? map['result']; - } - - @override - Future sign>(PartialEvent partialEvent, - {String? withPubkey}) async { - if (!isAvailable) { - throw Exception("Cannot sign, missing Amber"); - } - - if (partialEvent is PartialDirectMessage) { - final signedMessage = await _signerPlugin.nip04Encrypt( - partialEvent.event.content, - "", - withPubkey!.npub, - (partialEvent as PartialDirectMessage).receiver.hexKey); - final encryptedContent = signedMessage['result']; - partialEvent.event.content = encryptedContent; - } - - // Remove all null fields (Amber otherwise crashes) - final map = { - for (final e in partialEvent.toMap().entries) - if (e.value != null) e.key: e.value - }; - final signedMessage = - await _signerPlugin.signEvent(jsonEncode(map), "", withPubkey!); - final signedEvent = jsonDecode(signedMessage['event']); - return Event.getConstructor()!.call(signedEvent); - } -} - -final pkSigner = Bip340PrivateKeySigner( - 'e593c54f840b32054dcad0fac15d57e4ac6523e31fe26b3087de6b07a2e9af58'); diff --git a/lib/utils/system_info.dart b/lib/utils/system_info.dart deleted file mode 100644 index 39095e5..0000000 --- a/lib/utils/system_info.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:android_package_manager/android_package_manager.dart'; -import 'package:device_info_plus/device_info_plus.dart'; -import 'package:flutter_data/flutter_data.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:system_info2/system_info2.dart'; -import 'package:zapstore/main.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/utils/extensions.dart'; - -final deviceInfoPlugin = DeviceInfoPlugin(); -final packageManager = AndroidPackageManager(); - -class SystemInfo { - final AndroidDeviceInfo androidInfo; - final PackageInfo zsInfo; - final Map dbInfo; - - SystemInfo({ - required this.androidInfo, - required this.zsInfo, - required this.dbInfo, - }); - - @override - String toString() { - return ''' -Version name: ${zsInfo.versionName} -Version code: ${zsInfo.versionCode} -DB version: $kDbVersion -SDK version: ${androidInfo.version.sdkInt} -Device: ${androidInfo.brand} ${androidInfo.device} (${androidInfo.hardware}) -Display: ${androidInfo.display} -Model: ${androidInfo.manufacturer} ${androidInfo.model} ${androidInfo.product} -Host: ${androidInfo.host} -Supported ABIs: ${androidInfo.supported64BitAbis} - -DB: $dbInfo - -Free/total memory: ${SysInfo.getFreePhysicalMemory() ~/ (1024 * 1024)}/${SysInfo.getTotalPhysicalMemory() ~/ (1024 * 1024)} MB -Free/total virtual: ${SysInfo.getFreeVirtualMemory() ~/ (1024 * 1024)}/${SysInfo.getTotalVirtualMemory() ~/ (1024 * 1024)} MB -Low RAM device? ${androidInfo.isLowRamDevice} -'''; - } -} - -class SystemInfoNotifier extends StateNotifier> { - final Ref ref; - - SystemInfoNotifier(this.ref) : super(AsyncLoading()) { - fetch(); - } - - Future fetch() async { - final zsInfo = await packageManager.getPackageInfo( - packageName: kZapstoreAppIdentifier); - final dbInfo = { - 'apps': ref.apps.countLocal, - 'releases': ref.releases.countLocal, - 'metadata': ref.fileMetadata.countLocal, - }; - - final info = SystemInfo( - androidInfo: await deviceInfoPlugin.androidInfo, - zsInfo: zsInfo!, - dbInfo: dbInfo, - ); - - state = AsyncData(info); - return info; - } -} - -final systemInfoNotifierProvider = - StateNotifierProvider>( - SystemInfoNotifier.new); diff --git a/lib/utils/theme.dart b/lib/utils/theme.dart deleted file mode 100644 index 688a49a..0000000 --- a/lib/utils/theme.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter/material.dart'; - -const kBackgroundColor = Color.fromARGB(255, 6, 6, 6); - -final theme = ThemeData( - primarySwatch: Colors.lightBlue, - brightness: Brightness.dark, - fontFamily: 'Inter', - useMaterial3: true, - scaffoldBackgroundColor: kBackgroundColor, - visualDensity: VisualDensity.adaptivePlatformDensity, -); diff --git a/lib/utils/url_utils.dart b/lib/utils/url_utils.dart new file mode 100644 index 0000000..b61cf75 --- /dev/null +++ b/lib/utils/url_utils.dart @@ -0,0 +1,38 @@ +/// Lightweight URL validation helpers used across widgets to avoid invalid +/// image fetches (e.g. data: URIs or missing hosts). +String? sanitizeHttpUrl(String? url) { + if (url == null) return null; + final trimmed = url.trim(); + if (trimmed.isEmpty) return null; + + final uri = Uri.tryParse(trimmed); + if (uri == null) return null; + + final scheme = uri.scheme.toLowerCase(); + final hasAllowedScheme = scheme == 'http' || scheme == 'https'; + if (!hasAllowedScheme || uri.host.isEmpty) return null; + + return trimmed; +} + +/// Returns only valid HTTP(S) URLs from the provided list, preserving order. +List filterValidHttpUrls(Iterable urls) { + final result = []; + for (final candidate in urls) { + final sanitized = sanitizeHttpUrl(candidate); + if (sanitized != null) { + result.add(sanitized); + } + } + return result; +} + +/// Returns the first valid HTTP(S) URL from the provided list, or null. +String? firstValidHttpUrl(Iterable urls) { + for (final candidate in urls) { + final sanitized = sanitizeHttpUrl(candidate); + if (sanitized != null) return sanitized; + } + return null; +} + diff --git a/lib/utils/version.dart b/lib/utils/version.dart deleted file mode 100644 index 467084d..0000000 --- a/lib/utils/version.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'dart:math'; - -int compareVersions(String installedVersion, String latestFromRemoteVersion) { - // Split the versions into main and pre-release components - final parts1 = installedVersion.split('-'); - final parts2 = latestFromRemoteVersion.split('-'); - - // Split the main version into numeric parts - final mainVersion1 = parts1[0].split('.').map(int.tryParse).toList(); - final mainVersion2 = parts2[0].split('.').map(int.tryParse).toList(); - - // Compare the main version parts - for (var i = 0; i < max(mainVersion1.length, mainVersion2.length); i++) { - final v1 = i < mainVersion1.length ? (mainVersion1[i] ?? 0) : 0; - final v2 = i < mainVersion2.length ? (mainVersion2[i] ?? 0) : 0; - - if (v1 < v2) return 1; // upgrade - if (v1 > v2) return -1; // downgrade - } - - // If main versions are equal, compare pre-release versions if they exist - final preRelease1 = parts1.length > 1 ? parts1[1] : ''; - final preRelease2 = parts2.length > 1 ? parts2[1] : ''; - - if (preRelease1.isEmpty && preRelease2.isNotEmpty) { - return -1; // downgrade - } - if (preRelease2.isEmpty && preRelease1.isNotEmpty) { - return 1; // upgrade - } - - // Compare pre-release versions lexicographically - final comparison = preRelease2.compareTo(preRelease1); - - return comparison < 0 - ? -1 - : comparison > 0 - ? 1 - : 0; -} diff --git a/lib/utils/version_utils.dart b/lib/utils/version_utils.dart new file mode 100644 index 0000000..01862f1 --- /dev/null +++ b/lib/utils/version_utils.dart @@ -0,0 +1,202 @@ +// Version comparison + +// SPDX-License-Identifier: MIT +/// Version comparison helper. +/// +/// canUpgrade(installed, current) → true if `current` is newer than `installed`. +/// +/// The rules implemented are a pragmatic superset of Semantic Versioning: +/// +/// • Optional `v`/`V` prefix is ignored (e.g. `v1.2.3`). +/// • The core version is an arbitrary number of dot-separated numeric parts, +/// compared *numerically*, not lexicographically (1.2.10 > 1.2.3). +/// • A version containing a pre-release label (`-alpha`, `-beta.1`, `-rc`, +/// etc.) is considered *older* than the same version without one +/// (1.0.0 < 1.0.0-rc). +/// • When both versions have pre-release labels, they are compared component +/// by component, splitting on `.`: +/// – purely numeric identifiers are compared numerically; +/// – non-numeric identifiers are compared lexically (ASCII order); +/// – numeric identifiers have *lower* precedence than non-numeric ones +/// (SemVer rule 11). +/// • Build metadata introduced by `+` (e.g. `1.0.0+20180101`) never affects +/// ordering and is ignored. +/// +/// The implementation is completely self-contained – no external packages. +bool canUpgrade(String installed, String current) { + return _Version.parse(current).compareTo(_Version.parse(installed)) > 0; +} + +/* -------------------------------------------------------------------------- */ +/* Internal implementation */ +/* -------------------------------------------------------------------------- */ + +class _Version implements Comparable<_Version> { + _Version(this.parts, this.preRelease); + + /// Dot-separated version components (numeric or string). + final List<_VersionPart> parts; + + /// Pre-release identifiers, possibly empty. + final List<_Identifier> preRelease; + + /* ------------------------------- parsing -------------------------------- */ + + static final _coreRegex = RegExp(r'^v?', caseSensitive: false); + + static _Version parse(String input) { + // 1. strip leading 'v' or 'V' + input = input.replaceFirst(_coreRegex, ''); + + // 2. drop build metadata (`+...`) + final plus = input.indexOf('+'); + if (plus != -1) input = input.substring(0, plus); + + // 3. split pre-release (`-...`) + String core; + String? pre; + final dash = input.indexOf('-'); + if (dash == -1) { + core = input; + pre = null; + } else { + core = input.substring(0, dash); + pre = input.substring(dash + 1); + } + + final parts = _parseVersionParts(core); + + final preParts = <_Identifier>[]; + if (pre != null) { + for (final id in pre.split('.')) { + preParts.add(_Identifier(id)); + } + } + + return _Version(parts, preParts); + } + + static List<_VersionPart> _parseVersionParts(String core) { + return core + .split('.') + .map((part) => _VersionPart(part)) + .toList(growable: false); + } + + /* ----------------------------- comparison ------------------------------- */ + + @override + int compareTo(_Version other) { + // 1. compare version parts + final maxLen = parts.length > other.parts.length + ? parts.length + : other.parts.length; + for (var i = 0; i < maxLen; i++) { + final a = i < parts.length ? parts[i] : _VersionPart.zero; + final b = i < other.parts.length ? other.parts[i] : _VersionPart.zero; + final cmp = a.compareTo(b); + if (cmp != 0) return cmp; + } + + // 2. handle pre-release vs stable + final aHasPre = preRelease.isNotEmpty; + final bHasPre = other.preRelease.isNotEmpty; + if (aHasPre && !bHasPre) return -1; // pre-release < stable + if (!aHasPre && bHasPre) return 1; // stable > pre-release + + // 3. both stable or both prerelease + final maxPre = preRelease.length > other.preRelease.length + ? preRelease.length + : other.preRelease.length; + + for (var i = 0; i < maxPre; i++) { + final aId = i < preRelease.length ? preRelease[i] : _Identifier.empty; + final bId = i < other.preRelease.length + ? other.preRelease[i] + : _Identifier.empty; + + final cmp = aId.compareTo(bId); + if (cmp != 0) return cmp; + } + + // versions are identical + return 0; + } + + @override + String toString() { + final core = parts.join('.'); + if (preRelease.isEmpty) return core; + return '$core-${preRelease.join('.')}'; + } +} + +/* -------------------------------------------------------------------------- */ + +class _VersionPart implements Comparable<_VersionPart> { + _VersionPart(String raw) : isNumeric = _numeric.hasMatch(raw), value = raw; + + static final _numeric = RegExp(r'^[0-9]+$'); + + /// Special value for absent parts when lengths differ. + static final zero = _VersionPart('0'); + + final bool isNumeric; + final String value; + + @override + int compareTo(_VersionPart other) { + // If both are numeric, compare numerically + if (isNumeric && other.isNumeric) { + return int.parse(value).compareTo(int.parse(other.value)); + } + + // If both are non-numeric, compare lexically + if (!isNumeric && !other.isNumeric) { + return value.compareTo(other.value); + } + + // Mixed: numeric parts are considered "less than" non-numeric parts + // This means "1.0.0" < "android.1.0" + return isNumeric ? -1 : 1; + } + + @override + String toString() => value; +} + +class _Identifier implements Comparable<_Identifier> { + _Identifier(String raw) : isNumeric = _numeric.hasMatch(raw), value = raw; + + static final _numeric = RegExp(r'^[0-9]+$'); + + /// Special value for absent identifiers when lengths differ. + static final empty = _Identifier('').._isEmpty = true; + + final bool isNumeric; + final String value; + bool _isEmpty = false; + + @override + int compareTo(_Identifier other) { + // Empty identifiers are considered lower + if (_isEmpty && other._isEmpty) return 0; + if (_isEmpty) return -1; + if (other._isEmpty) return 1; + + // Numeric vs non-numeric + if (isNumeric && other.isNumeric) { + // numeric compare + return int.parse(value).compareTo(int.parse(other.value)); + } + if (isNumeric != other.isNumeric) { + // numeric identifiers have lower precedence than non-numeric + return isNumeric ? -1 : 1; + } + // Both non-numeric: lexicographic ASCII + return value.compareTo(other.value); + } + + @override + String toString() => value; +} diff --git a/lib/widgets/app_card.dart b/lib/widgets/app_card.dart index 5f030d3..79e2145 100644 --- a/lib/widgets/app_card.dart +++ b/lib/widgets/app_card.dart @@ -1,253 +1,576 @@ -import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:remove_markdown/remove_markdown.dart'; +import 'package:models/models.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:go_router/go_router.dart'; +import 'package:markdown/markdown.dart' as md; import 'package:skeletonizer/skeletonizer.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/app.dart'; import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/widgets/author_container.dart'; -import 'package:zapstore/widgets/install_button.dart'; -import 'package:zapstore/widgets/pill_widget.dart'; -import 'package:zapstore/widgets/rounded_image.dart'; -import 'package:zapstore/widgets/version_pill_widget.dart'; +import 'package:zapstore/utils/url_utils.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/widgets/zap_widgets.dart'; + +import 'common/profile_avatar.dart'; +import 'version_pill_widget.dart'; +import 'install_button.dart'; +import '../theme.dart'; class AppCard extends HookConsumerWidget { - final App model; - final bool showDate; - final bool showUpdate; - - const AppCard( - {super.key, - required this.model, - this.showUpdate = false, - this.showDate = false}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final state = ref.apps.watchOne(model, alsoWatch: (_) => {_.localApp}); - final app = state.model!; - - final isUpdate = app.canUpdate && showUpdate; - return GestureDetector( - onTap: () => context.push('/details', extra: app), - child: Card( - margin: EdgeInsets.only(top: 6, bottom: 6), - elevation: 0, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - RoundedImage( - url: app.icons.firstOrNull, - size: 64, - radius: 15, - ), - Gap(16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Gap(2), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: AutoSizeText( - app.name!, - minFontSize: 16, - style: TextStyle( - fontSize: 19, fontWeight: FontWeight.bold), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ), - VersionPillWidget(app: app), - ], - ), - Gap(6), - Text( - app.description.removeMarkdown().parseEmojis(), - style: - TextStyle(fontSize: 14, fontWeight: FontWeight.w300), - overflow: TextOverflow.ellipsis, - maxLines: 3, - softWrap: true, - ), - Gap(6), - if (app.signer.isPresent && app.isSelfSigned) - AuthorContainer( - user: app.signer.value!, - beforeText: 'Signed by', - oneLine: true, - size: 12, - ), - if (showDate) - Text( - app.latestRelease!.event.createdAt.toIso8601String()), - if (isUpdate) - Padding( - padding: const EdgeInsets.only(top: 10, bottom: 10), - child: InstallButton( - app: app, - compact: true, - ), - ), - if (app.identifier == kZapstoreAppIdentifier && - app.canUpdate) - Padding( - padding: const EdgeInsets.only(top: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Icon( - Icons.warning_rounded, - size: 18, - color: Colors.orange[200], - ), - Gap(10), - Text('Please make sure to update', - style: TextStyle( - color: Colors.orange[200], - fontWeight: FontWeight.bold, - )), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ), - ); - } -} - -class SkeletonAppCard extends StatelessWidget { - const SkeletonAppCard({super.key}); - - @override - Widget build(BuildContext context) { - return Skeletonizer.zone( - child: Card( - margin: EdgeInsets.only(top: 6, bottom: 6), - elevation: 0, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Column( - children: [ - Bone.square(uniRadius: 10, size: 70), - ], - ), - Gap(16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Bone.text(fontSize: 20), - Padding( - padding: const EdgeInsets.only(top: 4), - child: Bone.multiText(lines: 4), - ), - ], - ), - ), - ], - ), - ), - ), - ); - } -} - -class TinyAppCard extends HookConsumerWidget { final App? app; + final bool isLoading; + final bool showUpdateArrow; + final bool showSignedBy; + final bool showUpdateButton; + final bool showZapEncouragement; + final bool showDescription; - const TinyAppCard({super.key, required this.app}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return GestureDetector( - onTap: () { - if (app != null) { - context.push('/details', extra: app); - } - }, - child: Card( - margin: EdgeInsets.all(0), - elevation: 0, - child: Padding( - padding: const EdgeInsets.only(top: 12, bottom: 8, left: 6, right: 6), - child: app == null - ? Skeletonizer.zone( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Bone.square(uniRadius: 12, size: 46), - Gap(10), - Bone.multiText( - lines: 2, fontSize: 8, textAlign: TextAlign.center), - ], - ), - ) - : Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - RoundedImage( - url: app!.icons.firstOrNull, - size: 46, - radius: 12, - ), - Gap(8), - Expanded( - child: Center( - child: AutoSizeText( - app!.name!, - textAlign: TextAlign.center, - minFontSize: 9, - style: TextStyle(fontSize: 11.5), - overflow: TextOverflow.clip, - maxLines: 2, - wrapWords: false, - ), - ), - ), - ], - ), - ), - ), - ); - } -} - -class TagsContainer extends StatelessWidget { - final List tags; - const TagsContainer({ + const AppCard({ super.key, - required this.tags, + this.app, + this.isLoading = false, + this.showUpdateArrow = false, + this.showSignedBy = true, + this.showUpdateButton = false, + this.showZapEncouragement = false, + this.showDescription = true, }); @override - Widget build(BuildContext context) { - return Row( - children: [ - for (final tag in tags) - Padding( - padding: const EdgeInsets.only(right: 6), - child: PillWidget(TextSpan(text: tag)), + Widget build(BuildContext context, WidgetRef ref) { + // Show skeleton when loading or app is null + if (isLoading || app == null) { + return _buildSkeleton(context); + } + + final currentApp = app!; + final needsPublisher = showSignedBy || showZapEncouragement; + final descriptionStyle = context.textTheme.bodyMedium?.copyWith( + height: 1.5, + color: AppColors.darkOnSurfaceSecondary, + ); + final descriptionText = app!.description.isNotEmpty + ? _stripMarkdown(app!.description) + : 'No description available'; + + Widget buildCard(Profile? publisher) => GestureDetector( + onTap: () { + final segments = GoRouterState.of(context).uri.pathSegments; + final first = segments.isNotEmpty ? segments.first : 'search'; + // Prefer naddr so the detail screen can uniquely identify the app + // (identifier + author) and not accidentally resolve to a different + // publisher's app with the same identifier. + final naddr = Utils.encodeShareableIdentifier( + AddressInput( + identifier: app!.identifier, + author: app!.pubkey, + kind: app!.event.kind, + relays: const [], ), + ); + context.push('/$first/app/$naddr'); + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row: Icon + Name/Version (icon matches header height) + LayoutBuilder( + builder: (context, constraints) { + // Icon takes a little over 20% of available width + final iconSize = (constraints.maxWidth * 0.21).clamp( + 50.0, + 68.0, + ); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // App Icon (stretches to match name + version height, ~20% width) + _buildAppIcon(context, iconSize), + + const SizedBox(width: 14), + + // App Name and Version (always stacked) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // App name with optional "by publisher" inline + _buildAppNameWithPublisher(context, publisher), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: VersionPillWidget( + app: app!, + showUpdateArrow: showUpdateArrow, + ), + ), + ], + ), + ), + ], + ); + }, + ), + + // App Description rendered as plain text (markdown stripped) + if (showDescription) ...[ + const SizedBox(height: 10), + Text( + descriptionText, + style: descriptionStyle, + overflow: TextOverflow.ellipsis, + maxLines: 2, + softWrap: true, + ), + ], + + // Update button (for apps with updates or currently downloading/installing) + if (showUpdateButton) + LayoutBuilder( + builder: (context, constraints) { + // Match the text column start (icon width + spacing) + final iconSize = (constraints.maxWidth * 0.21).clamp( + 50.0, + 68.0, + ); + final leftInset = iconSize + 14; + + return Padding( + padding: EdgeInsets.only(left: leftInset), + child: SizedBox( + width: (constraints.maxWidth - leftInset).clamp( + 0.0, + constraints.maxWidth, + ), + child: _AppCardUpdateButtonSection(app: app!), + ), + ); + }, + ), + + // Zap encouragement (only for downloading/installing developer-signed apps) + if (showZapEncouragement) + _AppCardZapEncouragementSection(app: app!, publisher: publisher), + ], + ), + ), + ); + + if (!needsPublisher) return buildCard(null); + + return Consumer( + builder: (context, ref, _) { + final publisherState = ref.watch( + query( + authors: {currentApp.event.pubkey}, + source: const LocalAndRemoteSource( + relays: {'social', 'vertex'}, + cachedFor: Duration(hours: 2), + ), + ), + ); + + final publisher = switch (publisherState) { + StorageData(:final models) => models.firstOrNull, + _ => null, + }; + + return buildCard(publisher); + }, + ); + } + + Widget _buildAppIcon(BuildContext context, double size) { + final iconUrl = firstValidHttpUrl(app!.icons); + + return SizedBox( + width: size, + height: size, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: iconUrl != null + ? CachedNetworkImage( + imageUrl: iconUrl, + fit: BoxFit.cover, + fadeInDuration: const Duration(milliseconds: 500), + fadeOutDuration: const Duration(milliseconds: 200), + placeholder: (_, url) => const SizedBox.shrink(), + errorWidget: (context, url, error) => Center( + child: Icon( + Icons.broken_image_outlined, + size: 32, + color: Colors.grey[400], + ), + ), + ) + : Center( + child: Icon( + Icons.apps_outlined, + size: 32, + color: Colors.grey[400], + ), + ), + ), + ), + ); + } + + Widget _buildAppNameWithPublisher(BuildContext context, Profile? publisher) { + final appName = app!.name ?? app!.identifier; + final titleStyle = context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + ); + + // If no publisher or relay-signed, just show name + if (!showSignedBy || publisher == null || app!.isRelaySigned) { + return Text( + appName, + style: titleStyle, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); + } + + // Show "(app name) by (profile)" with wrapping + final byStyle = context.textTheme.bodyMedium?.copyWith( + color: AppColors.darkOnSurfaceSecondary, + ); + final publisherStyle = byStyle?.copyWith(fontWeight: FontWeight.w600); + final avatarSize = context.textTheme.bodyMedium!.fontSize! * 1.4; + final displayName = publisher.nameOrNpub.abbreviateNpub(); + + return Text.rich( + TextSpan( + children: [ + TextSpan(text: appName, style: titleStyle), + TextSpan(text: ' by ', style: byStyle), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: avatarSize, + height: avatarSize, + child: ProfileAvatar( + profile: publisher, + radius: avatarSize / 2, + ), + ), + ), + ), + TextSpan(text: displayName, style: publisherStyle), + ], + ), + softWrap: true, + overflow: TextOverflow.visible, + ); + } + + /// Parses markdown and returns plain text without formatting artifacts. + String _stripMarkdown(String input) { + final doc = md.Document(encodeHtml: false); + final nodes = doc.parseLines(input.split('\n')); + final buffer = StringBuffer(); + + void writeNode(md.Node node) { + if (node is md.Text) { + buffer.write(node.text); + return; + } + + if (node is md.Element) { + // Preserve spacing for block-level elements + final isBlock = const { + 'p', + 'li', + 'ul', + 'ol', + 'blockquote', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + }.contains(node.tag); + + if (node.tag == 'br') buffer.write('\n'); + + for (final child in node.children ?? []) { + writeNode(child); + } + + if (isBlock) buffer.write('\n'); + } + } + + for (final node in nodes) { + writeNode(node); + } + + return buffer.toString().replaceAll(RegExp(r'\s+'), ' ').trim(); + } + + Widget _buildSkeleton(BuildContext context) { + return SkeletonizerConfig( + data: AppColors.getSkeletonizerConfig(Theme.of(context).brightness), + child: Skeletonizer( + enabled: true, + child: Container( + // Match actual content: vertical: 4 + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.2), + width: 1, + ), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final iconSize = (constraints.maxWidth * 0.21).clamp(50.0, 68.0); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row: Icon + Name/Version + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // App icon skeleton + Container( + width: iconSize, + height: iconSize, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(14), + ), + ), + const SizedBox(width: 14), + // App name and version skeleton (stacked) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // App name skeleton (with "by publisher" space) + Container( + height: 20, + width: 180, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 8), + // Version pill skeleton + Container( + height: 20, + width: 60, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(10), + ), + ), + ], + ), + ), + ], + ), + // Description skeleton - 2 lines to match maxLines: 2 + if (showDescription) ...[ + const SizedBox(height: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 16, + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 8), + Container( + height: 16, + width: 200, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ], + ], + ); + }, + ), + ), + ), + ); + } +} + +class _AppCardUpdateButtonSection extends ConsumerWidget { + const _AppCardUpdateButtonSection({required this.app}); + + final App app; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final operation = ref.watch(installOperationProvider(app.identifier)); + final hasOperation = operation != null; + final shouldShow = app.hasUpdate || hasOperation; + + if (!shouldShow) return const SizedBox.shrink(); + + return Column( + children: [ + const Gap(10), + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 38), + child: _CompactInstallButton( + app: app, + release: app.latestRelease.value, + ), + ), ], ); } } + +class _AppCardZapEncouragementSection extends ConsumerWidget { + const _AppCardZapEncouragementSection({ + required this.app, + required this.publisher, + }); + + final App app; + final Profile? publisher; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final operation = ref.watch(installOperationProvider(app.identifier)); + + final isActive = operation?.isActive ?? false; + final hasLud16 = publisher?.lud16?.trim().isNotEmpty ?? false; + final canZap = publisher != null && hasLud16; + final shouldShow = isActive && !app.isRelaySigned && canZap; + + if (!shouldShow) return const SizedBox.shrink(); + + return Column( + children: [ + const Gap(10), + _ZapEncouragementInCard(app: app, author: publisher), + ], + ); + } +} + +/// Compact wrapper around InstallButton for use in app cards +class _CompactInstallButton extends ConsumerWidget { + final App app; + final Release? release; + + const _CompactInstallButton({required this.app, this.release}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return InstallButton(app: app, release: release, compact: true); + } +} + +/// Zap encouragement widget shown inside app cards during installation +class _ZapEncouragementInCard extends HookConsumerWidget { + final App app; + final Profile? author; + + const _ZapEncouragementInCard({required this.app, required this.author}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return InkWell( + onTap: () => showZapDialog(context, ref, app, author), + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Colors.orange.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + 'Enjoying ${app.name ?? 'this app'}? Zap it!', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: 8), + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 28), + child: TextButton( + onPressed: () => showZapDialog(context, ref, app, author), + style: TextButton.styleFrom( + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + child: Text( + 'Zap', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/app_curation_container.dart b/lib/widgets/app_curation_container.dart deleted file mode 100644 index 1ab5457..0000000 --- a/lib/widgets/app_curation_container.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:purplebase/purplebase.dart' as base; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/app_curation_set.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/widgets/horizontal_grid.dart'; -import 'package:zapstore/widgets/pill_widget.dart'; -import 'package:zapstore/widgets/rounded_image.dart'; - -class AppCurationContainer extends HookConsumerWidget { - const AppCurationContainer({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final scrollController = useScrollController(); - final selectedAppCurationSet = ref.watch(_selectedIdProvider); - final appCurationSets = ref.appCurationSets - .findAllLocal() - .sortedBy((s) => s.event.createdAt) - .reversed - .toList(); - - // Custom curation set to place nostr set first (as its preloaded) - final nostrCurationSet = appCurationSets.firstWhereOrNull( - (s) => s.getReplaceableEventLink() == kNostrCurationSetLink); - if (nostrCurationSet != null) { - appCurationSets.remove(nostrCurationSet); - appCurationSets.insert(0, nostrCurationSet); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: scrollController, - child: Row( - children: [ - for (final appCurationSet in appCurationSets) - Padding( - padding: const EdgeInsets.only(right: 12), - child: GestureDetector( - onTap: () => ref.read(_selectedIdProvider.notifier).state = - appCurationSet.getReplaceableEventLink(), - child: PillWidget( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - appCurationSet.name, - style: TextStyle(fontWeight: FontWeight.bold), - ), - if (appCurationSet.signer.isPresent) - Row( - children: [ - Text( - ' by ${appCurationSet.signer.value!.name}'), - Gap(5), - RoundedImage( - url: appCurationSet - .signer.value!.avatarUrl, - size: 16), - ], - ), - ], - ), - ), - color: appCurationSet.getReplaceableEventLink() == - selectedAppCurationSet - ? Colors.blue[700]! - : Colors.grey[800]!, - ), - ), - ), - ], - ), - ), - Gap(12), - Consumer( - builder: (context, ref, _) { - final state = - ref.watch(appCurationSetProvider(selectedAppCurationSet)); - return state.when( - data: (set) => HorizontalGrid(apps: set.apps.toList()), - error: (e, _) => Text('Error: $e ; $_'), - loading: () => HorizontalGrid(apps: []), - ); - }, - ), - ], - ); - } -} - -// Providers - -class AppCurationSetNotifier - extends FamilyAsyncNotifier { - @override - Future build(base.ReplaceableEventLink arg) async { - final appCurationSet = ref.appCurationSets.findOneLocalById(arg.formatted)!; - fetch(); - return appCurationSet; - } - - Future fetch() async { - final appCurationSet = await future; - - if (appCurationSet.apps.isEmpty) { - state = AsyncLoading(); - } - state = await AsyncValue.guard(() async { - // Only load apps (no releases for now, includes=false) - // We can use ignoreReturn here as we do not care about releases - await ref.apps.findAll( - params: { - '#d': appCurationSet.appIds, - 'includes': false, - 'ignoreReturn': true, - }, - ); - return appCurationSet; - }); - } -} - -final appCurationSetProvider = AsyncNotifierProvider.family< - AppCurationSetNotifier, - AppCurationSet, - base.ReplaceableEventLink>(AppCurationSetNotifier.new); - -final _selectedIdProvider = - StateProvider((_) => kNostrCurationSetLink); - -const kNostrCurationSetLink = (30267, kFranzapPubkey, 'nostr'); diff --git a/lib/widgets/app_detail_widgets.dart b/lib/widgets/app_detail_widgets.dart new file mode 100644 index 0000000..c48bea6 --- /dev/null +++ b/lib/widgets/app_detail_widgets.dart @@ -0,0 +1,931 @@ +import 'dart:convert'; + +import 'package:async_button_builder/async_button_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:zapstore/services/bookmarks_service.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/theme.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/bookmark_widgets.dart'; +import 'package:zapstore/widgets/common/base_dialog.dart'; +import 'package:zapstore/widgets/expandable_markdown.dart'; +import 'package:zapstore/widgets/zap_widgets.dart'; + +/// Format a date as time ago (e.g., "2 days ago") +String formatDate(DateTime date) { + final now = DateTime.now(); + final diff = now.difference(date); + + if (diff.inDays > 365) { + return '${(diff.inDays / 365).floor()} year${diff.inDays ~/ 365 != 1 ? 's' : ''} ago'; + } else if (diff.inDays > 30) { + return '${(diff.inDays / 30).floor()} month${diff.inDays ~/ 30 != 1 ? 's' : ''} ago'; + } else if (diff.inDays > 0) { + return '${diff.inDays} day${diff.inDays != 1 ? 's' : ''} ago'; + } else if (diff.inHours > 0) { + return '${diff.inHours} hour${diff.inHours != 1 ? 's' : ''} ago'; + } else { + return 'just now'; + } +} + +/// Widget displaying release notes with expandable markdown +class ReleaseNotes extends StatelessWidget { + const ReleaseNotes({super.key, required this.release}); + + final Release release; + + @override + Widget build(BuildContext context) { + if (release.releaseNotes?.isEmpty != false) { + return const SizedBox.shrink(); + } + return ExpandableMarkdown( + data: release.releaseNotes!, + onTapLink: (text, url, title) { + if (url != null) { + launchUrl(Uri.parse(url)); + } + }, + styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith( + blockquoteDecoration: BoxDecoration( + color: const Color(0xFF1E3A5F), // Dark blue + borderRadius: BorderRadius.circular(8), + ), + ), + ); + } +} + +/// Row of social action buttons (zap, bookmark, app stack, share) +class SocialActionsRow extends HookConsumerWidget { + const SocialActionsRow({super.key, required this.app, required this.author}); + + final App app; + final Profile? author; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + final isSignedIn = signedInPubkey != null; + + // Watch saved apps from centralized provider (handles decryption) + final savedAppsAsync = ref.watch(bookmarksProvider); + final savedAppIds = savedAppsAsync.when( + data: (ids) => ids, + loading: () => {}, + error: (_, __) => {}, + ); + + // Check if this app is saved + final appAddressableId = + '${app.event.kind}:${app.pubkey}:${app.identifier}'; + final isPrivatelySaved = savedAppIds.contains(appAddressableId); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Zap + Save App + App Stack button row + SizedBox( + height: 36, + child: Row( + children: [ + // Zap button + Expanded( + flex: 68, + child: ZapButton(app: app, author: author), + ), + const SizedBox(width: 8), + // Save App button + Expanded( + flex: 16, + child: FilledButton( + onPressed: () => _handleSaveApp( + context, + ref, + app, + isPrivatelySaved, + isSignedIn, + ), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + backgroundColor: isPrivatelySaved && isSignedIn + ? (Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF9B4F5E) // Red wine/burgundy + : const Color(0xFF7D3C4D)) // Deep burgundy + : (Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF3A3A3F) // Dark neutral + : const Color(0xFFE8E3E8)), // Light neutral + foregroundColor: isPrivatelySaved && isSignedIn + ? Colors.white + : Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Icon( + isPrivatelySaved && isSignedIn + ? Icons.bookmark + : Icons.bookmark_border, + size: 20, + ), + ), + ), + const SizedBox(width: 8), + // App Stack button + Expanded( + flex: 16, + child: FilledButton( + onPressed: () => _showAddToStackDialog(context, app), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + backgroundColor: + Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF4A7BA7) // Soft steel blue + : const Color(0xFF5B8FB9), // Light ocean blue + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.apps, size: 20), + ), + ), + ], + ), + ), + + // Zappers list + if (!app.isRelaySigned) _ZappersListSection(app: app), + ], + ); + } + + Future _handleSaveApp( + BuildContext context, + WidgetRef ref, + App app, + bool isPrivatelySaved, + bool isSignedIn, + ) async { + // If not signed in, show dialog to prompt sign in + if (!isSignedIn) { + await showBaseDialog( + context: context, + dialog: SaveAppDialog(app: app, isPrivatelySaved: isPrivatelySaved), + ); + return; + } + + // If signed in, save directly without dialog + try { + final signer = ref.read(Signer.activeSignerProvider); + final signedInPubkey = ref.read(Signer.activePubkeyProvider); + + if (signer == null || signedInPubkey == null) return; + + // Query for existing stack + final existingStackState = await ref.storage.query( + RequestFilter( + authors: {signedInPubkey}, + tags: { + '#d': {kAppBookmarksIdentifier}, + }, + ).toRequest(), + source: const LocalSource(), + ); + final existingStack = existingStackState.firstOrNull; + + // Get existing app IDs by decrypting if stack exists + List existingAppIds = []; + if (existingStack != null) { + try { + final decryptedContent = await signer.nip44Decrypt( + existingStack.content, + signedInPubkey, + ); + existingAppIds = (jsonDecode(decryptedContent) as List) + .cast(); + } catch (_) { + // Silently start fresh if decryption fails + } + } + + // Modify the list + final appAddressableId = + '${app.event.kind}:${app.pubkey}:${app.identifier}'; + + if (isPrivatelySaved) { + existingAppIds.remove(appAddressableId); + } else { + if (!existingAppIds.contains(appAddressableId)) { + existingAppIds.add(appAddressableId); + } + } + + // Create new partial stack with updated list + final partialStack = PartialAppStack.withEncryptedApps( + name: 'Saved Apps', + identifier: kAppBookmarksIdentifier, + apps: existingAppIds, + ); + + // Sign (encrypts the content) + final signedStack = await partialStack.signWith(signer); + + // Save to local storage and publish to relays + await ref.storage.save({signedStack}); + ref.storage.publish({ + signedStack, + }, source: RemoteSource(relays: 'social')); + + if (context.mounted) { + context.showInfo( + isPrivatelySaved ? 'App removed from saved' : 'App saved privately', + ); + } + } catch (e) { + if (context.mounted) { + context.showError('Failed to save app', description: '$e'); + } + } + } + + Future _showAddToStackDialog(BuildContext context, App app) async { + await showBaseDialog( + context: context, + dialog: AddToStackDialog(app: app), + ); + } +} + +class _ZappersListSection extends ConsumerWidget { + const _ZappersListSection({required this.app}); + + final App app; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final metadataId = app.latestFileMetadata?.id; + + // Query zaps on app (via #a tag) + final zapsState = ref.watch( + query( + tags: app.event.addressableIdTagMap, + source: const LocalAndRemoteSource(relays: 'social'), + subscriptionPrefix: 'app-zaps', + ), + ); + + // Query zaps on metadata (via #e tag) - for legacy compatibility + final zapsOnMetadataState = metadataId != null + ? ref.watch( + query( + tags: { + '#e': {metadataId}, + }, + source: const LocalAndRemoteSource(relays: 'social'), + subscriptionPrefix: 'metadata-zaps', + ), + ) + : null; + + // Combine zaps from both queries + final allZaps = { + ...zapsState.models, + if (zapsOnMetadataState != null) ...zapsOnMetadataState.models, + }; + + if (allZaps.isEmpty) return const SizedBox.shrink(); + + // Collect zapper pubkeys from metadata (already extracted from description tag) + final zapperPubkeys = {}; + for (final zap in allZaps) { + // The zapper's pubkey is in event.metadata['author'], extracted from description + final zapperPubkey = zap.event.metadata['author'] as String?; + if (zapperPubkey != null) { + zapperPubkeys.add(zapperPubkey); + } + } + + // Query profiles separately with cachedFor + final profilesState = ref.watch( + query( + authors: zapperPubkeys, + source: const LocalAndRemoteSource( + relays: {'social', 'vertex'}, + cachedFor: Duration(hours: 2), + ), + ), + ); + final profilesMap = { + for (final p in profilesState.models) p.pubkey: p, + }; + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: ZappersHorizontalList( + zaps: allZaps.toList(), + profilesMap: profilesMap, + ), + ); + } +} + +/// Skeleton loading state for app detail screen +class AppDetailSkeleton extends StatelessWidget { + const AppDetailSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return SkeletonizerConfig( + data: AppColors.getSkeletonizerConfig(Theme.of(context).brightness), + child: Skeletonizer( + enabled: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header (AppHeader) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: SizedBox( + width: 74, + height: 74, + child: buildGradientLoader(context), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 20, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + ), + child: buildGradientLoader(context), + ), + const SizedBox(height: 8), + SizedBox( + height: 16, + width: 140, + child: buildGradientLoader(context), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 12), + + // Author (AuthorContainer) + const AuthorSkeleton(), + + const SizedBox(height: 12), + + // Download text (DownloadTextContainer) + SizedBox( + height: 16, + width: double.infinity, + child: buildGradientLoader(context), + ), + + const SizedBox(height: 16), + + // Screenshots (ScreenshotsGallery) + SizedBox( + height: 200, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 6, + padding: const EdgeInsets.only(right: 12), + itemBuilder: (context, index) { + return Container( + width: 120, + margin: EdgeInsets.only(left: index == 0 ? 0 : 6, right: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: buildGradientLoader(context), + ), + ); + }, + ), + ), + + const SizedBox(height: 16), + + // Description (ExpandableMarkdown) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate( + 4, + (i) => Padding( + padding: EdgeInsets.only(bottom: i == 3 ? 0 : 8), + child: SizedBox( + height: 16, + width: i == 3 ? 180 : double.infinity, + child: buildGradientLoader(context), + ), + ), + ), + ), + + const SizedBox(height: 16), + + // Zappers list (when present) + Row( + children: [ + // total sats pill + Container( + height: 26, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + ), + child: buildGradientLoader(context), + ), + const SizedBox(width: 12), + // avatars + amounts + ...List.generate( + 4, + (index) => Padding( + padding: const EdgeInsets.only(right: 12), + child: Row( + children: [ + ClipOval( + child: SizedBox( + width: 24, + height: 24, + child: buildGradientLoader(context), + ), + ), + const SizedBox(width: 6), + SizedBox( + width: 32, + height: 12, + child: buildGradientLoader(context), + ), + ], + ), + ), + ), + ], + ), + + const SizedBox(height: 8), + + // Zap button + SizedBox( + height: 48, + width: double.infinity, + child: buildGradientLoader(context), + ), + + const SizedBox(height: 24), + + // Latest release / Up to date title + SizedBox( + height: 24, + width: 160, + child: buildGradientLoader(context), + ), + const SizedBox(height: 8), + Row( + children: [ + SizedBox( + height: 22, + width: 100, + child: buildGradientLoader(context), + ), + const SizedBox(width: 12), + SizedBox( + height: 22, + width: 90, + child: buildGradientLoader(context), + ), + ], + ), + + const SizedBox(height: 16), + + // Release notes (ExpandableMarkdown) + Column( + children: List.generate( + 3, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: SizedBox( + height: 16, + width: index == 2 ? 200 : double.infinity, + child: buildGradientLoader(context), + ), + ), + ), + ), + + const SizedBox(height: 16), + + // App info table (AppInfoTable) + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: List.generate( + 4, + (index) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 16, + child: buildGradientLoader(context), + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 80, + height: 16, + child: buildGradientLoader(context), + ), + ], + ), + ), + ), + ), + ), + ), + + const SizedBox(height: 16), + + // Comments section (CommentsSection) + Column( + children: List.generate( + 3, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipOval( + child: SizedBox( + width: 28, + height: 28, + child: buildGradientLoader(context), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 12, + width: 120, + child: buildGradientLoader(context), + ), + const SizedBox(height: 6), + SizedBox( + height: 14, + child: buildGradientLoader(context), + ), + const SizedBox(height: 6), + SizedBox( + height: 14, + width: 200, + child: buildGradientLoader(context), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Build gradient loader for skeleton screens +Widget buildGradientLoader(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF2A2A2A), Color(0xFF3A3A3A), Color(0xFF2A2A2A)], + stops: [0.0, 0.5, 1.0], + ), + ), + child: LocalShimmerEffect(context: context), + ); +} + +/// Local shimmer effect for skeleton screens +class LocalShimmerEffect extends HookWidget { + final BuildContext context; + + const LocalShimmerEffect({super.key, required this.context}); + + @override + Widget build(BuildContext context) { + final controller = useAnimationController( + duration: const Duration(milliseconds: 1500), + )..repeat(); + final animation = useMemoized( + () => Tween( + begin: -1.0, + end: 2.0, + ).animate(CurvedAnimation(parent: controller, curve: Curves.easeInOut)), + [controller], + ); + + const shimmerColors = [ + Color(0xFF2A2A2A), + Color(0xFF3A3A3A), + Color(0xFF2A2A2A), + ]; + + return AnimatedBuilder( + animation: animation, + builder: (context, child) { + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: shimmerColors, + stops: [ + (animation.value - 0.3).clamp(0.0, 1.0), + animation.value.clamp(0.0, 1.0), + (animation.value + 0.3).clamp(0.0, 1.0), + ], + ), + ), + ); + }, + ); + } +} + +/// Skeleton loader for author information +class AuthorSkeleton extends StatelessWidget { + const AuthorSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return SkeletonizerConfig( + data: AppColors.getSkeletonizerConfig(Theme.of(context).brightness), + child: Skeletonizer( + enabled: true, + child: Row( + children: [ + ClipOval( + child: SizedBox( + width: 24, + height: 24, + child: buildGradientLoader(context), + ), + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox(height: 14, child: buildGradientLoader(context)), + ), + ], + ), + ), + ); + } +} + +/// Debug section showing all versions available for the app +class DebugVersionsSection extends HookConsumerWidget { + const DebugVersionsSection({super.key, required this.app}); + + final App app; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isExpanded = useState(false); + + // Query ALL releases for this app using addressable ID tags + final releasesState = ref.watch( + query( + tags: app.event.addressableIdTagMap, + and: (release) => {release.latestMetadata, release.latestAsset}, + source: LocalAndRemoteSource(relays: 'AppCatalog', stream: false), + subscriptionPrefix: 'debug-releases', + ), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(), + const SizedBox(height: 8), + FilledButton.tonal( + onPressed: () => isExpanded.value = !isExpanded.value, + style: FilledButton.styleFrom( + backgroundColor: Colors.purple.withValues(alpha: 0.2), + foregroundColor: Colors.purple, + minimumSize: const Size(double.infinity, 48), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🐛 Debug: All Versions'), + const SizedBox(width: 8), + Icon( + isExpanded.value ? Icons.expand_less : Icons.expand_more, + size: 20, + ), + ], + ), + ), + if (isExpanded.value) ...[ + const SizedBox(height: 16), + switch (releasesState) { + StorageLoading() => const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator(), + ), + ), + StorageError(:final exception) => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Error loading releases: $exception', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ), + StorageData(:final models) => + models.isEmpty + ? const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: Text('No releases found'), + ), + ) + : Column( + children: models.map((release) { + final metadata = release.latestMetadata.value; + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'Version ${release.version}', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (metadata != null) ...[ + Text( + 'Released ${formatDate(metadata.createdAt)}', + style: Theme.of( + context, + ).textTheme.bodySmall, + ), + Text( + '${(metadata.size ?? 0) ~/ (1024 * 1024)} MB', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), + ), + ], + ], + ), + ), + if (metadata != null) + SizedBox( + width: 100, + height: 36, + child: AsyncButtonBuilder( + onPressed: () => _installVersion( + ref, + app, + release, + metadata, + ), + builder: + ( + context, + child, + callback, + buttonState, + ) { + return FilledButton( + onPressed: buttonState + .maybeWhen( + loading: () => null, + orElse: () => callback, + ), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + ), + child: buttonState.maybeWhen( + loading: () => const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation< + Color + >(Colors.white), + ), + ), + orElse: () => + const Text('Install'), + ), + ); + }, + child: const SizedBox.shrink(), + onError: () { + if (context.mounted) { + context.showError( + 'Installation failed. Please try again.', + ); + } + }, + ), + ), + ], + ), + if (release.releaseNotes?.isNotEmpty == + true) ...[ + const SizedBox(height: 8), + Text( + release.releaseNotes!, + style: Theme.of( + context, + ).textTheme.bodySmall, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ); + }).toList(), + ), + }, + ], + ], + ); + } + + Future _installVersion( + WidgetRef ref, + App app, + Release release, + FileMetadata metadata, + ) async { + // Use PackageManager to start download + final pm = ref.read(packageManagerProvider.notifier); + await pm.startDownload(app.identifier, metadata, displayName: app.name); + } +} diff --git a/lib/widgets/app_header.dart b/lib/widgets/app_header.dart new file mode 100644 index 0000000..364aaa2 --- /dev/null +++ b/lib/widgets/app_header.dart @@ -0,0 +1,86 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/widgets/version_pill_widget.dart'; +import 'package:zapstore/utils/url_utils.dart'; + +class AppHeader extends StatelessWidget { + const AppHeader({super.key, required this.app}); + + final App app; + + @override + Widget build(BuildContext context) { + final iconUrl = firstValidHttpUrl(app.icons); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + ), + child: iconUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(16), + child: CachedNetworkImage( + imageUrl: iconUrl, + fit: BoxFit.cover, + fadeInDuration: const Duration(milliseconds: 500), + fadeOutDuration: const Duration(milliseconds: 200), + errorWidget: (context, url, error) => Center( + child: Icon( + Icons.broken_image_outlined, + size: 32, + color: Colors.grey[400], + ), + ), + placeholder: (context, url) => const SizedBox.shrink(), + ), + ) + : Center( + child: Icon( + Icons.apps_outlined, + size: 32, + color: Colors.grey[400], + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AutoSizeText( + app.name ?? app.identifier, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontSize: + (Theme.of( + context, + ).textTheme.headlineSmall?.fontSize ?? + 24) * + 1.08, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + minFontSize: 16, + ), + const SizedBox(height: 10), + VersionPillWidget(app: app, showUpdateArrow: true), + ], + ), + ), + ], + ), + Gap(16), + ], + ); + } +} diff --git a/lib/widgets/app_info_table.dart b/lib/widgets/app_info_table.dart new file mode 100644 index 0000000..fdcd5c2 --- /dev/null +++ b/lib/widgets/app_info_table.dart @@ -0,0 +1,309 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:models/models.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/download_text_container.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/theme.dart'; + +class AppInfoTable extends HookConsumerWidget { + const AppInfoTable({super.key, required this.app, this.fileMetadata}); + + final App app; + final FileMetadata? fileMetadata; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Column(children: _buildInfoRows(context, ref)), + ); + } + + List _buildInfoRows(BuildContext context, WidgetRef ref) { + final rows = []; + + rows.add( + _InfoRow( + label: 'Source', + value: app.repository ?? 'Not available', + valueWidget: app.repository != null + ? Flexible( + child: GestureDetector( + onTap: () => launchUrl(Uri.parse(app.repository!)), + child: DownloadTextContainer( + url: app.repository!, + beforeText: '', + oneLine: true, + showFullUrl: false, + size: context.textTheme.bodyMedium?.fontSize, + ), + ), + ) + : Flexible( + child: Text( + 'Not available', + style: context.textTheme.bodyMedium?.copyWith( + color: Colors.red[300], + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.right, + ), + ), + ), + ); + + if (app.license != null) { + final licenseId = app.license!; + final licenseName = licenseId == 'NOASSERTION' + ? 'N/A' + : (_spdxIdToName[licenseId] ?? licenseId); + rows.add(_InfoRow(label: 'License', value: licenseName)); + } + + rows.add(_InfoRow(label: 'App ID', value: app.identifier)); + + if (fileMetadata?.hash != null) { + final full = fileMetadata!.hash; + rows.add( + _InfoRow(label: 'File hash', value: full.abbreviate(), copyValue: full), + ); + } + + if (fileMetadata?.certificateHash != null) { + final full = fileMetadata!.certificateHash!; + rows.add( + _InfoRow( + label: 'Certificate hash', + value: full.abbreviate(), + copyValue: full, + ), + ); + } + + if (fileMetadata?.versionCode != null) { + // Get installed package info for comparison + final installedPackage = app.installedPackage; + final installedVersionCode = installedPackage?.versionCode; + final availableVersionCode = fileMetadata!.versionCode!; + + rows.add( + _InfoRow( + label: 'Version code', + value: availableVersionCode.toString(), + valueWidget: _buildVersionCodePills( + context, + ref, + installedVersionCode, + availableVersionCode, + ), + ), + ); + } + + // Add release date as the last row + final release = app.latestRelease.value; + if (release?.createdAt != null) { + rows.add( + _InfoRow( + label: 'Release date', + value: DateFormat('MMM d, y').format(release!.createdAt), + ), + ); + } + + return rows; + } + + Widget _buildVersionCodePills( + BuildContext context, + WidgetRef ref, + int? installedVersionCode, + int availableVersionCode, + ) { + // If no installed version or same version code, show single pill + if (installedVersionCode == null || + installedVersionCode == availableVersionCode) { + return Flexible( + child: _buildVersionCodePill( + context, + availableVersionCode.toString(), + AppColors.darkPillBackground, + Colors.white, + ), + ); + } + + // Show dual pills for upgrade/downgrade + final isDowngrade = availableVersionCode < installedVersionCode; + + return Flexible( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + // Installed version pill (muted) + _buildVersionCodePill( + context, + installedVersionCode.toString(), + Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), + Theme.of(context).colorScheme.onSurface, + ), + + // Arrow + Icon( + Icons.arrow_right, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + + // Available version pill (highlighted for update, muted for downgrade) + _buildVersionCodePill( + context, + availableVersionCode.toString(), + isDowngrade + ? Theme.of(context).colorScheme.outline.withValues(alpha: 0.3) + : AppColors.darkPillBackground, + isDowngrade + ? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5) + : Colors.white, + ), + ], + ), + ); + } + + Widget _buildVersionCodePill( + BuildContext context, + String versionCode, + Color backgroundColor, + Color textColor, + ) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + versionCode, + style: context.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + color: textColor, + ), + ), + ); + } +} + +// SPDX id -> human friendly name (compact) +const Map _spdxIdToName = { + '0BSD': 'BSD Zero Clause', + 'Apache-2.0': 'Apache 2.0', + 'MIT': 'MIT', + 'BSD-2-Clause': 'BSD 2-Clause "Simplified" License', + 'BSD-3-Clause': 'BSD 3-Clause "New" or "Revised" License', + 'GPL-2.0-only': 'GNU GPL v2.0 only', + 'GPL-2.0-or-later': 'GNU GPL v2.0 or later', + 'GPL-3.0-only': 'GNU GPL v3.0 only', + 'GPL-3.0-or-later': 'GNU GPL v3.0 or later', + 'LGPL-2.1-only': 'GNU LGPL v2.1 only', + 'LGPL-2.1-or-later': 'GNU LGPL v2.1 or later', + 'LGPL-3.0-only': 'GNU LGPL v3.0 only', + 'LGPL-3.0-or-later': 'GNU LGPL v3.0 or later', + 'AGPL-3.0-only': 'GNU AGPL v3.0 only', + 'AGPL-3.0-or-later': 'GNU AGPL v3.0 or later', + 'MPL-2.0': 'Mozilla Public License 2.0', + 'Unlicense': 'The Unlicense', + 'CC0-1.0': 'CC0 1.0', + 'CDDL-1.0': 'CDDL 1.0', + 'EUPL-1.2': 'EUPL 1.2', + 'BSL-1.0': 'Boost 1.0', + 'ISC': 'ISC', + 'Zlib': 'zlib', + 'Artistic-2.0': 'Artistic 2.0', + 'NCSA': 'NCSA', + 'WTFPL': 'WTFPL', +}; + +class _InfoRow extends StatelessWidget { + const _InfoRow({ + required this.label, + required this.value, + this.copyValue, + this.valueWidget, + }); + final String label; + final String value; + final String? copyValue; + final Widget? valueWidget; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + label, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Colors.grey[600]), + maxLines: 1, + ), + const SizedBox(width: 16), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + valueWidget != null + ? valueWidget! + : Flexible( + child: AutoSizeText( + value, + style: context.textTheme.bodyMedium, + maxLines: 1, + textAlign: TextAlign.right, + minFontSize: 12, + overflow: TextOverflow.ellipsis, + ), + ), + if (copyValue != null) ...[ + const SizedBox(width: 6), + IconButton( + icon: const Icon(Icons.copy, size: 16), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + style: IconButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: EdgeInsets.zero, + minimumSize: Size.zero, + ), + onPressed: () { + Clipboard.setData(ClipboardData(text: copyValue!)); + context.showInfo('Copied to clipboard'); + }, + ), + ], + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/app_stack_container.dart b/lib/widgets/app_stack_container.dart new file mode 100644 index 0000000..ee53a2e --- /dev/null +++ b/lib/widgets/app_stack_container.dart @@ -0,0 +1,538 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:go_router/go_router.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import '../utils/extensions.dart'; +import '../utils/url_utils.dart'; +import '../theme.dart'; +import 'common/profile_avatar.dart'; + +/// App Stack Container - horizontally scrollable 2-row grid of stack cards +class AppStackContainer extends HookConsumerWidget { + const AppStackContainer({super.key, this.showSkeleton = false}); + + final bool showSkeleton; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scrollController = useScrollController(); + + if (showSkeleton) { + return _buildSkeleton(context); + } + + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + + final appStacksState = ref.watch( + query( + limit: 50, + and: (pack) => {pack.apps}, + source: LocalAndRemoteSource(stream: true, relays: 'social'), + andSource: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + subscriptionPrefix: 'app-stack', + schemaFilter: appStackEventFilter, + ), + ); + + // Query contact list for signed-in user + final contactListState = signedInPubkey != null + ? ref.watch( + query( + authors: {signedInPubkey}, + limit: 1, + source: const LocalAndRemoteSource( + relays: 'social', + stream: false, + ), + subscriptionPrefix: 'user-contacts-stacks', + ), + ) + : null; + + final followingPubkeys = + contactListState?.models.firstOrNull?.followingPubkeys; + + final stacks = switch (appStacksState) { + StorageData(:final models) => models.toList(), + _ => [], + }; + + if (stacks.isEmpty) { + return _buildSkeleton(context); + } + + // Sort stacks: followed profiles first (or franzap if not signed in), then by recency + final sortedStacks = _sortStacks( + stacks, + signedInPubkey: signedInPubkey, + followingPubkeys: followingPubkeys, + ); + + // 2-row horizontal scroll list with partial next card visible (Android UX pattern) + return SingleChildScrollView( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(left: 12, right: 12), + clipBehavior: Clip.none, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < sortedStacks.length; i += 2) + Padding( + padding: EdgeInsets.only( + right: + i == sortedStacks.length - 2 || i == sortedStacks.length - 1 + ? 0 + : 10, + ), + child: SizedBox( + width: 160, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _StackCard(stack: sortedStacks[i]), + if (i + 1 < sortedStacks.length) ...[ + const SizedBox(height: 10), + _StackCard(stack: sortedStacks[i + 1]), + ], + ], + ), + ), + ), + ], + ), + ); + } + + List _sortStacks( + List stacks, { + String? signedInPubkey, + Set? followingPubkeys, + }) { + final random = Random(); + + if (signedInPubkey != null && + followingPubkeys != null && + followingPubkeys.isNotEmpty) { + // Signed in: followed profiles first, then by recency + final followed = []; + final others = []; + + for (final stack in stacks) { + if (followingPubkeys.contains(stack.pubkey)) { + followed.add(stack); + } else { + others.add(stack); + } + } + + // Sort both lists by recency + followed.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); + others.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt)); + + return [...followed, ...others]; + } else { + // Not signed in: put one random franzap stack first, then by recency + final franzapStacks = stacks + .where((s) => s.pubkey == kFranzapPubkey) + .toList(); + final otherStacks = stacks + .where((s) => s.pubkey != kFranzapPubkey) + .toList(); + + // Sort others by recency + otherStacks.sort( + (a, b) => b.event.createdAt.compareTo(a.event.createdAt), + ); + + if (franzapStacks.isNotEmpty) { + // Pick one random franzap stack + final randomFranzap = + franzapStacks[random.nextInt(franzapStacks.length)]; + return [randomFranzap, ...otherStacks]; + } + + return otherStacks; + } + } + + Widget _buildSkeleton(BuildContext context) { + return SkeletonizerConfig( + data: AppColors.getSkeletonizerConfig(Theme.of(context).brightness), + child: Skeletonizer( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(left: 12, right: 12), + clipBehavior: Clip.none, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int column = 0; column < 3; column++) ...[ + if (column > 0) const SizedBox(width: 10), + SizedBox( + width: 160, + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + _SkeletonStackCard(), + SizedBox(height: 10), + _SkeletonStackCard(), + ], + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +/// Skeleton card for loading state - matches vertical layout +class _SkeletonStackCard extends StatelessWidget { + const _SkeletonStackCard(); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + ), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 18, + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 8), + Container( + height: 14, + width: 90, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 12), + AspectRatio( + aspectRatio: 4, + child: Container( + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ), + ); + } +} + +/// Individual stack card with vertical layout: +/// Stack Name, by (profile), horizontal app icons row, total apps +class _StackCard extends HookConsumerWidget { + const _StackCard({required this.stack}); + + final AppStack stack; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Query author profile + final authorState = ref.watch( + query( + authors: {stack.event.pubkey}, + source: const LocalAndRemoteSource( + relays: {'social', 'vertex'}, + cachedFor: Duration(hours: 2), + ), + ), + ); + final author = switch (authorState) { + StorageData(:final models) => models.firstOrNull, + _ => null, + }; + + final stackApps = stack.apps.toList(); + final totalApps = stackApps.length; + + // Pick preview apps deterministically (stable UI) and only fetch those from AppCatalog + final previewBaseApps = (() { + final shuffled = List.from(stackApps) + ..shuffle(Random(stack.id.hashCode)); + return shuffled.take(totalApps > 4 ? 3 : 4).toList(); + })(); + final previewIdentifiers = previewBaseApps + .map((app) => app.identifier) + .toSet(); + + final appsState = ref.watch( + query( + tags: {'#d': previewIdentifiers}, + limit: previewIdentifiers.length, + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), + subscriptionPrefix: 'app-stack-preview-apps-${stack.id}', + ), + ); + + final appsMap = {for (final app in appsState.models) app.identifier: app}; + final previewApps = previewBaseApps + .map((app) => appsMap[app.identifier] ?? app) + .toList(); + + // Styling like app_card.dart (slightly larger for visibility) + final profileStyle = context.textTheme.bodySmall?.copyWith( + color: AppColors.darkOnSurfaceSecondary, + fontSize: 13, + ); + + return GestureDetector( + onTap: () { + final segments = GoRouterState.of(context).uri.pathSegments; + final first = segments.isNotEmpty ? segments.first : 'search'; + final naddr = Utils.encodeShareableIdentifier( + AddressInput( + identifier: stack.identifier, + author: stack.pubkey, + kind: stack.event.kind, + relays: const [], + ), + ); + context.push('/$first/stack/$naddr'); + }, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + width: 1, + ), + ), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Stack name with conditional fade out + _FadingText( + stack.name ?? stack.identifier, + style: context.textTheme.titleMedium?.copyWith( + fontFamily: 'Inter', + fontSize: (context.textTheme.titleMedium?.fontSize ?? 16) * 0.9, + ), + ), + const SizedBox(height: 6), + // Author row: avatar + Name + Row( + children: [ + ProfileAvatar(profile: author, radius: 9), + const SizedBox(width: 5), + Expanded( + child: Text( + author?.nameOrNpub ?? '', + style: profileStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + // Horizontal row of app icons + _AppIconsRow(apps: previewApps, totalApps: totalApps), + ], + ), + ), + ); + } +} + +/// Horizontal row of app icons for stack preview +class _AppIconsRow extends StatelessWidget { + const _AppIconsRow({required this.apps, required this.totalApps}); + + final List apps; + final int totalApps; + + @override + Widget build(BuildContext context) { + final hasMore = totalApps > 4; + final extraCount = totalApps - 3; + + return Row( + children: List.generate(4, (index) { + // Show "+X" indicator in the 4th slot if there are more than 4 apps + if (hasMore && index == 3) { + return Expanded( + child: Padding( + padding: const EdgeInsets.all(2), + child: AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest + .withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Text( + '+$extraCount', + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ), + ); + } + if (index < apps.length) { + return Expanded(child: _AppIconTile(app: apps[index])); + } + // Empty placeholder for missing apps + return Expanded( + child: Padding( + padding: const EdgeInsets.all(2), + child: AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ), + ); + }), + ); + } +} + +/// Individual app icon tile for the horizontal row +class _AppIconTile extends StatelessWidget { + const _AppIconTile({required this.app}); + + final App app; + + @override + Widget build(BuildContext context) { + final iconUrl = firstValidHttpUrl(app.icons); + + return Padding( + padding: const EdgeInsets.all(2), + child: AspectRatio( + aspectRatio: 1, + child: Opacity( + opacity: 0.87, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: iconUrl != null + ? CachedNetworkImage( + imageUrl: iconUrl, + fit: BoxFit.cover, + fadeInDuration: const Duration(milliseconds: 200), + placeholder: (_, __) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + errorWidget: (_, __, ___) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Icon( + Icons.broken_image_outlined, + size: 16, + color: Colors.grey, + ), + ), + ) + : Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Icon( + Icons.apps_outlined, + size: 16, + color: Colors.grey, + ), + ), + ), + ), + ), + ); + } +} + +/// Text widget that only applies fade-out effect when text overflows +class _FadingText extends HookWidget { + const _FadingText(this.text, {required this.style}); + + final String text; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + // Measure if text would overflow + final textPainter = TextPainter( + text: TextSpan(text: text, style: style), + maxLines: 1, + textDirection: TextDirection.ltr, + )..layout(maxWidth: double.infinity); + + final isOverflowing = textPainter.width > constraints.maxWidth; + + final textWidget = Text( + text, + style: style, + maxLines: 1, + overflow: TextOverflow.clip, + ); + + if (!isOverflowing) { + return textWidget; + } + + // Apply fade effect only when overflowing + return ShaderMask( + shaderCallback: (bounds) { + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Theme.of(context).colorScheme.onSurface, + Theme.of(context).colorScheme.onSurface, + Theme.of(context).colorScheme.onSurface.withValues(alpha: 0), + ], + stops: const [0.0, 0.8, 1.0], + ).createShader(bounds); + }, + blendMode: BlendMode.dstIn, + child: textWidget, + ); + }, + ); + } +} diff --git a/lib/widgets/auth_widgets.dart b/lib/widgets/auth_widgets.dart new file mode 100644 index 0000000..98206e8 --- /dev/null +++ b/lib/widgets/auth_widgets.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:zapstore/constants/app_constants.dart'; +import 'package:zapstore/main.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/utils/debug_utils.dart'; + +/// Reusable sign-in prompt widget for dialogs and bottom sheets. +/// Displays a styled tappable message prompting the user to sign in. +class SignInPrompt extends HookConsumerWidget { + const SignInPrompt({super.key, required this.message}); + + /// Description of what signing in enables + final String message; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final pmState = ref.watch(packageManagerProvider); + final isAmberInstalled = pmState.installed.containsKey(kAmberPackageId); + final isLoading = useState(false); + + Future handleSignIn() async { + if (isLoading.value) return; + + if (!isAmberInstalled) { + context.showInfo( + 'Install Amber to sign in with your Nostr identity', + actions: [ + ( + 'Open Amber', + () async => context.push('/search/app/$kAmberNaddr'), + ), + ], + ); + } else { + isLoading.value = true; + try { + await ref.read(amberSignerProvider).signIn(); + onSignInSuccess(ref.read(refProvider)); + } catch (e) { + if (context.mounted) { + context.showError( + 'Sign-in failed', + description: + 'Amber could not complete the sign-in. Make sure Amber is installed and try again.\n\n$e', + ); + } + } finally { + if (context.mounted) { + isLoading.value = false; + } + } + } + } + + return Material( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: handleSignIn, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + if (isLoading.value) + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ) + else + Icon(Icons.login, size: 20, color: theme.colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ), + const SizedBox(width: 8), + Icon( + Icons.chevron_right, + size: 20, + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/author_container.dart b/lib/widgets/author_container.dart index bf5909d..1e9da8a 100644 --- a/lib/widgets/author_container.dart +++ b/lib/widgets/author_container.dart @@ -1,62 +1,80 @@ import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/widgets/rounded_image.dart'; +import 'package:models/models.dart'; +import 'common/profile_avatar.dart'; +import '../utils/extensions.dart'; +/// Author container widget showing "Published by [profile]" text with avatar +/// Based on the old AuthorContainer design pattern +/// Can be hidden for Zapstore-published apps (except main Zapstore app) class AuthorContainer extends StatelessWidget { - final User user; - final String beforeText; + final Profile profile; + final String? beforeText; final String? afterText; final bool oneLine; - final double size; + final double? size; + final App? app; // Optional app to check for Zapstore hiding logic + final VoidCallback? onTap; // Optional tap handler const AuthorContainer({ super.key, - required this.user, - this.beforeText = 'Signed by', + required this.profile, + this.beforeText, this.afterText, this.oneLine = true, - this.size = 14, + this.size, + this.app, + this.onTap, }); @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only(top: 4, bottom: 4), - child: Row( + // ignore: no_leading_underscores_for_local_identifiers + final _size = size ?? context.textTheme.bodyMedium!.fontSize!; + + // Hide "Published by Zapstore" for Zapstore-published apps (except main Zapstore app) + if (app != null && app!.isRelaySigned) { + return const SizedBox.shrink(); + } + + final baseStyle = context.textTheme.bodyMedium?.copyWith( + fontSize: _size, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ); + final boldStyle = baseStyle?.copyWith(fontWeight: FontWeight.w600); + + final rowWidget = Text.rich( + TextSpan( children: [ - RoundedImage(url: user.avatarUrl, size: oneLine ? (size * 1.5) : 46), - Gap(10), - if (oneLine) - Expanded( - child: Text( - '$beforeText ${user.nameOrNpub}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: size), + if (beforeText != null) + TextSpan(text: '$beforeText ', style: baseStyle), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 6), + child: SizedBox( + width: _size * 1.4, + height: _size * 1.4, + child: ProfileAvatar(profile: profile, radius: _size * 0.7), ), ), - if (!oneLine) - Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(beforeText), - Text( - user.nameOrNpub, - style: TextStyle(fontWeight: FontWeight.bold), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (afterText != null) - Text( - afterText!, - style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), - ), - ], - ) + ), + TextSpan(text: profile.nameOrNpub, style: boldStyle), + if (afterText != null) TextSpan(text: afterText, style: baseStyle), ], ), + softWrap: !oneLine, + overflow: oneLine ? TextOverflow.ellipsis : TextOverflow.visible, + maxLines: oneLine ? 1 : null, ); + + if (onTap != null) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding(padding: const EdgeInsets.all(4), child: rowWidget), + ); + } + + return rowWidget; } } diff --git a/lib/widgets/bookmark_widgets.dart b/lib/widgets/bookmark_widgets.dart new file mode 100644 index 0000000..769f7d5 --- /dev/null +++ b/lib/widgets/bookmark_widgets.dart @@ -0,0 +1,694 @@ +import 'dart:convert'; + +import 'package:async_button_builder/async_button_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/auth_widgets.dart'; +import 'package:zapstore/widgets/common/base_dialog.dart'; + +/// Dialog for saving an app privately (encrypted) +class SaveAppDialog extends HookConsumerWidget { + const SaveAppDialog({ + super.key, + required this.app, + required this.isPrivatelySaved, + }); + + final App app; + final bool isPrivatelySaved; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isSignedIn = ref.watch(Signer.activePubkeyProvider) != null; + + return BaseDialog( + titleIcon: const Icon(Icons.bookmark), + title: Text( + 'Save App Privately', + style: Theme.of(context).textTheme.headlineSmall, + ), + content: BaseDialogContent( + children: [ + const SizedBox(height: 8), + Text( + 'Encrypted and only visible to you. Save apps to find them easily later.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + if (!isSignedIn) ...[ + const SizedBox(height: 16), + const SignInPrompt(message: 'Sign in to save apps privately.'), + ], + ], + ), + actions: [ + BaseDialogAction( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + AsyncButtonBuilder( + onPressed: () => + _togglePrivateSave(context, ref, app, isPrivatelySaved), + builder: (context, child, callback, buttonState) { + return FilledButton.icon( + onPressed: !isSignedIn + ? null + : buttonState.maybeWhen( + loading: () => null, + orElse: () => callback, + ), + icon: buttonState.maybeWhen( + loading: () => const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + orElse: () => Icon( + isPrivatelySaved ? Icons.bookmark_remove : Icons.bookmark_add, + size: 18, + ), + ), + label: Text(isPrivatelySaved ? 'Remove' : 'Save'), + ); + }, + child: const SizedBox.shrink(), + ), + ], + ); + } + + Future _togglePrivateSave( + BuildContext context, + WidgetRef ref, + App app, + bool isCurrentlySaved, + ) async { + try { + final signer = ref.read(Signer.activeSignerProvider); + final signedInPubkey = ref.read(Signer.activePubkeyProvider); + + if (signer == null || signedInPubkey == null) { + if (context.mounted) { + context.showError( + 'Sign in required', + description: 'You need to sign in to save apps privately.', + ); + } + return; + } + + // Query for existing stack + final existingStackState = await ref.storage.query( + RequestFilter( + authors: {signedInPubkey}, + tags: { + '#d': {kAppBookmarksIdentifier}, + }, + ).toRequest(), + source: const LocalSource(), + ); + final existingStack = existingStackState.firstOrNull; + + // Get existing app IDs by decrypting if stack exists + List existingAppIds = []; + if (existingStack != null) { + try { + final decryptedContent = await signer.nip44Decrypt( + existingStack.content, + signedInPubkey, + ); + existingAppIds = (jsonDecode(decryptedContent) as List) + .cast(); + } catch (e) { + if (context.mounted) { + context.showError( + 'Could not read existing saved apps', + description: + 'Your previous saved apps could not be decrypted. Starting fresh.\n\n$e', + ); + } + } + } + + // Modify the list + final appAddressableId = + '${app.event.kind}:${app.pubkey}:${app.identifier}'; + + if (isCurrentlySaved) { + existingAppIds.remove(appAddressableId); + } else { + if (!existingAppIds.contains(appAddressableId)) { + existingAppIds.add(appAddressableId); + } + } + + // Create new partial stack with updated list + final partialStack = PartialAppStack.withEncryptedApps( + name: 'Saved Apps', + identifier: kAppBookmarksIdentifier, + apps: existingAppIds, + ); + + // Sign (encrypts the content) + final signedStack = await partialStack.signWith(signer); + + // Save to local storage and publish to relays + await ref.storage.save({signedStack}); + ref.storage.publish({ + signedStack, + }, source: RemoteSource(relays: 'social')); + + if (context.mounted) { + Navigator.pop(context); + context.showInfo( + isCurrentlySaved ? 'App removed from saved' : 'App saved privately', + ); + } + } catch (e) { + if (context.mounted) { + context.showError('Failed to update bookmark', description: '$e'); + } + } + } +} + +/// Dialog for managing app stacks (public collections) +class AddToStackDialog extends HookConsumerWidget { + const AddToStackDialog({super.key, required this.app}); + + final App app; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + if (signedInPubkey == null) { + return _AddToStackDialogSignedOut(app: app); + } + return _AddToStackDialogSignedIn(app: app, signedInPubkey: signedInPubkey); + } +} + +class _AddToStackDialogSignedOut extends StatelessWidget { + const _AddToStackDialogSignedOut({required this.app}); + + final App app; + + @override + Widget build(BuildContext context) { + return BaseDialog( + titleIcon: const Icon(Icons.apps), + title: Text( + 'Add to App Stacks', + style: Theme.of(context).textTheme.headlineSmall, + ), + content: BaseDialogContent( + children: [ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.info_outline, + size: 18, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Add or remove ${app.name} from public app stacks that you share with others.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + const SignInPrompt( + message: 'Sign in to create and manage your public app stacks.', + ), + ], + ), + actions: [ + BaseDialogAction( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton.icon( + onPressed: null, + icon: const Icon(Icons.save, size: 18), + label: const Text('Save'), + ), + ], + ); + } +} + +class _AddToStackDialogSignedIn extends HookConsumerWidget { + const _AddToStackDialogSignedIn({ + required this.app, + required this.signedInPubkey, + }); + + final App app; + final String signedInPubkey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final newStackNameController = useTextEditingController(); + + final publicStacksState = ref.watch( + query( + authors: {signedInPubkey}, + and: (stack) => {stack.apps}, + source: const LocalAndRemoteSource(relays: 'social', stream: false), + andSource: const LocalSource(), + subscriptionPrefix: 'user-stacks-dialog', + // Filter at query level: exclude saved-apps stack and stacks with no app references + schemaFilter: (event) { + final tags = event['tags'] as List?; + if (tags == null) return false; + // Check d tag is not the bookmarks identifier + final dTag = tags.firstWhere( + (t) => t is List && t.isNotEmpty && t[0] == 'd', + orElse: () => null, + ); + if (dTag != null && dTag[1] == kAppBookmarksIdentifier) return false; + // Check has at least one 'a' tag (app reference) + final hasAppRef = tags.any( + (t) => t is List && t.isNotEmpty && t[0] == 'a', + ); + return hasAppRef; + }, + ), + ); + + final publicStacks = publicStacksState.models.toList(); + + // Check which stacks contain this app + final selectedCollections = useState>({}); + // Track new stacks with their names (identifier -> name) + final newStackNames = useState>({}); + + useEffect(() { + final selected = {}; + for (final stack in publicStacks) { + if (stack.apps.toList().any((appModel) => appModel.id == app.id)) { + selected.add(stack.identifier); + } + } + selectedCollections.value = selected; + return null; + }, [app.id, publicStacks.length]); + + // Helper to slugify name to identifier + String slugify(String text) { + return text + .toLowerCase() + .trim() + .replaceAll(RegExp(r'[^\w\s-]'), '') + .replaceAll(RegExp(r'\s+'), '-') + .replaceAll(RegExp(r'-+'), '-'); + } + + return BaseDialog( + titleIcon: const Icon(Icons.apps), + title: Text( + 'Add to App Stacks', + style: Theme.of(context).textTheme.headlineSmall, + ), + content: BaseDialogContent( + children: [ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.info_outline, + size: 18, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Add or remove ${app.name} from public app stacks that you share with others.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Show existing stacks and newly created stacks as selectable chips + if (publicStacks.isNotEmpty || newStackNames.value.isNotEmpty) ...[ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + // Existing stacks + ...publicStacks.map((stack) { + final isSelected = selectedCollections.value.contains( + stack.identifier, + ); + final displayName = stack.name ?? stack.identifier; + return FilterChip( + label: Text(displayName), + selected: isSelected, + onSelected: (selected) { + if (selected) { + selectedCollections.value = { + ...selectedCollections.value, + stack.identifier, + }; + } else { + selectedCollections.value = { + ...selectedCollections.value, + }..remove(stack.identifier); + } + }, + backgroundColor: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.surfaceContainerHighest, + selectedColor: Theme.of(context).colorScheme.primary, + labelStyle: TextStyle( + color: isSelected + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurface, + fontSize: 13, + ), + labelPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 0, + ), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 6, + ), + side: BorderSide.none, + ); + }), + // Newly created stacks not yet in publicStacks + ...newStackNames.value.entries + .where( + (entry) => !publicStacks.any( + (stack) => stack.identifier == entry.key, + ), + ) + .map((entry) { + final identifier = entry.key; + final name = entry.value; + final isSelected = selectedCollections.value.contains( + identifier, + ); + return FilterChip( + label: Text(name), + selected: isSelected, + onSelected: (selected) { + if (selected) { + selectedCollections.value = { + ...selectedCollections.value, + identifier, + }; + } else { + selectedCollections.value = { + ...selectedCollections.value, + }..remove(identifier); + // Also remove from newStackNames if deselected + final updatedNames = Map.from( + newStackNames.value, + )..remove(identifier); + newStackNames.value = updatedNames; + } + }, + backgroundColor: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + selectedColor: Theme.of(context).colorScheme.primary, + labelStyle: TextStyle( + color: isSelected + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurface, + fontSize: 13, + ), + labelPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 0, + ), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 6, + ), + side: BorderSide.none, + ); + }), + ], + ), + const SizedBox(height: 16), + ], + + // Input for new stack + TextField( + controller: newStackNameController, + maxLength: 20, + decoration: const InputDecoration( + labelText: 'Add to new app stack', + hintText: 'e.g., Favorite Apps', + border: OutlineInputBorder(), + ), + onSubmitted: (text) { + final name = text.trim(); + final identifier = slugify(name); + if (name.isNotEmpty && + identifier.isNotEmpty && + !selectedCollections.value.contains(identifier)) { + selectedCollections.value = { + ...selectedCollections.value, + identifier, + }; + newStackNames.value = { + ...newStackNames.value, + identifier: name, + }; + newStackNameController.clear(); + } + }, + ), + ], + ), + actions: [ + BaseDialogAction( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + AsyncButtonBuilder( + onPressed: () async { + // Check if there's a pending new stack name in the text field + final pendingStackName = newStackNameController.text.trim(); + if (pendingStackName.isNotEmpty) { + final identifier = slugify(pendingStackName); + if (identifier.isNotEmpty && + !selectedCollections.value.contains(identifier)) { + // Add the pending stack to the collections + selectedCollections.value = { + ...selectedCollections.value, + identifier, + }; + newStackNames.value = { + ...newStackNames.value, + identifier: pendingStackName, + }; + } + } + + // Now save with the updated collections + await _savePublicCollections( + context, + ref, + app, + publicStacks, + selectedCollections.value, + newStackNames.value, + ); + }, + builder: (context, child, callback, buttonState) { + return FilledButton.icon( + onPressed: buttonState.maybeWhen( + loading: () => null, + orElse: () => callback, + ), + icon: buttonState.maybeWhen( + loading: () => const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + orElse: () => const Icon(Icons.save, size: 18), + ), + label: const Text('Save'), + ); + }, + child: const SizedBox.shrink(), + ), + ], + ); + } + + Future _savePublicCollections( + BuildContext context, + WidgetRef ref, + App app, + List existingStacks, + Set selectedCollectionIds, + Map newStackNames, + ) async { + try { + final signer = ref.read(Signer.activeSignerProvider); + if (signer == null) { + if (context.mounted) { + context.showError( + 'Sign in required', + description: 'You need to sign in to manage app stacks.', + ); + } + return; + } + + // Get current state of which collections contain this app + final currentCollectionsWithApp = {}; + for (final stack in existingStacks) { + // Get raw app IDs from event tags + final appIds = stack.event.getTagSetValues('a'); + if (appIds.contains(app.id)) { + currentCollectionsWithApp.add(stack.identifier); + } + } + + // Collections to remove from + final collectionsToRemoveFrom = currentCollectionsWithApp.difference( + selectedCollectionIds, + ); + + // Update or create collections + for (final collectionId in selectedCollectionIds) { + final existingStack = existingStacks + .where((stack) => stack.identifier == collectionId) + .firstOrNull; + + // Use the provided name from newStackNames, or existing stack name, or identifier + final stackName = + newStackNames[collectionId] ?? existingStack?.name ?? collectionId; + + // Get existing app IDs as a list to preserve order + final existingAppIds = + existingStack?.event.getTagSetValues('a').toList() ?? []; + + // Check if app already exists in the stack + final appAlreadyExists = existingAppIds.contains(app.id); + + // Only update if the app is not already in the stack + if (!appAlreadyExists) { + final partialStack = PartialAppStack( + name: stackName, + identifier: collectionId, + ); + + // Add existing apps in order + for (final appId in existingAppIds) { + partialStack.addApp(appId); + } + + // Add this app at the end + partialStack.addApp(app.id); + + final signedStack = await partialStack.signWith(signer); + await ref.storage.save({signedStack}); + await ref.storage.publish({ + signedStack, + }, source: RemoteSource(relays: 'social')); + } + } + + // Remove from deselected collections + for (final collectionId in collectionsToRemoveFrom) { + final existingStack = existingStacks + .where((stack) => stack.identifier == collectionId) + .firstOrNull; + if (existingStack != null) { + final partialStack = PartialAppStack( + name: existingStack.name ?? collectionId, + identifier: collectionId, + ); + + // Re-add all apps except the one we're removing, preserving order + // Get raw app IDs from event tags as a list to preserve order + final existingAppIds = existingStack.event + .getTagSetValues('a') + .toList(); + for (final appId in existingAppIds) { + if (appId != app.id) { + partialStack.addApp(appId); + } + } + + final signedStack = await partialStack.signWith(signer); + await ref.storage.save({signedStack}); + await ref.storage.publish({ + signedStack, + }, source: RemoteSource(relays: 'social')); + } + } + + if (context.mounted) { + if (selectedCollectionIds.isEmpty) { + context.showInfo('Removed from all app stacks'); + } else { + context.showInfo('App stacks updated'); + } + Navigator.pop(context); + } + } catch (e) { + if (context.mounted) { + context.showError('Failed to save', description: '$e'); + } + } + } +} diff --git a/lib/widgets/breathing_logo.dart b/lib/widgets/breathing_logo.dart new file mode 100644 index 0000000..3a74fa6 --- /dev/null +++ b/lib/widgets/breathing_logo.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// An animated breathing logo widget used for loading states +/// Smoothly scales the Zapstore logo in a "breathe in, breathe out" motion +class BreathingLogo extends HookWidget { + final double size; + const BreathingLogo({super.key, this.size = 200}); + + @override + Widget build(BuildContext context) { + final controller = useAnimationController( + duration: const Duration(milliseconds: 1500), + )..repeat(reverse: true); + + final scaleAnimation = useMemoized( + () => Tween(begin: 0.85, end: 1.0).animate( + CurvedAnimation( + parent: controller, + curve: Curves.easeInOut, + ), + ), + [controller], + ); + + return Center( + child: AnimatedBuilder( + animation: scaleAnimation, + builder: (context, child) { + return Transform.scale( + scale: scaleAnimation.value, + child: child, + ); + }, + child: Image.asset( + 'assets/images/logo.png', + height: size, + width: size, + ), + ), + ); + } +} + diff --git a/lib/widgets/comments_section.dart b/lib/widgets/comments_section.dart new file mode 100644 index 0000000..01edc93 --- /dev/null +++ b/lib/widgets/comments_section.dart @@ -0,0 +1,651 @@ +import 'package:async_button_builder/async_button_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/theme.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/auth_widgets.dart'; +import 'package:zapstore/widgets/common/profile_avatar.dart'; +import 'package:zapstore/widgets/pill_widget.dart'; + +/// Comments section for App detail screen +class CommentsSection extends HookConsumerWidget { + const CommentsSection({super.key, required this.app, this.fileMetadata}); + + final App app; + final FileMetadata? fileMetadata; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (fileMetadata == null) { + return const SizedBox.shrink(); + } + + final commentsState = ref.watch( + query( + tags: { + '#A': {app.id}, + }, + source: LocalAndRemoteSource(stream: true, relays: 'social'), + subscriptionPrefix: 'app-comments', + ), + ); + + // Extract comments and error state + final List comments = switch (commentsState) { + StorageData(:final models) => models, + _ => [], + }; + final errorException = switch (commentsState) { + StorageError(:final exception) => exception, + _ => null, + }; + + return _CommentsSectionLayout( + comments: comments, + errorException: errorException, + addCommentButton: _AddCommentButton(fileMetadata: fileMetadata!), + ); + } +} + +/// Comments section for AppStack/Stack detail screen +class StackCommentsSection extends HookConsumerWidget { + const StackCommentsSection({super.key, required this.stack}); + + final AppStack stack; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final commentsState = ref.watch( + query( + tags: { + '#A': {stack.id}, + }, + source: LocalAndRemoteSource(stream: true, relays: 'social'), + subscriptionPrefix: 'stack-comments', + ), + ); + + // Extract comments and error state + final List comments = switch (commentsState) { + StorageData(:final models) => models, + _ => [], + }; + final errorException = switch (commentsState) { + StorageError(:final exception) => exception, + _ => null, + }; + + return _CommentsSectionLayout( + comments: comments, + errorException: errorException, + addCommentButton: _AddStackCommentButton(stack: stack), + ); + } +} + +/// Shared layout for both App and Stack comments +class _CommentsSectionLayout extends StatelessWidget { + const _CommentsSectionLayout({ + required this.comments, + required this.errorException, + required this.addCommentButton, + }); + + final List comments; + final Object? errorException; + final Widget addCommentButton; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header - always visible + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Comments', style: Theme.of(context).textTheme.titleLarge), + if (comments.isNotEmpty) + _CommentCountBadge(count: comments.length), + ], + ), + const SizedBox(height: 12), + // Add Comment button - always visible + addCommentButton, + // Error message if any + if (errorException != null) ...[ + const SizedBox(height: 16), + _buildCommentsError(context, errorException!), + ], + // Comments list - only when there are comments + if (comments.isNotEmpty) ...[ + const SizedBox(height: 16), + ...(comments.toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt))) + .map( + (comment) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _CommentCard(comment: comment), + ), + ), + ], + ], + ), + ); + } + + Widget _buildCommentsError(BuildContext context, Object exception) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.onErrorContainer, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Failed to load comments', + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ); + } +} + +class _CommentCountBadge extends StatelessWidget { + const _CommentCountBadge({required this.count}); + + final int count; + + @override + Widget build(BuildContext context) { + final display = count > 99 ? '99+' : '$count'; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + display, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.grey.shade700, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _CommentCard extends HookConsumerWidget { + const _CommentCard({required this.comment}); + + final Comment comment; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Query author profile with caching + // Query author profile (comment.event.pubkey is always present) + final authorState = ref.watch( + query( + authors: {comment.event.pubkey}, + source: const LocalAndRemoteSource( + relays: {'social', 'vertex'}, + stream: false, + cachedFor: Duration(hours: 2), + ), + ), + ); + final author = switch (authorState) { + StorageData(:final models) => models.firstOrNull, + _ => null, + }; + // Extract version from v tag (per NIP-22 guidance) + final version = comment.event.getFirstTagValue('v'); + + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ProfileAvatar(profile: author, radius: 16), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + children: [ + Text( + author?.nameOrNpub ?? '', + style: context.textTheme.titleSmall?.copyWith( + fontSize: + (context + .textTheme + .titleSmall + ?.fontSize ?? + 14) * + 0.82, + ), + ), + if (version != null) ...[ + Text( + 'on', + style: context.textTheme.bodySmall + ?.copyWith(color: Colors.grey[600]), + ), + PillWidget( + TextSpan(text: version), + color: AppColors.darkPillBackground, + size: 8, + ), + ], + ], + ), + ), + Text( + DateFormat('MMM d, y').format(comment.createdAt), + style: context.textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + comment.content, + style: context.textTheme.bodyMedium, + ), + ], + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _AddCommentButton extends ConsumerWidget { + const _AddCommentButton({required this.fileMetadata}); + + final FileMetadata fileMetadata; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () => _showCommentComposer(context), + icon: const Icon(Icons.add_comment), + label: const Text('Add Comment'), + style: FilledButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + foregroundColor: Theme.of(context).colorScheme.onSurface, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ); + } + + void _showCommentComposer(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _CommentComposer(fileMetadata: fileMetadata), + ); + } +} + +class _AddStackCommentButton extends ConsumerWidget { + const _AddStackCommentButton({required this.stack}); + + final AppStack stack; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () => _showStackCommentComposer(context), + icon: const Icon(Icons.add_comment), + label: const Text('Add Comment'), + style: FilledButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + foregroundColor: Theme.of(context).colorScheme.onSurface, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ); + } + + void _showStackCommentComposer(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _StackCommentComposer(stack: stack), + ); + } +} + +class _CommentComposer extends HookConsumerWidget { + const _CommentComposer({required this.fileMetadata}); + + final FileMetadata fileMetadata; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isSignedIn = ref.watch(Signer.activePubkeyProvider) != null; + final textController = useTextEditingController(); + // Rebuild on text changes so the Post button enables/disables correctly + useListenable(textController); + + final app = fileMetadata.release.value?.app.value; + final installedVersion = app?.installedPackage?.version; + final versionToComment = installedVersion ?? fileMetadata.version; + final appName = app?.name ?? 'this app'; + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Comment on $versionToComment', + style: context.textTheme.titleMedium, + ), + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 16), + if (!isSignedIn) ...[ + const SignInPrompt( + message: + 'Sign in to share your thoughts and help others discover great apps.', + ), + ] else ...[ + TextField( + controller: textController, + decoration: InputDecoration( + hintText: + 'Share your thoughts about $appName $versionToComment...', + border: const OutlineInputBorder(), + ), + maxLines: 4, + autofocus: true, + ), + ], + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: AsyncButtonBuilder( + child: const Text('Post Comment'), + onPressed: () => + _publishComment(ref, textController.text, context), + builder: (context, child, callback, buttonState) { + return FilledButton( + onPressed: !isSignedIn + ? null + : buttonState.maybeWhen( + loading: () => null, + orElse: () => textController.text.trim().isEmpty + ? null + : callback, + ), + child: buttonState.maybeWhen( + loading: () => const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ), + orElse: () => child, + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + Future _publishComment( + WidgetRef ref, + String content, + BuildContext context, + ) async { + if (content.trim().isEmpty) return; + + try { + final signer = ref.read(Signer.activeSignerProvider); + if (signer == null) { + if (context.mounted) { + context.showError( + 'Sign in required', + description: 'You need to sign in with Amber to post comments.', + ); + } + return; + } + + final app = fileMetadata.release.value?.app.value; + if (app == null) { + if (context.mounted) { + context.showError( + 'Unable to comment', + description: 'App information is not available. Try again later.', + ); + } + return; + } + + // Get the version to comment on (installed version or latest release) + final installedVersion = app.installedPackage?.version; + final versionToComment = installedVersion ?? fileMetadata.version; + + final comment = PartialComment( + content: content.trim(), + rootModel: app, + // No parentModel for root comments - only A/K/P tags, no e/k/p + ); + + // Add v tag for version (per NIP-22 guidance, not d tag) + comment.event.addTagValue('v', versionToComment); + + final signedComment = await comment.signWith(signer); + + await signedComment.save(); + await signedComment.publish(source: RemoteSource(relays: 'social')); + + if (context.mounted) { + Navigator.pop(context); + } + } catch (e) { + if (context.mounted) { + context.showError('Failed to post comment', description: '$e'); + } + } + } +} + +/// Comment composer for Stack comments +class _StackCommentComposer extends HookConsumerWidget { + const _StackCommentComposer({required this.stack}); + + final AppStack stack; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isSignedIn = ref.watch(Signer.activePubkeyProvider) != null; + final textController = useTextEditingController(); + useListenable(textController); + + final stackName = stack.name ?? stack.identifier; + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Comment on $stackName', + style: context.textTheme.titleMedium, + ), + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 16), + if (!isSignedIn) ...[ + const SignInPrompt( + message: 'Sign in to share your thoughts about this stack.', + ), + ] else ...[ + TextField( + controller: textController, + decoration: InputDecoration( + hintText: 'Share your thoughts about $stackName...', + border: const OutlineInputBorder(), + ), + maxLines: 4, + autofocus: true, + ), + ], + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: AsyncButtonBuilder( + child: const Text('Post Comment'), + onPressed: () => + _publishComment(ref, textController.text, context), + builder: (context, child, callback, buttonState) { + return FilledButton( + onPressed: !isSignedIn + ? null + : buttonState.maybeWhen( + loading: () => null, + orElse: () => textController.text.trim().isEmpty + ? null + : callback, + ), + child: buttonState.maybeWhen( + loading: () => const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ), + orElse: () => child, + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + Future _publishComment( + WidgetRef ref, + String content, + BuildContext context, + ) async { + if (content.trim().isEmpty) return; + + try { + final signer = ref.read(Signer.activeSignerProvider); + if (signer == null) { + if (context.mounted) { + context.showError( + 'Sign in required', + description: 'You need to sign in with Amber to post comments.', + ); + } + return; + } + + final comment = PartialComment(content: content.trim(), rootModel: stack); + + final signedComment = await comment.signWith(signer); + + await signedComment.save(); + await signedComment.publish(source: RemoteSource(relays: 'social')); + + if (context.mounted) { + Navigator.pop(context); + } + } catch (e) { + if (context.mounted) { + context.showError('Failed to post comment', description: '$e'); + } + } + } +} diff --git a/lib/widgets/common/badges.dart b/lib/widgets/common/badges.dart new file mode 100644 index 0000000..7a26b25 --- /dev/null +++ b/lib/widgets/common/badges.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +/// Generic pill-style badge container used for counts or small status indicators. +class BadgePill extends StatelessWidget { + const BadgePill({ + super.key, + required this.child, + this.color, + this.padding = const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + this.constraints = const BoxConstraints(minWidth: 18, minHeight: 18), + this.borderRadius, + }); + + final Widget child; + final Color? color; + final EdgeInsetsGeometry padding; + final BoxConstraints constraints; + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + return Container( + padding: padding, + constraints: constraints, + decoration: BoxDecoration( + color: color ?? Colors.red.withValues(alpha: 0.4), + borderRadius: borderRadius ?? BorderRadius.circular(9), + ), + child: Center(child: child), + ); + } +} + +/// Count badge that renders `99+` when count > 99. +class CountBadge extends StatelessWidget { + const CountBadge({ + super.key, + required this.count, + this.color, + this.textStyle, + }); + + final int count; + final Color? color; + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + final display = count > 99 ? '99+' : count.toString(); + + return BadgePill( + color: color, + child: Text( + display, + style: + textStyle ?? + const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ); + } +} diff --git a/lib/widgets/common/base_dialog.dart b/lib/widgets/common/base_dialog.dart new file mode 100644 index 0000000..9857df9 --- /dev/null +++ b/lib/widgets/common/base_dialog.dart @@ -0,0 +1,152 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; + +/// Helper function to show a dialog with consistent blur background +Future showBaseDialog({ + required BuildContext context, + required Widget dialog, +}) { + return showDialog( + context: context, + barrierColor: Colors.black.withValues(alpha: 0.25), + builder: (context) => dialog, + ); +} + +class BaseDialog extends StatelessWidget { + const BaseDialog({ + super.key, + required this.title, + required this.content, + required this.actions, + this.titleIcon, + this.titleIconColor, + this.maxWidth = 560, + this.applyFontSizeFactor = false, + this.fontSizeFactor = 1.16, + }); + + final Widget title; + final Widget content; + final List actions; + final Widget? titleIcon; + final Color? titleIconColor; + final double maxWidth; + final bool applyFontSizeFactor; + final double fontSizeFactor; + + @override + Widget build(BuildContext context) { + Widget dialogContent = AlertDialog( + elevation: 10, + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + title: titleIcon != null + ? Row( + children: [ + DefaultTextStyle( + style: TextStyle(color: titleIconColor, fontSize: 16), + child: titleIcon!, + ), + const SizedBox(width: 8), + Expanded(child: title), + ], + ) + : title, + content: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: SingleChildScrollView(child: content), + ), + actions: actions, + ); + + if (applyFontSizeFactor) { + dialogContent = Theme( + data: Theme.of(context).copyWith( + textTheme: Theme.of( + context, + ).textTheme.apply(fontSizeFactor: fontSizeFactor), + ), + child: dialogContent, + ); + } + + // Wrap with blur effect + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), + child: dialogContent, + ), + ); + } +} + +class BaseDialogTitle extends StatelessWidget { + const BaseDialogTitle(this.text, {super.key, this.style}); + + final String text; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + return Text( + text, + style: style ?? const TextStyle(fontWeight: FontWeight.bold), + ); + } +} + +class BaseDialogContent extends StatelessWidget { + const BaseDialogContent({ + super.key, + required this.children, + this.mainAxisSize = MainAxisSize.min, + this.crossAxisAlignment = CrossAxisAlignment.start, + this.padding, + }); + + final List children; + final MainAxisSize mainAxisSize; + final CrossAxisAlignment crossAxisAlignment; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + Widget content = Column( + mainAxisSize: mainAxisSize, + crossAxisAlignment: crossAxisAlignment, + children: children, + ); + + if (padding != null) { + content = Padding(padding: padding!, child: content); + } + + return content; + } +} + +class BaseDialogAction extends StatelessWidget { + const BaseDialogAction({ + super.key, + required this.onPressed, + required this.child, + this.isPrimary = false, + this.padding, + }); + + final VoidCallback? onPressed; + final Widget child; + final bool isPrimary; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + Widget button = TextButton(onPressed: onPressed, child: child); + + if (padding != null) { + button = Padding(padding: padding!, child: button); + } + + return button; + } +} diff --git a/lib/widgets/common/engagement_row.dart b/lib/widgets/common/engagement_row.dart new file mode 100644 index 0000000..7df326e --- /dev/null +++ b/lib/widgets/common/engagement_row.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; + +class EngagementRow extends StatelessWidget { + final int likesCount; + final int repostsCount; + final int zapsCount; + final int zapsSatAmount; + final int? commentsCount; + final VoidCallback? onLike; + final VoidCallback? onRepost; + final VoidCallback? onZap; + final VoidCallback? onComment; + final bool isLiked; + final bool isReposted; + final bool isZapped; + final bool isLiking; + final bool isReposting; + final bool isZapping; + + const EngagementRow({ + super.key, + required this.likesCount, + required this.repostsCount, + required this.zapsCount, + required this.zapsSatAmount, + this.commentsCount, + this.onLike, + this.onRepost, + this.onZap, + this.onComment, + this.isLiked = false, + this.isReposted = false, + this.isZapped = false, + this.isLiking = false, + this.isReposting = false, + this.isZapping = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Row( + children: [ + // Comments (first now) + if (commentsCount != null) ...[ + _EngagementItem( + icon: Icons.mode_comment_outlined, + activeIcon: Icons.mode_comment, + count: commentsCount!, + onTap: onComment, + isActive: false, + isLoading: false, + activeColor: colorScheme.primary, + theme: theme, + ), + const SizedBox(width: 24), + ], + + // Likes + _EngagementItem( + icon: Icons.favorite_border, + activeIcon: Icons.favorite, + count: likesCount, + onTap: onLike, + isActive: isLiked, + isLoading: isLiking, + activeColor: const Color(0xFFE91E63), // Material Pink + theme: theme, + ), + + const SizedBox(width: 24), + + // Reposts + _EngagementItem( + icon: Icons.repeat, + activeIcon: Icons.repeat, + count: repostsCount, + onTap: onRepost, + isActive: isReposted, + isLoading: isReposting, + activeColor: const Color(0xFF4CAF50), // Material Green + theme: theme, + ), + + const SizedBox(width: 24), + + // Zaps + _ZapItem( + count: zapsCount, + satAmount: zapsSatAmount, + onTap: onZap, + isActive: isZapped, + isLoading: isZapping, + theme: theme, + ), + ], + ); + } +} + +class _EngagementItem extends StatelessWidget { + final IconData icon; + final IconData? activeIcon; + final int count; + final VoidCallback? onTap; + final bool isActive; + final bool isLoading; + final Color activeColor; + final ThemeData theme; + + const _EngagementItem({ + required this.icon, + this.activeIcon, + required this.count, + required this.onTap, + required this.isActive, + required this.isLoading, + required this.activeColor, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + final color = isActive + ? activeColor + : theme.colorScheme.onSurface.withValues(alpha: 0.6); + + final displayIcon = isActive && activeIcon != null ? activeIcon! : icon; + + return InkWell( + onTap: isLoading ? null : onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + isLoading + ? SizedBox( + width: 17, + height: 17, + child: CircularProgressIndicator( + strokeWidth: 2, + color: color, + ), + ) + : Icon(displayIcon, size: 17, color: color), + if (count > 0) ...[ + const SizedBox(width: 4), + Text( + _formatCount(count), + style: theme.textTheme.labelSmall?.copyWith( + color: color, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ], + ), + ), + ); + } + + String _formatCount(int count) { + if (count >= 1000000) { + return '${(count / 1000000).toStringAsFixed(1)}M'; + } else if (count >= 1000) { + return '${(count / 1000).toStringAsFixed(1)}K'; + } + return count.toString(); + } +} + +class _ZapItem extends StatelessWidget { + final int count; + final int satAmount; + final VoidCallback? onTap; + final bool isActive; + final bool isLoading; + final ThemeData theme; + + const _ZapItem({ + required this.count, + required this.satAmount, + required this.onTap, + required this.isActive, + required this.isLoading, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + final color = isActive + ? const Color(0xFFFF9800) // Material Orange + : theme.colorScheme.onSurface.withValues(alpha: 0.6); + + return InkWell( + onTap: isLoading ? null : onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + isLoading + ? SizedBox( + width: 17, + height: 17, + child: CircularProgressIndicator( + strokeWidth: 2, + color: color, + ), + ) + : Icon(Icons.bolt, size: 17, color: color), + if (count > 0 || satAmount > 0) ...[ + const SizedBox(width: 4), + Text( + satAmount > 0 ? _formatSats(satAmount) : count.toString(), + style: theme.textTheme.labelSmall?.copyWith( + color: color, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ], + ), + ), + ); + } + + String _formatSats(int sats) { + if (sats >= 1000000) { + return '${(sats / 1000000).toStringAsFixed(1)}M'; + } else if (sats >= 1000) { + return '${(sats / 1000).toStringAsFixed(1)}K'; + } + return sats.toString(); + } +} diff --git a/lib/widgets/common/note_parser.dart b/lib/widgets/common/note_parser.dart new file mode 100644 index 0000000..62cc802 --- /dev/null +++ b/lib/widgets/common/note_parser.dart @@ -0,0 +1,835 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:any_link_preview/any_link_preview.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:zapstore/utils/url_utils.dart'; + +/// Enum for different media types +enum MediaType { image, video, audio, none } + +/// Helper function to launch URLs with robust error handling +Future _launchUrlSafely(String url, {String? context}) async { + try { + // Clean and validate the URL + String cleanUrl = url.trim(); + if (!cleanUrl.startsWith('http://') && !cleanUrl.startsWith('https://')) { + cleanUrl = 'https://$cleanUrl'; + } + + final uri = Uri.parse(cleanUrl); + + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + // Try alternative launch mode + try { + await launchUrl(uri, mode: LaunchMode.platformDefault); + } catch (e2) { + // Silently fail - both launch modes failed + } + } + } catch (e) { + // Silently handle URL launch errors + } +} + +/// A utility for parsing Nostr note content and replacing entities with custom widgets. +class NoteParser { + // Regex patterns for different content types + static final RegExp nip19Regex = RegExp( + r'(?:nostr:)?(npub|nsec|note|nprofile|nevent|naddr|nrelay)1[02-9ac-hj-np-z]+', + caseSensitive: false, + ); + + static final RegExp httpUrlPattern = RegExp( + r'https?://[^\s<>"\[\]{}|\\^`]+', + caseSensitive: false, + ); + + // Hashtag pattern - matches #word (letters, numbers, underscores) + static final RegExp _hashtagPattern = RegExp( + r'#[a-zA-Z0-9_]+', + caseSensitive: false, + ); + + // Image file extensions + static final Set _imageExtensions = { + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + 'bmp', + 'svg', + }; + + // Video file extensions + static final Set _videoExtensions = { + 'mp4', + 'webm', + 'avi', + 'mov', + 'mkv', + }; + + // Audio file extensions (for future use) + static final Set _audioExtensions = {'mp3', 'wav', 'ogg'}; + + /// Parses note content and returns a RichText widget with custom entity replacements. + /// + /// [context] - The build context for accessing theme + /// [content] - The note text content to parse + /// [onNostrEntity] - Optional callback for replacing NIP-19 entities (npub, note, etc.) + /// [onHttpUrl] - Optional callback for replacing HTTP URLs + /// [onMediaUrl] - Optional callback specifically for media URLs (images, videos, etc.) + /// [onHashtag] - Optional callback for replacing hashtags (#hashtag) + /// [onHashtagTap] - Optional callback for when a hashtag is tapped + /// [onProfileTap] - Optional callback for when a profile is tapped + /// [textStyle] - Default text style for regular text + /// [linkStyle] - Text style for unhandled links (when callback returns null) + static Widget parse( + BuildContext context, + String content, { + Widget? Function(String entity)? onNostrEntity, + Widget? Function(String httpUrl)? onHttpUrl, + Widget? Function(String mediaUrl)? onMediaUrl, + Widget? Function(String hashtag)? onHashtag, + void Function(String hashtag)? onHashtagTap, + void Function(String pubkey)? onProfileTap, + TextStyle? textStyle, + TextStyle? linkStyle, + }) { + if (content.isEmpty) { + return Text('', style: textStyle); + } + + final List spans = []; + final List<_EntityMatch> matches = []; + + // Find all NIP-19 entities + for (final match in nip19Regex.allMatches(content)) { + final entity = match.group(0)!; + final nip19Entity = entity.replaceFirst('nostr:', ''); + + matches.add( + _EntityMatch( + start: match.start, + end: match.end, + text: entity, + type: _EntityType.nip19, + cleanEntity: nip19Entity, + ), + ); + } + + // Find all HTTP URLs + for (final match in httpUrlPattern.allMatches(content)) { + final url = match.group(0)!; + final isMedia = _isMediaUrl(url); + + matches.add( + _EntityMatch( + start: match.start, + end: match.end, + text: url, + type: isMedia ? _EntityType.media : _EntityType.http, + cleanEntity: url, + ), + ); + } + + // Find all hashtags + for (final match in _hashtagPattern.allMatches(content)) { + final hashtag = match.group(0)!; + + matches.add( + _EntityMatch( + start: match.start, + end: match.end, + text: hashtag, + type: _EntityType.hashtag, + cleanEntity: hashtag.substring(1), // Remove the # symbol + ), + ); + } + + // Sort matches by position + matches.sort((a, b) => a.start.compareTo(b.start)); + + // Build spans + int currentPos = 0; + + for (final match in matches) { + // Add text before this match + if (match.start > currentPos) { + final textBefore = content.substring(currentPos, match.start); + spans.add(TextSpan(text: textBefore, style: textStyle)); + } + + // Handle the entity + Widget? replacement; + + switch (match.type) { + case _EntityType.nip19: + replacement = onNostrEntity?.call(match.cleanEntity); + break; + case _EntityType.media: + replacement = + onMediaUrl?.call(match.text) ?? onHttpUrl?.call(match.text); + break; + case _EntityType.http: + replacement = onHttpUrl?.call(match.text); + break; + case _EntityType.hashtag: + replacement = + onHashtag?.call(match.cleanEntity) ?? + HashtagWidget( + hashtag: match.cleanEntity, + colorPair: [ + Theme.of(context).colorScheme.primary, + Theme.of(context).colorScheme.primaryContainer, + ], + onTap: onHashtagTap != null + ? () => onHashtagTap(match.cleanEntity) + : null, + ); + break; + } + + if (replacement != null) { + spans.add( + WidgetSpan( + child: replacement, + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + ), + ); + } else { + // Fallback to styled text + final style = + linkStyle ?? + textStyle?.copyWith( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ); + spans.add(TextSpan(text: match.text, style: style)); + } + + currentPos = match.end; + } + + // Add remaining text + if (currentPos < content.length) { + final remainingText = content.substring(currentPos); + spans.add(TextSpan(text: remainingText, style: textStyle)); + } + + // If no entities found, return simple text + if (spans.isEmpty) { + return Text(content, style: textStyle); + } + + return Text.rich(TextSpan(children: spans)); + } + + /// Checks if a URL is likely a media URL and returns the media type + static MediaType _getMediaType(String url) { + try { + final uri = Uri.parse(url); + final path = uri.path.toLowerCase(); + final extension = path.split('.').last; + + if (_imageExtensions.contains(extension)) { + return MediaType.image; + } else if (_videoExtensions.contains(extension)) { + return MediaType.video; + } else if (_audioExtensions.contains(extension)) { + return MediaType.audio; + } else { + return MediaType.none; + } + } catch (e) { + return MediaType.none; + } + } + + /// Checks if a URL is likely a media URL based on file extension + static bool _isMediaUrl(String url) { + return _getMediaType(url) != MediaType.none; + } + + /// Validates if a string is a valid NIP-19 entity + static bool isValidNip19Entity(String entity) { + try { + Utils.decodeShareableIdentifier(entity); + return true; + } catch (e) { + return false; + } + } + + /// Extracts all NIP-19 entities from text + @protected + @visibleForTesting + static List extractNip19Entities(String content) { + return nip19Regex + .allMatches(content) + .map((match) => match.group(0)!.replaceFirst('nostr:', '')) + .where((entity) => isValidNip19Entity(entity)) + .toList(); + } + + /// Extracts all HTTP URLs from text + @protected + @visibleForTesting + static List extractHttpUrls(String content) { + return httpUrlPattern + .allMatches(content) + .map((match) => match.group(0)!) + .toList(); + } + + /// Extracts all media URLs from text + @protected + @visibleForTesting + static List extractMediaUrls(String content) { + return extractHttpUrls(content).where((url) => _isMediaUrl(url)).toList(); + } + + /// Extracts all hashtags from text (without the # symbol) + @protected + @visibleForTesting + static List extractHashtags(String content) { + return _hashtagPattern + .allMatches(content) + .map((match) => match.group(0)!.substring(1)) // Remove # symbol + .toList(); + } +} + +/// Internal class for tracking entity matches +class _EntityMatch { + final int start; + final int end; + final String text; + final _EntityType type; + final String cleanEntity; + + _EntityMatch({ + required this.start, + required this.end, + required this.text, + required this.type, + required this.cleanEntity, + }); +} + +/// Internal enum for entity types +enum _EntityType { nip19, http, media, hashtag } + +// Widgets + +class NostrEntityWidget extends StatelessWidget { + final String entity; + final List colorPair; + final void Function(String pubkey)? onProfileTap; + final void Function(String hashtag)? onHashtagTap; + + const NostrEntityWidget({ + super.key, + required this.entity, + required this.colorPair, + this.onProfileTap, + this.onHashtagTap, + }); + + @override + Widget build(BuildContext context) { + try { + final decoded = Utils.decodeShareableIdentifier(entity); + + return switch (decoded) { + ProfileData() => ProfileEntityWidget( + profileData: decoded, + colorPair: colorPair, + onProfileTap: onProfileTap, + ), + EventData() => EventEntityWidget( + eventData: decoded, + colorPair: colorPair, + ), + AddressData() => AddressEntityWidget( + addressData: decoded, + colorPair: colorPair, + ), + }; + } catch (e) { + return GenericNip19Widget(entity: entity, colorPair: colorPair); + } + } +} + +class ProfileEntityWidget extends ConsumerWidget { + final ProfileData profileData; + final List colorPair; + final void Function(String pubkey)? onProfileTap; + + const ProfileEntityWidget({ + super.key, + required this.profileData, + required this.colorPair, + this.onProfileTap, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final profileState = ref.watch( + query( + authors: {profileData.pubkey}, + source: const LocalAndRemoteSource( + relays: {'social', 'vertex'}, + cachedFor: Duration(hours: 2), + ), + ), + ); + + // Show animated npub while profile is being loaded + return switch (profileState) { + StorageLoading() => GestureDetector( + onTap: onProfileTap != null + ? () => onProfileTap!(profileData.pubkey) + : null, + child: _AnimatedLoadingChip( + text: 'npub1${profileData.pubkey.substring(0, 8)}...', + colorPair: colorPair, + ), + ), + StorageError() || StorageData(models: []) => GestureDetector( + onTap: onProfileTap != null + ? () => onProfileTap!(profileData.pubkey) + : null, + child: _AnimatedLoadingChip( + text: 'npub1${profileData.pubkey.substring(0, 8)}...', + colorPair: colorPair, + ), + ), + StorageData(:final models) => _buildProfileWidget(context, models.first), + }; + } + + Widget _buildProfileWidget(BuildContext context, Profile profile) { + final displayName = profile.nameOrNpub; + + return GestureDetector( + onTap: onProfileTap != null + ? () => onProfileTap!(profileData.pubkey) + : null, + child: DecoratedBox( + decoration: BoxDecoration( + color: colorPair[0].withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4.0), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Text( + displayName, + style: context.textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w500, + color: colorPair[0], + ), + ), + ), + ), + ); + } +} + +class EventEntityWidget extends StatelessWidget { + final EventData eventData; + final List colorPair; + + const EventEntityWidget({ + super.key, + required this.eventData, + required this.colorPair, + }); + + @override + Widget build(BuildContext context) { + final shortId = eventData.eventId.length > 12 + ? '${eventData.eventId.substring(0, 8)}…' + : eventData.eventId; + + return Container( + margin: const EdgeInsets.symmetric(vertical: 8.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(12.0), + border: Border.all( + color: colorPair[0].withValues(alpha: 0.2), + width: 1.0, + ), + boxShadow: [ + BoxShadow( + color: colorPair[0].withValues(alpha: 0.05), + blurRadius: 4.0, + offset: const Offset(0, 2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Icon(Icons.event, color: colorPair[0]), + const SizedBox(width: 8.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Event', + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + shortId, + style: context.textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class AddressEntityWidget extends StatelessWidget { + final AddressData addressData; + final List colorPair; + + const AddressEntityWidget({ + super.key, + required this.addressData, + required this.colorPair, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + // Handle address navigation + }, + child: DecoratedBox( + decoration: BoxDecoration( + color: colorPair[0].withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4.0), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Text( + addressData.identifier, + style: context.textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w500, + color: colorPair[0], + ), + ), + ), + ), + ); + } +} + +class GenericNip19Widget extends StatelessWidget { + final String entity; + final List colorPair; + + const GenericNip19Widget({ + super.key, + required this.entity, + required this.colorPair, + }); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: colorPair[0].withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4.0), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Text( + entity, + style: context.textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w500, + color: colorPair[0], + ), + ), + ), + ); + } +} + +class UrlChipWidget extends StatelessWidget { + final String url; + final List colorPair; + + const UrlChipWidget({super.key, required this.url, required this.colorPair}); + + @override + Widget build(BuildContext context) { + // Fallback widget for when preview fails + Widget fallbackWidget = GestureDetector( + onTap: () => _launchUrlSafely(url, context: 'Fallback'), + child: DecoratedBox( + decoration: BoxDecoration( + color: colorPair[0].withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4.0), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Text( + url, + style: context.textTheme.bodyMedium!.copyWith( + color: colorPair[0], + decoration: TextDecoration.underline, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ); + + return Container( + margin: const EdgeInsets.symmetric(vertical: 4.0), + child: Material( + color: Colors.transparent, + child: AnyLinkPreview( + link: url, + displayDirection: UIDirection.uiDirectionHorizontal, + showMultimedia: true, + bodyMaxLines: 2, + bodyTextOverflow: TextOverflow.ellipsis, + titleStyle: context.textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + bodyStyle: context.textTheme.bodySmall!.copyWith( + color: Colors.grey[600], + fontSize: 12, + ), + cache: Duration.zero, + backgroundColor: colorPair[0].withValues(alpha: 0.05), + borderRadius: 8.0, + removeElevation: true, + boxShadow: const [], + onTap: () => _launchUrlSafely(url, context: 'Link Preview'), + errorWidget: fallbackWidget, + errorBody: 'Link preview unavailable', + errorTitle: 'Unable to load preview', + errorImage: '', + placeholderWidget: fallbackWidget, + ), + ), + ); + } +} + +class MediaWidget extends StatelessWidget { + final String url; + final List colorPair; + + const MediaWidget({super.key, required this.url, required this.colorPair}); + + @override + Widget build(BuildContext context) { + final mediaType = NoteParser._getMediaType(url); + + return Container( + margin: const EdgeInsets.symmetric(vertical: 8.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.0), + border: Border.all(color: colorPair[0].withValues(alpha: 0.2)), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12.0), + child: switch (mediaType) { + MediaType.image => _buildImageWidget(context), + _ => _buildUnsupportedWidget(context), + }, + ), + ); + } + + Widget _buildImageWidget(BuildContext context) { + final sanitizedUrl = sanitizeHttpUrl(url); + if (sanitizedUrl == null) { + return _buildUnsupportedWidget(context); + } + + return CachedNetworkImage( + imageUrl: sanitizedUrl, + fit: BoxFit.cover, + placeholder: (context, url) => + SizedBox(height: 200, child: _buildContentImageLoader(context)), + errorWidget: (context, url, error) => SizedBox( + height: 60, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.broken_image_outlined, color: Colors.grey[400]), + const SizedBox(width: 8.0), + Text( + 'Image failed to load', + style: Theme.of( + context, + ).textTheme.bodySmall!.copyWith(color: Colors.grey[600]), + ), + ], + ), + ), + ); + } + + Widget _buildContentImageLoader(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF2A2A2A), Color(0xFF3A3A3A), Color(0xFF2A2A2A)], + stops: [0.0, 0.5, 1.0], + ), + ), + ); + } + + Widget _buildUnsupportedWidget(BuildContext context) { + return Container( + height: 60, + color: colorPair[0].withValues(alpha: 0.1), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.help_outline, color: colorPair[0]), + const SizedBox(width: 8.0), + Text( + 'Unsupported media type', + style: Theme.of( + context, + ).textTheme.bodySmall!.copyWith(color: colorPair[0]), + ), + ], + ), + ); + } +} + +class HashtagWidget extends StatelessWidget { + final String hashtag; + final List colorPair; + final VoidCallback? onTap; + + const HashtagWidget({ + super.key, + required this.hashtag, + required this.colorPair, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: colorPair[0].withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4.0), + ), + padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 1.0), + child: Text( + '#$hashtag', + style: context.textTheme.bodyMedium!.copyWith( + color: colorPair[0], + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } +} + +/// Animated loading chip for profile loading states +class _AnimatedLoadingChip extends StatefulWidget { + final String text; + final List colorPair; + + const _AnimatedLoadingChip({required this.text, required this.colorPair}); + + @override + State<_AnimatedLoadingChip> createState() => _AnimatedLoadingChipState(); +} + +class _AnimatedLoadingChipState extends State<_AnimatedLoadingChip> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + ); + _animation = Tween( + begin: 0.1, + end: 0.3, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + _controller.repeat(reverse: true); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return DecoratedBox( + decoration: BoxDecoration( + color: widget.colorPair[0].withValues(alpha: _animation.value), + borderRadius: BorderRadius.circular(4.0), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Text( + widget.text, + style: context.textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w500, + color: widget.colorPair[0], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/common/profile_avatar.dart b/lib/widgets/common/profile_avatar.dart new file mode 100644 index 0000000..8c0ca87 --- /dev/null +++ b/lib/widgets/common/profile_avatar.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/utils/url_utils.dart'; +import '../../theme.dart'; + +class ProfileAvatar extends StatelessWidget { + final Profile? profile; + final double radius; + final List? borderColors; + + const ProfileAvatar({ + super.key, + this.profile, + this.radius = 24, + this.borderColors, + }); + + @override + Widget build(BuildContext context) { + final pictureUrl = sanitizeHttpUrl(profile?.pictureUrl); + + Widget avatar = ClipOval( + child: pictureUrl != null + ? CachedNetworkImage( + imageUrl: pictureUrl, + fit: BoxFit.cover, + width: radius * 2, + height: radius * 2, + fadeInDuration: const Duration(milliseconds: 500), + fadeOutDuration: const Duration(milliseconds: 200), + placeholder: (context, url) => Container( + width: radius * 2, + height: radius * 2, + color: const Color(0xFF1A1A1A), + child: Center( + child: Icon( + Icons.person, + color: const Color(0xFF2C4A6B), + size: radius * 1.2, + ), + ), + ), + errorWidget: (context, url, error) => Container( + width: radius * 2, + height: radius * 2, + color: const Color(0xFF1A1A1A), + child: Center( + child: Icon( + Icons.person, + color: const Color(0xFF2C4A6B), + size: radius * 1.2, + ), + ), + ), + ) + : Container( + width: radius * 2, + height: radius * 2, + color: const Color(0xFF1A1A1A), + child: Center( + child: Icon( + Icons.person, + color: const Color(0xFF0D1B2A), + size: radius * 2, + ), + ), + ), + ); + + if (borderColors != null) { + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient(colors: borderColors!), + ), + child: Container( + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + shape: BoxShape.circle, + ), + child: avatar, + ), + ); + } + + return avatar; + } +} diff --git a/lib/widgets/common/time_utils.dart b/lib/widgets/common/time_utils.dart new file mode 100644 index 0000000..987c6c3 --- /dev/null +++ b/lib/widgets/common/time_utils.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +/// Provider that updates current time every 10 seconds for reactive time displays +final currentTimeProvider = + StateNotifierProvider((ref) { + return CurrentTimeNotifier(); + }); + +class CurrentTimeNotifier extends StateNotifier { + late final Timer _timer; + + CurrentTimeNotifier() : super(DateTime.now()) { + // Update every 10 seconds to keep "time ago" displays fresh + _timer = Timer.periodic(const Duration(seconds: 10), (_) { + state = DateTime.now(); + }); + } + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } +} + +class TimeUtils { + static String _formatFullDate(DateTime timestamp) { + return DateFormat('MMM d, y').format(timestamp); + } + + /// Formats a timestamp into a human-readable relative time string + static String formatTimestamp(DateTime timestamp) { + final now = DateTime.now(); + final difference = now.difference(timestamp); + + if (difference.inMinutes < 1) { + return 'Just now'; + } else if (difference.inHours < 1) { + return '${difference.inMinutes}m ago'; + } else if (difference.inDays < 1) { + return '${difference.inHours}h ago'; + } else if (difference.inDays < 7) { + return '${difference.inDays}d ago'; + } else { + return _formatFullDate(timestamp); + } + } + + /// Formats timestamp relative to a specific current time (useful for testing) + static String formatTimestampRelativeTo( + DateTime timestamp, + DateTime currentTime, + ) { + final difference = currentTime.difference(timestamp); + + if (difference.inMinutes < 1) { + return 'Just now'; + } else if (difference.inHours < 1) { + return '${difference.inMinutes}m ago'; + } else if (difference.inDays < 1) { + return '${difference.inHours}h ago'; + } else if (difference.inDays < 7) { + return '${difference.inDays}d ago'; + } else { + return _formatFullDate(timestamp); + } + } +} + +/// A reactive text widget that automatically updates time-relative displays +class TimeAgoText extends ConsumerWidget { + final DateTime timestamp; + final TextStyle? style; + final TextAlign? textAlign; + final int? maxLines; + final TextOverflow? overflow; + + const TimeAgoText( + this.timestamp, { + super.key, + this.style, + this.textAlign, + this.maxLines, + this.overflow, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Watch the current time provider to automatically rebuild when time changes + final currentTime = ref.watch(currentTimeProvider); + + return Text( + TimeUtils.formatTimestampRelativeTo(timestamp, currentTime), + style: style, + textAlign: textAlign, + maxLines: maxLines, + overflow: overflow, + ); + } +} diff --git a/lib/widgets/download_text_container.dart b/lib/widgets/download_text_container.dart new file mode 100644 index 0000000..02ee6ff --- /dev/null +++ b/lib/widgets/download_text_container.dart @@ -0,0 +1,197 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:zapstore/utils/extensions.dart'; + +/// Download text container widget showing "Direct download from (icon) (path)" +/// Mirrors the style and layout of `author_container.dart` +class DownloadTextContainer extends StatelessWidget { + final String url; + final String beforeText; + final bool oneLine; + final double? size; + final VoidCallback? onTap; + final bool showFullUrl; + + const DownloadTextContainer({ + super.key, + required this.url, + this.beforeText = 'Released at ', + this.oneLine = true, + this.size, + this.onTap, + this.showFullUrl = false, + }); + + @override + Widget build(BuildContext context) { + final Uri? uri = _tryParseUri(url); + + final iconWidget = _buildIcon(context, uri); + final pathText = showFullUrl ? url : (_buildDisplayPath(uri) ?? url); + + final baseStyle = context.textTheme.bodyMedium?.copyWith( + fontSize: size ?? context.textTheme.bodyMedium!.fontSize!, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ); + final boldStyle = baseStyle?.copyWith(fontWeight: FontWeight.w600); + + final richText = Text.rich( + TextSpan( + children: [ + TextSpan(text: beforeText, style: baseStyle), + const TextSpan(text: ' '), + if (iconWidget != null) + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: iconWidget, + ), + ), + TextSpan(text: pathText, style: boldStyle), + ], + ), + softWrap: !oneLine, + overflow: oneLine ? TextOverflow.ellipsis : TextOverflow.visible, + maxLines: oneLine ? 1 : null, + ); + + if (onTap != null) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding(padding: const EdgeInsets.all(4), child: richText), + ); + } + + return richText; + } + + Uri? _tryParseUri(String value) { + try { + return Uri.parse(value); + } catch (_) { + return null; + } + } + + /// Builds the icon widget according to domain rules, or attempts to fetch favicon. + Widget? _buildIcon(BuildContext context, Uri? uri) { + if (uri == null || uri.host.isEmpty) return null; + final host = _normalizedHost(uri.host); + final double effectiveFontSize = + size ?? context.textTheme.bodySmall?.fontSize ?? 12; + final double diameter = + effectiveFontSize * 1.6; // Mirrors ProfileAvatar sizing + + // Known domains → asset icons + if (host == 'github.com') { + return _circleAsset(context, 'assets/images/github.png', diameter); + } + if (host == 'gitlab.com') { + return _circleAsset(context, 'assets/images/gitlab.png', diameter); + } + if (host == 'fdroid.org' || host == 'f-droid.org') { + return _circleAsset(context, 'assets/images/fdroid.png', diameter); + } + + // Fallback → attempt to fetch favicon from site + final scheme = (uri.scheme == 'http' || uri.scheme == 'https') + ? uri.scheme + : 'https'; + final faviconIcoUrl = '$scheme://${uri.host}/favicon.ico'; + final faviconPngUrl = '$scheme://${uri.host}/favicon.png'; + return CachedNetworkImage( + imageUrl: faviconIcoUrl, + fadeInDuration: const Duration(milliseconds: 250), + fadeOutDuration: const Duration(milliseconds: 150), + placeholder: (context, url) => const SizedBox.shrink(), + errorWidget: (context, url, error) => CachedNetworkImage( + imageUrl: faviconPngUrl, + fadeInDuration: const Duration(milliseconds: 250), + fadeOutDuration: const Duration(milliseconds: 150), + placeholder: (context, url) => const SizedBox.shrink(), + errorWidget: (context, url, error) => const SizedBox.shrink(), + imageBuilder: (context, imageProvider) { + return Container( + width: diameter, + height: diameter, + decoration: BoxDecoration( + shape: BoxShape.circle, + image: DecorationImage(image: imageProvider, fit: BoxFit.cover), + ), + ); + }, + ), + imageBuilder: (context, imageProvider) { + return Container( + width: diameter, + height: diameter, + decoration: BoxDecoration( + shape: BoxShape.circle, + image: DecorationImage(image: imageProvider, fit: BoxFit.cover), + ), + ); + }, + ); + } + + Widget _circleAsset(BuildContext context, String assetPath, double diameter) { + return Container( + width: diameter, + height: diameter, + decoration: BoxDecoration(shape: BoxShape.circle), + clipBehavior: Clip.antiAlias, + child: Image.asset( + assetPath, + width: diameter, + height: diameter, + fit: BoxFit.cover, + ), + ); + } + + /// Creates a display path according to domain-specific rules. + String? _buildDisplayPath(Uri? uri) { + if (uri == null) return null; + final host = _normalizedHost(uri.host); + final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList(); + + if (host == 'github.com' || host == 'gitlab.com') { + if (segments.length >= 2) { + return '${segments[0]}/${segments[1]}'; + } + return host; // Fallback + } + + if (host == 'fdroid.org' || host == 'f-droid.org') { + // New style: Expecting /repo/ + // Example: https://f-droid.org/repo/app.comaps.fdroid_25080802.apk + final repoIndex = segments.indexOf('repo'); + if (repoIndex != -1 && repoIndex + 1 < segments.length) { + final fileName = segments[repoIndex + 1]; + final appId = fileName.split('_').first; // before first underscore + return appId; + } + + // Legacy style: //packages/ + final pkgIndex = segments.indexOf('packages'); + if (pkgIndex != -1 && pkgIndex + 1 < segments.length) { + return segments[pkgIndex + 1]; + } + + // Fallback to last non-empty segment + if (segments.isNotEmpty) return segments.last; + return host; + } + + // Default → just the domain + return _normalizedHost(uri.host); + } + + String _normalizedHost(String host) { + final lower = host.toLowerCase(); + if (lower.startsWith('www.')) return lower.substring(4); + return lower; + } +} diff --git a/lib/widgets/drawer_container.dart b/lib/widgets/drawer_container.dart deleted file mode 100644 index d1b24af..0000000 --- a/lib/widgets/drawer_container.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:zapstore/main.dart'; -import 'package:zapstore/utils/system_info.dart'; -import 'package:zapstore/widgets/sign_in_container.dart'; - -class DrawerContainer extends StatelessWidget { - const DrawerContainer({super.key}); - - @override - Widget build(BuildContext context) { - return Drawer( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: SignInButton(), - ), - Consumer( - builder: (context, ref, _) { - final state = ref.watch(systemInfoNotifierProvider); - return switch (state) { - AsyncData(:final value) => Text( - 'Version: ${value.zsInfo.versionName} (${value.zsInfo.versionCode}, $kDbVersion)', - style: TextStyle(fontSize: 16), - ), - _ => Container(), - }; - }, - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/error_container.dart b/lib/widgets/error_container.dart deleted file mode 100644 index f877ecb..0000000 --- a/lib/widgets/error_container.dart +++ /dev/null @@ -1,157 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_data/flutter_data.dart'; -import 'package:flutter_phoenix/flutter_phoenix.dart'; -import 'package:gap/gap.dart'; -import 'package:http/http.dart' as http; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:zapstore/models/feedback.dart'; -import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/utils/signers.dart'; -import 'package:zapstore/utils/system_info.dart'; -import 'package:zapstore/utils/theme.dart'; - -class ErrorContainer extends HookConsumerWidget { - final Object exception; - final StackTrace? stack; - const ErrorContainer({super.key, required this.exception, this.stack}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Container( - color: kBackgroundColor, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.error_outline, - color: Colors.red, - size: 60, - ), - const SizedBox(height: 16), - const Text( - 'Dang, something went wrong!', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - ), - ), - Gap(10), - Text( - exception.toString(), - style: TextStyle( - fontSize: 16, - color: Colors.grey, - ), - textAlign: TextAlign.center, - ), - Gap(6), - Text( - stack?.toString().safeSubstring(80) ?? '', - style: TextStyle( - fontSize: 12, - color: Colors.grey, - ), - textAlign: TextAlign.center, - ), - Gap(12), - Text( - 'Reporting an error will send device information', - style: TextStyle( - fontSize: 14, - color: Colors.grey, - ), - textAlign: TextAlign.center, - ), - Gap(24), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ElevatedButton( - onPressed: () { - ref.read(localStorageProvider).destroy().then((_) { - if (context.mounted) { - Phoenix.rebirth(context); - context.go('/'); - } - }); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Color.fromARGB(255, 251, 89, 89), - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - child: const Text( - 'Clear and reload app', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - ), - ), - ), - Gap(10), - ElevatedButton( - onPressed: () => _sendErrorReport(ref, exception, stack), - style: ElevatedButton.styleFrom( - backgroundColor: Color.fromARGB(255, 255, 22, 22), - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - child: const Text( - 'Report error', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ], - ), - ); - } -} - -Widget materialErrorBuilder(BuildContext context, Widget? widget) { - if (widget is Scaffold || widget is Navigator) { - ErrorWidget.builder = (details) => Scaffold( - body: ErrorContainer( - exception: details.exception, - stack: details.stack, - ), - ); - return widget!; - } - ErrorWidget.builder = (details) => ErrorContainer( - exception: details.exception, - stack: details.stack, - ); - if (widget != null) return widget; - throw StateError('widget is null'); -} - -Future _sendErrorReport( - WidgetRef ref, Object exception, StackTrace? stack) async { - final systemInfo = - await ref.read(systemInfoNotifierProvider.notifier).fetch(); - - var map = { - 'e': exception.toString(), - if (stack != null) 'stack': stack.toString(), - 'info': systemInfo.androidInfo.toString() - }; - - final event = - await pkSigner.sign(PartialAppFeedback(content: jsonEncode(map))); - await http.post(Uri.parse('https://relay.zapstore.dev/'), - body: jsonEncode(event.toMap()), - headers: {'Content-Type': 'application/json'}); -} diff --git a/lib/widgets/expandable_markdown.dart b/lib/widgets/expandable_markdown.dart new file mode 100644 index 0000000..62ee87d --- /dev/null +++ b/lib/widgets/expandable_markdown.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:zapstore/utils/extensions.dart'; + +class ExpandableMarkdown extends HookWidget { + const ExpandableMarkdown({ + super.key, + required this.data, + this.onTapLink, + this.styleSheet, + }); + + final String data; + final void Function(String, String?, String?)? onTapLink; + final MarkdownStyleSheet? styleSheet; + + @override + Widget build(BuildContext context) { + final expanded = useState(false); + const maxHeight = 170.0; + + bool isLikelyLong(String text) { + // Simple heuristic to avoid expensive measurement + // - Many words OR many newlines => likely long content + final trimmed = text.trim(); + if (trimmed.isEmpty) return false; + + final wordCount = trimmed.split(RegExp(r'\s+')).length; + final newlineCount = '\n'.allMatches(trimmed).length; + final charCount = trimmed.length; + + // Thresholds tuned for typical article blurbs + // Show collapse if any of these exceed limits + return wordCount > 90 || newlineCount > 6 || charCount > 600; + } + + final shouldCollapse = !expanded.value && isLikelyLong(data); + + final markdown = MarkdownBody( + data: data, + onTapLink: onTapLink, + styleSheet: styleSheet, + ); + + if (!shouldCollapse) { + return markdown; + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: maxHeight), + child: ShaderMask( + shaderCallback: (Rect bounds) { + return const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.white, Colors.white, Colors.transparent], + stops: [0.0, 0.8, 1.0], + ).createShader(bounds); + }, + blendMode: BlendMode.dstIn, + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: markdown, + ), + ), + ), + const SizedBox(height: 8), + Center( + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.08), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => expanded.value = true, + child: Text('Read more', style: context.textTheme.labelSmall), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/horizontal_grid.dart b/lib/widgets/horizontal_grid.dart deleted file mode 100644 index 4a06311..0000000 --- a/lib/widgets/horizontal_grid.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/widgets/app_card.dart'; - -class HorizontalGrid extends StatelessWidget { - final List apps; - - HorizontalGrid({super.key, required this.apps}); - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 230, - child: GridView.builder( - scrollDirection: Axis.horizontal, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 1.15, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemCount: apps.isEmpty ? 8 : apps.length, - itemBuilder: (context, i) => TinyAppCard(app: apps.elementAtOrNull(i)), - ), - ); - } -} diff --git a/lib/widgets/install_alert_dialog.dart b/lib/widgets/install_alert_dialog.dart index 11f6986..e0e2f77 100644 --- a/lib/widgets/install_alert_dialog.dart +++ b/lib/widgets/install_alert_dialog.dart @@ -2,59 +2,91 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/user.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/widgets/author_container.dart'; +import 'package:zapstore/widgets/common/base_dialog.dart'; +import 'package:zapstore/widgets/download_text_container.dart'; import 'package:zapstore/widgets/relevant_who_follow_container.dart'; -import 'package:zapstore/widgets/sign_in_container.dart'; +import 'package:zapstore/widgets/sign_in_button.dart'; class InstallAlertDialog extends HookConsumerWidget { - const InstallAlertDialog({ - super.key, - required this.app, - }); + const InstallAlertDialog({super.key, required this.app}); final App app; @override Widget build(BuildContext context, WidgetRef ref) { - final trustedSignerNotifier = useState(false); - final signedInUser = ref.watch(signedInUserProvider); - - return AlertDialog( - elevation: 10, - title: Text( - 'Are you sure you want to install ${app.name}?', - style: TextStyle(fontWeight: FontWeight.bold), + // Query publisher profile (app.pubkey is always present) + final publisherState = ref.watch( + query( + authors: {app.pubkey}, + source: const LocalAndRemoteSource( + relays: {'social', 'vertex'}, + cachedFor: Duration(hours: 2), + ), ), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'By installing this app you are trusting the signer now and for all future updates. Make sure you know who they are.'), - Gap(20), - if (app.signer.value != null) - AuthorContainer( - user: app.signer.value!, - beforeText: 'Signed by', - oneLine: true), - Gap(20), - if (app.signer.value != null) - signedInUser != null - ? RelevantWhoFollowContainer( - toNpub: app.signer.value!.npub, - ) - : SignInButton( - label: - 'Sign in to view the signer\'s reputable followers', - minimal: true, - requireNip55: true, - ), - Gap(16), + ); + final publisher = switch (publisherState) { + StorageData(:final models) => models.firstOrNull, + _ => null, + }; + if (publisher == null) { + return const SizedBox.shrink(); + } + final trustedSignerNotifier = useState(false); + final profile = ref.watch(Signer.activeProfileProvider(LocalSource())); + final theme = Theme.of(context); + final baseTextSize = theme.textTheme.bodyMedium?.fontSize ?? 14.0; + + return BaseDialog( + title: const BaseDialogTitle('Trust this app?'), + content: BaseDialogContent( + children: [ + if (app.isRelaySigned) ...[ + AuthorContainer( + profile: publisher, + beforeText: 'The', + afterText: + ' relay makes the ${app.name ?? app.identifier} app available but did not develop it or sign its APK. ', + oneLine: false, + size: baseTextSize, + ), + Gap(10), + DownloadTextContainer( + beforeText: + '${app.name ?? app.identifier} will be installed from its original release location:', + oneLine: false, + showFullUrl: true, + url: app.latestFileMetadata!.urls.first, + size: baseTextSize, + ), + ] else ...[ + profile != null + ? RelevantWhoFollowContainer(app: app, size: baseTextSize) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AuthorContainer( + profile: publisher, + beforeText: + '${app.name ?? app.identifier} is published by', + afterText: '.', + oneLine: false, + size: baseTextSize, + ), + const Gap(14), + SignInButton( + label: + 'Sign in to view the publisher\'s reputable followers', + minimal: true, + requireNip55: true, + ), + ], + ), + const Gap(14), Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Switch( value: trustedSignerNotifier.value, @@ -64,37 +96,46 @@ class InstallAlertDialog extends HookConsumerWidget { ), Gap(4), Expanded( - child: Text( - 'Do not ask again for apps from ${app.signer.value!.name}', + child: Row( + children: [ + Text( + 'Always trust', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + Gap(4), + AuthorContainer( + beforeText: '', + profile: publisher, + size: baseTextSize, + ), + ], ), - ) + ), ], - ) + ), ], - ), + ], ), actions: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: TextButton( - onPressed: () { - app.install(alwaysTrustSigner: trustedSignerNotifier.value); - // NOTE: can't use context.pop() - Navigator.of(context).pop(); - }, - child: Text( - '${trustedSignerNotifier.value ? 'Always trust' : 'Trust'} ${app.signer.value!.name} and install app', - style: TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.right, - ), - ), - ), TextButton( onPressed: () { // NOTE: can't use context.pop() Navigator.of(context).pop(); }, - child: Text('Go back'), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of( + context, + ).pop((trustPermanently: trustedSignerNotifier.value)); + }, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + child: const Text('Trust and install app'), ), ], ); diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index 8c263d1..0f8a3c1 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -1,154 +1,777 @@ -import 'package:auto_size_text/auto_size_text.dart'; +import 'package:async_button_builder/async_button_builder.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:percent_indicator/linear_percent_indicator.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/local_app.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/services/trusted_signers_service.dart'; import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/utils/system_info.dart'; +import 'package:zapstore/widgets/author_container.dart'; +import 'package:zapstore/widgets/common/base_dialog.dart'; import 'package:zapstore/widgets/install_alert_dialog.dart'; -import 'package:zapstore/widgets/spinning_logo.dart'; +import 'package:zapstore/theme.dart'; -class InstallButton extends HookConsumerWidget { - InstallButton({ +class InstallButton extends ConsumerWidget { + const InstallButton({ super.key, required this.app, + this.release, this.compact = false, }); - final bool compact; final App app; + final Release? release; + final bool compact; @override Widget build(BuildContext context, WidgetRef ref) { - final progress = ref.watch(installationProgressProvider(app.id!)); - final status = app.localApp.value?.status; + final operation = ref.watch(installOperationProvider(app.identifier)); + final installedPkg = ref.watch(installedPackageProvider(app.identifier)); - useMemoized(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (progress is IdleInstallProgress && progress.success == true) { - context.showInfo('Success', - description: - '${app.name ?? app.identifier} was successfully installed'); - } - }); - }, [app.id, progress]); + // Derive what to display + final isInstalled = installedPkg != null; + final hasUpdate = app.hasUpdate; + final hasDowngrade = app.hasDowngrade; + final hasRelease = release != null; + final fileMetadata = app.latestFileMetadata; - return GestureDetector( - onTap: switch (status) { - AppInstallStatus.downgrade || - AppInstallStatus.certificateMismatch => - null, - AppInstallStatus.updated => () { - packageManager.openApp(app.identifier); - }, - _ => switch (progress) { - IdleInstallProgress() => () { - if (app.canInstall) { - final settings = ref.settings.findOneLocalById('_')!; - if (settings.trustedUsers.contains(app.signer.value!)) { - app.install().catchError((e) { - if (context.mounted) { - context.showError('Could not install', - description: e.message); - } - }); - } else { - showDialog( - context: context, - builder: (context) => InstallAlertDialog(app: app), - ); - } - } else if (app.canUpdate) { - app.install().catchError((e) { - if (context.mounted) { - context.showError('Could not install', - description: e.message); - } - }); - } else { - // no action - } - }, - ErrorInstallProgress(:final e, :final info, :final actions) => () { - // show error and reset state to idle - context.showError(e.message, - description: info, actions: actions); - ref.read(installationProgressProvider(app.id!).notifier).state = - IdleInstallProgress(); - }, - _ => null, - } - }, - child: LinearPercentIndicator( - lineHeight: compact ? 30 : 42, - percent: switch (progress) { - VerifyingHashProgress() => 1, - DownloadingInstallProgress(:final progress) => progress, - _ => switch (status) { - AppInstallStatus.updated => 1, - _ => 0, - }, - }, - backgroundColor: switch (progress) { - ErrorInstallProgress() => Colors.red, - IdleInstallProgress() => - (!app.canInstall && !app.canUpdate || app.hasCertificateMismatch) - ? Colors.grey - : kUpdateColor, - _ => kUpdateColor, - }, - progressColor: Colors.blue[800], - barRadius: Radius.circular(20), - padding: EdgeInsets.all(0), - animateFromLastPercent: true, - center: switch (status) { - AppInstallStatus.downgrade => Text( - 'Installed version ${app.localApp.value?.installedVersion ?? ''} is higher, can\'t downgrade', - textAlign: TextAlign.center, - ), - AppInstallStatus.certificateMismatch => Text( - 'Not possible to update', - textAlign: TextAlign.center, - ), - AppInstallStatus.updated => Text('Open'), - _ => switch (progress) { - IdleInstallProgress() => app.canUpdate - ? Padding( - padding: const EdgeInsets.only(left: 8, right: 8), - child: AutoSizeText( - 'Update', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: compact ? 10 : 14), - ), - ) - : (app.canInstall - ? Text('Install') - : Center(child: SpinningLogo(size: 40))), - DownloadingInstallProgress(:final progress) => Text( - progress == 0 - ? 'Starting download...' - : '${(progress * 100).floor()}%', - style: TextStyle(fontWeight: FontWeight.bold), - ), - VerifyingHashProgress() => Text('Verifying file integrity'), - RequestInstallProgress() => - Text('Requesting ${app.canUpdate ? 'update' : 'installation'}'), - ErrorInstallProgress() => Padding( - padding: const EdgeInsets.only(left: 8, right: 8), - child: Text( - 'Error (tap for details)', - textAlign: TextAlign.center, - ), - ), - } - }, + // Listen for errors to show toasts + ref.listen(installOperationProvider(app.identifier), (prev, next) { + if (next is OperationFailed && prev is! OperationFailed) { + _showErrorToast(context, next); + } + }); + + final fontSize = compact ? 13.0 : 16.0; + + // Build button based on operation state + final button = _buildButton( + context, + ref, + operation: operation, + isInstalled: isInstalled, + hasUpdate: hasUpdate, + hasDowngrade: hasDowngrade, + hasRelease: hasRelease, + fileMetadata: fileMetadata, + fontSize: fontSize, + ); + + if (compact) { + return button; + } + + // Full layout with action buttons + return Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + ), + child: SafeArea( + child: Row( + children: [ + Expanded(child: SizedBox(height: 48, child: button)), + // Show action buttons only for installed apps with no active operation + if (isInstalled && operation == null) ...[ + if (hasUpdate) ...[ + const SizedBox(width: 8), + _buildOpenIconButton(context, ref), + ], + const SizedBox(width: 8), + _buildUninstallIconButton(context, ref), + ], + ], + ), + ), ), ); } -} -final kUpdateColor = Colors.blue[700]!; + Widget _buildButton( + BuildContext context, + WidgetRef ref, { + required InstallOperation? operation, + required bool isInstalled, + required bool hasUpdate, + required bool hasDowngrade, + required bool hasRelease, + required FileMetadata? fileMetadata, + required double fontSize, + }) { + // Handle operation states first + if (operation != null) { + return switch (operation) { + DownloadQueued() => _buildSimpleButton( + context, + 'Queued', + null, + fontSize: fontSize, + showSpinner: true, + ), + + Downloading(:final progress) => _buildProgressButton( + context, + ref, + progress: progress, + text: _formatProgress(progress), + fontSize: fontSize, + onTap: () => _pauseDownload(ref), + ), + + DownloadPaused(:final progress) => _buildProgressButton( + context, + ref, + progress: progress, + text: '${_formatProgress(progress)} (paused)', + fontSize: fontSize, + onTap: () => _resumeDownload(ref), + ), + + Verifying() => _buildSimpleButton( + context, + 'Verifying...', + null, + fontSize: fontSize, + showSpinner: true, + ), + + AwaitingPermission() => _buildSimpleButton( + context, + 'Grant Permission', + () => _requestPermission(ref), + fontSize: fontSize, + isWarning: true, + ), + + ReadyToInstall() => _buildAsyncButton( + context, + ref, + text: isInstalled ? 'Update' : 'Install', + onPressed: () => _triggerInstall(ref), + fontSize: fontSize, + ), + + Installing(:final isSilent) => _buildSimpleButton( + context, + isSilent + ? (isInstalled ? 'Updating...' : 'Installing...') + : (isInstalled ? 'Requesting update' : 'Requesting installation'), + null, // Not tappable - auto-transitions to retry after 10s if no response + fontSize: fontSize, + showSpinner: true, + ), + + AwaitingUserAction() => _buildSimpleButton( + context, + 'Tap to retry', + () => _retryInstall(ref), + fontSize: fontSize, + isWarning: true, + icon: Icons.refresh, + ), + + Uninstalling() => _buildSimpleButton( + context, + 'Uninstalling...', + null, + fontSize: fontSize, + showSpinner: true, + ), + + OperationFailed(:final type, :final needsForceUpdate) => + _buildErrorButton( + context, + ref, + type: type, + needsForceUpdate: needsForceUpdate, + fontSize: fontSize, + ), + }; + } + + // No operation - show based on installed state + if (isInstalled) { + if (hasDowngrade) { + return _buildSimpleButton( + context, + "Can't downgrade", + null, + fontSize: fontSize, + isDisabled: true, + ); + } + + if (hasUpdate) { + return _buildAsyncButton( + context, + ref, + text: 'Update', + onPressed: hasRelease && fileMetadata != null + ? () => _startDownload(context, ref, fileMetadata) + : null, + fontSize: fontSize, + ); + } + + // Up to date + return _buildAsyncButton( + context, + ref, + text: 'Open', + onPressed: () => _openApp(context, ref), + fontSize: fontSize, + needsTrustCheck: false, + ); + } + + // Not installed + return _buildAsyncButton( + context, + ref, + text: 'Install', + onPressed: hasRelease && fileMetadata != null + ? () => _startDownload(context, ref, fileMetadata) + : null, + fontSize: fontSize, + needsTrustCheck: true, + ); + } + + String _formatProgress(double progress) { + final percent = (progress * 100).round(); + final sizeMb = _formatTotalSizeMb(); + return sizeMb != null ? '$percent% of $sizeMb' : '$percent%'; + } + + String? _formatTotalSizeMb() { + final sizeBytes = app.latestFileMetadata?.size; + if (sizeBytes == null || sizeBytes <= 0) return null; + final mb = sizeBytes / (1024 * 1024); + return '${mb.toStringAsFixed(1)} MB'; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // BUTTON BUILDERS + // ═══════════════════════════════════════════════════════════════════════════ + + Widget _buildAsyncButton( + BuildContext context, + WidgetRef ref, { + required String text, + required Future Function()? onPressed, + required double fontSize, + bool needsTrustCheck = false, + }) { + return AsyncButtonBuilder( + onPressed: onPressed == null + ? null + : () async { + if (needsTrustCheck) { + final proceed = await _checkTrust(context, ref); + if (!proceed) return; + } + await onPressed(); + }, + builder: (context, child, callback, buttonState) { + return _buildSimpleButton( + context, + buttonState.maybeWhen( + loading: () => text == 'Open' ? 'Launching...' : 'Starting...', + orElse: () => text, + ), + buttonState.maybeWhen(loading: () => null, orElse: () => callback), + fontSize: fontSize, + ); + }, + child: Text(text), + onError: () { + if (context.mounted) { + context.showError('Operation failed. Please try again.'); + } + }, + ); + } + + Widget _buildProgressButton( + BuildContext context, + WidgetRef ref, { + required double progress, + required String text, + required double fontSize, + VoidCallback? onTap, + }) { + const actionColor = AppColors.darkActionPrimary; + final darkerAction = Color.alphaBlend( + Colors.black.withValues(alpha: 0.22), + actionColor, + ); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + constraints: const BoxConstraints(minHeight: 38), + decoration: BoxDecoration( + color: actionColor, + borderRadius: BorderRadius.circular(12), + ), + child: Stack( + children: [ + // Progress fill + Positioned.fill( + child: Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: progress.clamp(0.0, 1.0), + heightFactor: 1.0, + child: Container( + decoration: BoxDecoration( + color: darkerAction, + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ), + // Text + Center( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 16, + ), + child: Text( + text, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildSimpleButton( + BuildContext context, + String text, + VoidCallback? onPressed, { + double fontSize = 16.0, + bool showSpinner = false, + bool isError = false, + bool isWarning = false, + bool isDisabled = false, + IconData? icon, + }) { + final theme = Theme.of(context); + + Widget child = Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[Icon(icon, size: 18), const SizedBox(width: 6)], + Flexible( + child: Text( + text, + style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + if (showSpinner) ...[ + const SizedBox(width: 8), + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ], + ], + ); + + Color backgroundColor; + Color foregroundColor = Colors.white; + + if (isError) { + backgroundColor = theme.colorScheme.error; + } else if (isWarning) { + backgroundColor = Colors.amber.shade700; + } else if (isDisabled) { + backgroundColor = theme.colorScheme.outline; + foregroundColor = theme.colorScheme.onSurface.withValues(alpha: 0.5); + } else { + backgroundColor = AppColors.darkActionPrimary; + } + + return FilledButton( + onPressed: isDisabled ? null : onPressed, + style: FilledButton.styleFrom( + backgroundColor: backgroundColor, + disabledBackgroundColor: backgroundColor, + disabledForegroundColor: foregroundColor, + foregroundColor: foregroundColor, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: child, + ); + } + + Widget _buildErrorButton( + BuildContext context, + WidgetRef ref, { + required FailureType type, + required bool needsForceUpdate, + required double fontSize, + }) { + if (needsForceUpdate) { + return _buildSimpleButton( + context, + 'Force update', + () => _showForceUpdateDialog(ref, context), + fontSize: fontSize, + isError: true, + ); + } + + return _buildSimpleButton( + context, + 'Error (tap for details)', + () => _handleErrorTap(ref, context), + fontSize: fontSize, + isError: true, + ); + } + + Widget _buildUninstallIconButton(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + return IconButton.filled( + onPressed: () => _uninstallApp(ref, context), + icon: const Icon(Icons.delete_outline), + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.errorContainer, + foregroundColor: theme.colorScheme.onErrorContainer, + padding: const EdgeInsets.all(12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + tooltip: 'Uninstall', + ); + } + + Widget _buildOpenIconButton(BuildContext context, WidgetRef ref) { + const actionColor = AppColors.darkActionPrimary; + return IconButton.filled( + onPressed: () => _openApp(context, ref), + icon: const Icon(Icons.open_in_new), + style: IconButton.styleFrom( + backgroundColor: actionColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.all(12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + tooltip: 'Open', + ); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // ACTIONS + // ═══════════════════════════════════════════════════════════════════════════ + + Future _checkTrust(BuildContext context, WidgetRef ref) async { + final signerPubkey = app.author.value?.pubkey; + bool shouldShowDialog = true; + + if (signerPubkey != null) { + try { + final isTrusted = await ref + .read(trustServiceProvider) + .isSignerTrusted(signerPubkey); + shouldShowDialog = !isTrusted; + } catch (_) { + shouldShowDialog = true; + } + } + + if (shouldShowDialog) { + if (!context.mounted) return false; + final result = await showBaseDialog<({bool trustPermanently})>( + context: context, + dialog: InstallAlertDialog(app: app), + ); + if (result == null) return false; + if (result.trustPermanently && signerPubkey != null) { + try { + await ref.read(trustServiceProvider).addTrustedSigner(signerPubkey); + } catch (_) {} + } + } + return true; + } + + Future _startDownload( + BuildContext context, + WidgetRef ref, + FileMetadata fileMetadata, + ) async { + final pm = ref.read(packageManagerProvider.notifier); + await pm.startDownload(app.identifier, fileMetadata, displayName: app.name); + } + + void _pauseDownload(WidgetRef ref) { + final pm = ref.read(packageManagerProvider.notifier); + pm.pauseDownload(app.identifier); + } + + void _resumeDownload(WidgetRef ref) { + final pm = ref.read(packageManagerProvider.notifier); + pm.resumeDownload(app.identifier); + } + + Future _triggerInstall(WidgetRef ref) async { + final pm = ref.read(packageManagerProvider.notifier); + await pm.triggerInstall(app.identifier); + } + + Future _retryInstall(WidgetRef ref) async { + final pm = ref.read(packageManagerProvider.notifier); + await pm.retryInstall(app.identifier); + } + + Future _requestPermission(WidgetRef ref) async { + final pm = ref.read(packageManagerProvider.notifier); + + // Check if permission is already granted (user may have granted it in settings) + if (await pm.hasPermission()) { + await pm.onPermissionGranted(app.identifier); + return; + } + + // Request permission (opens settings) + await pm.requestPermission(); + + // Check again after returning from settings + if (await pm.hasPermission()) { + await pm.onPermissionGranted(app.identifier); + } + // If still no permission, user will need to tap button again + } + + Future _openApp(BuildContext context, WidgetRef ref) async { + try { + final pm = ref.read(packageManagerProvider.notifier); + await pm.launchApp(app.identifier); + } catch (e) { + if (!context.mounted) return; + context.showError( + 'Failed to launch ${app.name ?? app.identifier}', + description: 'The app may have been uninstalled or moved.\n\n$e', + ); + } + } + + Future _uninstallApp(WidgetRef ref, BuildContext context) async { + try { + final pm = ref.read(packageManagerProvider.notifier); + await pm.uninstall(app.identifier); + if (context.mounted) { + context.showInfo('${app.name ?? app.identifier} has been uninstalled'); + } + } catch (e) { + if (context.mounted) { + final message = e.toString(); + if (!message.contains('cancelled')) { + context.showError('Uninstall failed', description: '$e'); + } + } + } + } + + void _handleErrorTap(WidgetRef ref, BuildContext context) { + final operation = ref.read(installOperationProvider(app.identifier)); + if (operation is! OperationFailed) return; + + context.showError( + operation.message, + description: operation.description, + actions: const [], + ); + + // Always clear error after showing it (reckless mode removed). + final pm = ref.read(packageManagerProvider.notifier); + pm.dismissError(app.identifier); + } + + void _showErrorToast(BuildContext context, OperationFailed operation) { + if (operation.type == FailureType.certMismatch) { + context.showError( + 'Certificate mismatch', + description: 'The app signature does not match. Force update required.', + ); + } else if (operation.description != null) { + // Errors with descriptions (from Kotlin) are shown as toasts + context.showError(operation.message, description: operation.description); + } + // Other errors are shown when user taps the error button + } + + Future _showForceUpdateDialog( + WidgetRef ref, + BuildContext context, + ) async { + final installedPkg = ref.read(installedPackageProvider(app.identifier)); + final operation = ref.read(installOperationProvider(app.identifier)); + if (operation is! OperationFailed) return; + + final updateVersion = operation.target.version; + final currentVersion = installedPkg?.version ?? 'Unknown'; + final currentCertHash = installedPkg?.signatureHash ?? 'Unknown'; + final updateCertHash = operation.target.apkSignatureHash ?? 'Unknown'; + final author = app.author.value; + + final shouldProceed = await showBaseDialog( + context: context, + dialog: Builder( + builder: (dialogContext) => BaseDialog( + titleIcon: Icon( + Icons.security, + color: Theme.of(dialogContext).colorScheme.error, + ), + title: const BaseDialogTitle('Certificate Mismatch'), + content: BaseDialogContent( + children: [ + if (author != null) + Row( + children: [ + Expanded( + child: AuthorContainer( + profile: author, + beforeText: 'This update was published by', + afterText: ' but signed with a different certificate.', + oneLine: false, + size: 14, + ), + ), + ], + ), + const SizedBox(height: 12), + _buildCompactHashRow( + dialogContext, + 'Current ($currentVersion)', + currentCertHash, + 'Installed version certificate', + ), + const SizedBox(height: 8), + _buildCompactHashRow( + dialogContext, + 'Update ($updateVersion)', + updateCertHash, + 'New version certificate', + ), + const SizedBox(height: 12), + const Text( + 'Android security prevents updating apps signed by different certificates. ' + 'Contact the publisher for details.', + style: TextStyle(fontSize: 14), + ), + const SizedBox(height: 8), + const Text( + 'To proceed anyway, uninstall the current version and install the new one. ' + 'ALL APP DATA WILL BE LOST.', + style: TextStyle(fontSize: 14), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(dialogContext).colorScheme.error, + foregroundColor: Colors.white, + ), + child: const Text('Uninstall & Install'), + ), + ], + ), + ), + ); + + if (shouldProceed == true && context.mounted) { + try { + final pm = ref.read(packageManagerProvider.notifier); + await pm.forceUpdate(app.identifier); + } catch (e) { + final message = e.toString(); + if (context.mounted && !message.contains('cancelled')) { + context.showError('Force update failed', description: '$e'); + } + } + } + } + + String _abbr(String v) { + final t = v.trim(); + if (t.length <= 12) return t; + return '${t.substring(0, 6)}...${t.substring(t.length - 6)}'; + } + + Widget _buildCompactHashRow( + BuildContext context, + String versionLabel, + String hash, + String tooltipText, + ) { + return Row( + children: [ + Expanded( + child: RichText( + text: TextSpan( + style: Theme.of(context).textTheme.bodyMedium, + children: [ + TextSpan( + text: '$versionLabel → ', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + TextSpan( + text: _abbr(hash), + style: const TextStyle(fontFamily: 'monospace'), + ), + ], + ), + ), + ), + IconButton( + icon: const Icon(Icons.copy, size: 16), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + style: IconButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: Size.zero, + ), + tooltip: 'Copy certificate hash', + onPressed: () { + Clipboard.setData(ClipboardData(text: hash)); + context.showInfo('Copied $tooltipText'); + }, + ), + ], + ); + } +} diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index 2cc9083..128acc5 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -1,144 +1,427 @@ -import 'dart:async'; - -import 'package:async_button_builder/async_button_builder.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/screens/settings_screen.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/utils/notifier.dart'; -import 'package:zapstore/widgets/app_card.dart'; +import 'app_card.dart'; class LatestReleasesContainer extends HookConsumerWidget { + const LatestReleasesContainer({ + super.key, + required this.scrollController, + this.showSkeleton = false, + }); + final ScrollController scrollController; - const LatestReleasesContainer({super.key, required this.scrollController}); + final bool showSkeleton; @override Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(latestReleasesAppProvider); + if (showSkeleton) { + return _buildSkeletonState(context); + } + + // Infinite scroll: trigger loadMore when near bottom + useEffect(() { + void onScroll() { + final state = ref.read(latestReleasesProvider); + if (state.isLoadingMore || !state.hasMore) return; + + // Trigger load when 300px from bottom + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 300) { + ref.read(latestReleasesProvider.notifier).loadMore(); + } + } + + scrollController.addListener(onScroll); + return () => scrollController.removeListener(onScroll); + }, [scrollController]); + + final state = ref.watch(latestReleasesProvider); + final storage = state.storage; + + // Get pinned apps with zapstore updates to inject at top + final categorized = ref.watch(categorizedAppsProvider); + final pinnedAppsWithUpdates = [ + ...categorized.automaticUpdates, + ...categorized.manualUpdates, + ].where((a) => a.isZapstoreApp).toList(); + final pinnedIds = pinnedAppsWithUpdates.map((a) => a.id).toSet(); + + // Combine live storage models (newest) with paged older apps, excluding pinned + final allApps = [ + ...storage.models, + ...state.olderApps, + ].where((a) => !pinnedIds.contains(a.id)).toList(); + final combinedApps = [...pinnedAppsWithUpdates, ...allApps]; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + Expanded( + child: Container( + height: 1, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.2), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 6, + ), + child: Text( + 'LATEST RELEASES', + style: context.textTheme.labelLarge?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.85), + letterSpacing: 1.5, + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + child: Container( + height: 1, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.2), + ), + ), + ], + ), + ), + + const SizedBox(height: 8), + + if (storage is StorageLoading || storage.models.isEmpty) + Column( + children: List.generate( + 3, + (index) => const AppCard(isLoading: true), + ), + ) + else if (storage is StorageError) + _buildErrorState(context, storage.exception.toString()) + else + _buildAppsList( + context, + combinedApps, + state.isLoadingMore, + state.hasMore, + ), + ], + ); + } + + Widget _buildErrorState(BuildContext context, String error) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: Column( children: [ - Text('Latest releases', - style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), - ElevatedButton( - onPressed: () { - scrollController.animateTo( - scrollController.position.maxScrollExtent, - duration: Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - }, - style: - ElevatedButton.styleFrom(backgroundColor: Colors.transparent), - child: Text('See more'), - ) + Icon(Icons.error_outline, size: 48, color: Colors.red[400]), + const SizedBox(height: 16), + Text('Error loading apps', style: context.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + error, + style: context.textTheme.bodySmall, + textAlign: TextAlign.center, + ), ], ), - Gap(12), - Column( - children: [ - if (state.hasError) Text('Error fetching: ${state.error}'), - if (state.isLoading) - for (final _ in List.generate(3, (_) => _)) SkeletonAppCard(), - if (state.hasValue) - // NOTE: Since we're showing apps but it's really a list of releases - // apps will appear repeated, to convert to set - for (final app in state.value!) AppCard(model: app), - if (state.hasValue) - AsyncButtonBuilder( - loadingWidget: SmallCircularProgressIndicator(), - onPressed: () async { - return ref - .read(latestReleasesAppProvider.notifier) - .fetchRemote(next: true); - }, - builder: (context, child, callback, state) { - return Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SizedBox( - child: ElevatedButton( - onPressed: callback, - style: ElevatedButton.styleFrom( - disabledBackgroundColor: Colors.transparent, - backgroundColor: Colors.grey[900]), - child: child, - ), - ), - ); - }, - child: Text('Load more'), + ), + ); + } + + Widget _buildAppsList( + BuildContext context, + List apps, + bool isLoadingMore, + bool hasMoreApps, + ) { + return Column( + children: [ + ...apps.map((app) { + return AppCard(app: app, showUpdateArrow: app.hasUpdate); + }), + + const SizedBox(height: 10), + + // Show loading indicator when fetching more + if (isLoadingMore) + Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 3, + color: Theme.of(context).colorScheme.primary, + ), ), - ], - ) + ), + ) + else if (!hasMoreApps && apps.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Center( + child: Text( + 'No more apps to load', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Colors.grey[600]), + ), + ), + ), + + const SizedBox(height: 24), + ], + ); + } + + static Widget _buildSkeletonState(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12), + child: Row( + children: [ + Expanded( + child: Container( + height: 1, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.2), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'LATEST RELEASES', + style: context.textTheme.labelLarge?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.35), + letterSpacing: 1.5, + fontWeight: FontWeight.w300, + ), + ), + ), + Expanded( + child: Container( + height: 1, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.2), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + // Show 3 skeleton app cards + Column(children: List.generate(3, (index) => AppCard(isLoading: true))), ], ); } } -class LatestReleasesAppNotifier extends PreloadingStateNotifier> { - DateTime? _oldestCreatedAt; - int _page = 1; +// Provider and Notifier for latest releases pagination and relationship loading - LatestReleasesAppNotifier(super.ref); +class LatestReleasesState { + final StorageState storage; + final List olderApps; + final bool isLoadingMore; + final bool hasMore; - @override - Future fetchRemote({bool next = false}) async { - if (next) { - _page++; - } + const LatestReleasesState({ + required this.storage, + required this.olderApps, + required this.isLoadingMore, + required this.hasMore, + }); - // Can't use ignoreReturn, as super.findAll does use the result - await ref.apps.findAll( - params: { - 'includes': true, - 'limit': 10, - 'until': next ? _oldestCreatedAt : null, - 'since': null, - }, + factory LatestReleasesState.initial() => LatestReleasesState( + storage: StorageLoading(const []), + olderApps: const [], + isLoadingMore: false, + hasMore: true, + ); + + LatestReleasesState copyWith({ + StorageState? storage, + List? olderApps, + bool? isLoadingMore, + bool? hasMore, + }) { + return LatestReleasesState( + storage: storage ?? this.storage, + olderApps: olderApps ?? this.olderApps, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + hasMore: hasMore ?? this.hasMore, ); - - state = fetchLocal(); - } - - @override - AsyncValue> fetchLocal() { - final models = ref.apps.findAllLocal(); - - if (models.isEmpty) { - return AsyncLoading(); - } - - // Do we have a Zapstore version that needs update? - final zapstoreApp = - models.firstWhereOrNull((a) => a.identifier == kZapstoreAppIdentifier); - - final apps = models - .where((a) => a.latestMetadata != null) - .sortedByLatest - .take(_page * 10) - .toList(); - - // Set timestamp of oldest, to prepare for next query - if (apps.isNotEmpty) { - _oldestCreatedAt = apps.last.event.createdAt; - } - // "Pin" Zapstore if it needs to be updated - if (zapstoreApp != null && zapstoreApp.canUpdate) { - apps.remove(zapstoreApp); - apps.insert(0, zapstoreApp); - } - return AsyncData(apps); } } -final latestReleasesAppProvider = - StateNotifierProvider>>( - LatestReleasesAppNotifier.new); +final latestReleasesProvider = + StateNotifierProvider( + (ref) => LatestReleasesNotifier(ref), + ); + +class LatestReleasesNotifier extends StateNotifier { + LatestReleasesNotifier(this.ref) : super(LatestReleasesState.initial()) { + _startQuery(); + } + + final Ref ref; + static const int _pageSize = 10; + ProviderSubscription>? _sub; + // We keep a fixed live head window from query(); older pages are appended + + void _startQuery() { + _sub?.close(); + + _sub = ref.listen>( + query( + limit: _pageSize, + tags: { + '#f': {'android-arm64-v8a'}, + }, + and: (app) => { + app.latestRelease, + app.latestRelease.value?.latestMetadata, + app.latestRelease.value?.latestAsset, + }, + // NOTE: It must stream=true + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), + andSource: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), // No streaming for relationships + subscriptionPrefix: 'latest', + ), + (previous, next) async { + // Always mirror storage state and ensure olderApps don't duplicate the live head + if (next is StorageData) { + final liveIds = next.models.map((a) => a.id).toSet(); + final filteredOlder = state.olderApps + .where((a) => !liveIds.contains(a.id)) + .toList(); + state = state.copyWith(storage: next, olderApps: filteredOlder); + } else { + state = state.copyWith(storage: next); + } + + if (next is StorageError) { + state = state.copyWith(isLoadingMore: false); + } + }, + fireImmediately: true, + ); + } + + /// Fetch authors for a page of apps (used during pagination) + /// Note: Profiles are now loaded reactively via `query` in individual widgets + Future _loadRelationshipsFor(List appsPage) async { + // No-op: profiles are now loaded reactively via `query` with caching + } + + Future loadMore() async { + final live = state.storage.models; + final combined = [...live, ...state.olderApps]; + if (state.isLoadingMore || !state.hasMore || combined.isEmpty) return; + + final oldest = combined + .map((a) => a.event.createdAt) + .reduce((a, b) => a.isBefore(b) ? a : b) + .subtract(const Duration(milliseconds: 1)); + + state = state.copyWith(isLoadingMore: true); + + try { + // Fetch older apps + final olderPage = await ref.storage.query( + RequestFilter( + until: oldest, + limit: _pageSize, + tags: { + '#f': {'android-arm64-v8a'}, + }, + ).toRequest(), + source: const LocalAndRemoteSource(stream: false), + ); + + if (olderPage.isNotEmpty) { + // Load relationships: releases and their file metadata (same relay group) + final releases = await ref.storage.query( + Request( + olderPage + .map((app) => app.latestRelease.req?.filters.firstOrNull) + .nonNulls + .toList(), + ), + source: const RemoteSource(stream: false), + ); + + // Load file metadata for the releases (old format) + if (releases.isNotEmpty) { + await ref.storage.query( + Request( + releases + .map((r) => r.latestMetadata.req?.filters.firstOrNull) + .nonNulls + .toList(), + ), + source: const RemoteSource(stream: false), + ); + // Load software assets for the releases (new format) + await ref.storage.query( + Request( + releases + .map((r) => r.latestAsset.req?.filters.firstOrNull) + .nonNulls + .toList(), + ), + source: const RemoteSource(stream: false), + ); + } + + // Load release authors from different relay group (social) + await _loadRelationshipsFor(olderPage); + + final existingIds = combined.map((a) => a.id).toSet(); + final uniqueOlder = olderPage + .where((a) => !existingIds.contains(a.id)) + .toList(); + state = state.copyWith( + olderApps: [...state.olderApps, ...uniqueOlder], + isLoadingMore: false, + hasMore: olderPage.length >= _pageSize, + ); + } else { + state = state.copyWith(isLoadingMore: false, hasMore: false); + } + } catch (e) { + state = state.copyWith(isLoadingMore: false); + rethrow; + } + } + + @override + void dispose() { + _sub?.close(); + super.dispose(); + } +} diff --git a/lib/widgets/nwc_widgets.dart b/lib/widgets/nwc_widgets.dart new file mode 100644 index 0000000..bc1ba0d --- /dev/null +++ b/lib/widgets/nwc_widgets.dart @@ -0,0 +1,341 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/services/secure_storage_service.dart'; +import 'package:zapstore/theme.dart'; +import 'package:zapstore/utils/extensions.dart'; + +/// Card showing NWC connection status and management +class NWCConnectionCard extends HookConsumerWidget { + const NWCConnectionCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final hasNwc = ref.watch(hasNwcStringProvider); + final connected = hasNwc.maybeWhen(data: (v) => v, orElse: () => false); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.flash_on, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Lightning Wallet', + style: context.textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 12), + + Text( + 'Use Nostr Wallet Connect (NWC) to zap the developers.', + style: context.textTheme.bodySmall, + ), + + const SizedBox(height: 12), + + // Connection Status + hasNwc.when( + loading: () => Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + ), + ), + const SizedBox(width: 8), + const Text('Checking wallet connection...'), + ], + ), + data: (value) => value + ? Row( + children: [ + Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + size: 16, + ), + const SizedBox(width: 8), + const Expanded( + child: Text('Lightning wallet connected'), + ), + ], + ) + : Row( + children: [ + Icon( + Icons.warning, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + size: 16, + ), + const SizedBox(width: 8), + const Expanded(child: Text('No wallet connected')), + ], + ), + error: (error, _) => Row( + children: [ + Icon( + Icons.error, + color: Theme.of(context).colorScheme.error, + size: 16, + ), + const SizedBox(width: 8), + const Expanded( + child: Text('Error checking wallet connection'), + ), + ], + ), + ), + + const SizedBox(height: 16), + + // Action Buttons + Row( + children: [ + Expanded( + child: TextButton.icon( + onPressed: () => _showNWCDialog(context, ref), + icon: Icon( + Icons.flash_on, + size: 18, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Theme.of(context).colorScheme.primary, + ), + label: Text( + connected ? 'Update Connection' : 'Connect Wallet', + ), + style: TextButton.styleFrom( + backgroundColor: AppColors.darkPillBackground, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + ), + ), + ), + if (connected) ...[ + const SizedBox(width: 8), + FilledButton( + onPressed: () => _removeNWCConnection(context, ref), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + minimumSize: const Size(0, 0), + ), + child: const Icon(Icons.delete_outline, size: 18), + ), + ], + ], + ), + ], + ), + ), + ); + } + + void _showNWCDialog(BuildContext context, WidgetRef ref) async { + final connected = await showDialog( + context: context, + builder: (context) => NWCConnectionDialog(ref: ref), + ); + if (connected == true) { + ref.invalidate(hasNwcStringProvider); + } + } + + void _removeNWCConnection(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove Wallet Connection'), + content: const Text( + 'Are you sure you want to remove your Lightning wallet connection? You won\'t be able to send zaps until you reconnect.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + child: const Text('Remove'), + ), + ], + ), + ); + + if (confirmed == true) { + try { + final secureStorage = ref.read(secureStorageServiceProvider); + await secureStorage.clearNWCString(); + ref.invalidate(hasNwcStringProvider); + if (context.mounted) { + context.showInfo( + 'Wallet disconnected', + description: 'You can reconnect anytime from Settings.', + ); + } + } catch (e) { + if (context.mounted) { + context.showError('Failed to disconnect wallet', description: '$e'); + } + } + } + } +} + +/// Dialog for connecting/updating NWC connection +class NWCConnectionDialog extends HookWidget { + final WidgetRef ref; + + const NWCConnectionDialog({super.key, required this.ref}); + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(); + final isLoading = useState(false); + + return AlertDialog( + title: const Row( + children: [Icon(Icons.flash_on), SizedBox(width: 8), Text('NWC')], + ), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Enter your NWC (Nostr Wallet Connect) connection string from your Lightning wallet:', + style: context.textTheme.bodyMedium, + ), + const SizedBox(height: 16), + TextField( + controller: controller, + decoration: InputDecoration( + labelText: 'NWC Connection String', + hintText: 'nostr+walletconnect://...', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: const Icon(Icons.paste), + onPressed: () async { + final data = await Clipboard.getData('text/plain'); + if (data?.text != null) { + controller.text = data!.text!; + } + }, + ), + ), + maxLines: 3, + enabled: !isLoading.value, + ), + const SizedBox(height: 12), + Text( + 'Compatible wallets: Alby, Coinos, and others that support NWC.', + style: context.textTheme.bodySmall, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: isLoading.value ? null : () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: isLoading.value + ? null + : () => _connectWallet(context, controller.text, isLoading), + style: FilledButton.styleFrom( + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + child: isLoading.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text('Connect'), + ), + ], + ); + } + + Future _connectWallet( + BuildContext context, + String nwcString, + ValueNotifier isLoading, + ) async { + if (nwcString.trim().isEmpty) { + context.showError( + 'Missing connection string', + description: + 'Get a NWC connection string from your Lightning wallet (e.g., Alby, Zeus, Coinos).', + ); + return; + } + + if (!nwcString.trim().startsWith('nostr+walletconnect://')) { + context.showError( + 'Invalid NWC format', + description: + 'Connection string should start with nostr+walletconnect://', + ); + return; + } + + isLoading.value = true; + + try { + final secureStorage = ref.read(secureStorageServiceProvider); + await secureStorage.setNWCString(nwcString.trim()); + + if (context.mounted) { + Navigator.pop(context, true); + context.showInfo('⚡ Lightning wallet connected successfully!'); + } + } catch (e) { + if (context.mounted) { + context.showError( + 'Wallet connection failed', + description: + 'Could not connect to the wallet. Verify the connection string and try again.\n\n$e', + ); + } + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/widgets/pill_widget.dart b/lib/widgets/pill_widget.dart index f686cac..48ecaca 100644 --- a/lib/widgets/pill_widget.dart +++ b/lib/widgets/pill_widget.dart @@ -1,11 +1,18 @@ import 'package:flutter/material.dart'; +/// A pill-shaped widget for displaying text with a colored background +/// Used for tags, categories, and interactive filter chips class PillWidget extends StatelessWidget { final InlineSpan text; final Color color; final double size; - const PillWidget(this.text, - {super.key, this.color = Colors.blue, this.size = 13}); + + const PillWidget( + this.text, { + super.key, + this.color = Colors.blue, + this.size = 13, + }); @override Widget build(BuildContext context) { @@ -15,11 +22,11 @@ class PillWidget extends StatelessWidget { color: color, borderRadius: BorderRadius.circular(size * 1.1), ), - child: RichText( - text: TextSpan( + child: Text.rich( + TextSpan( style: TextStyle( color: Colors.white, - fontSize: size * 1.2, + fontSize: size, fontWeight: FontWeight.bold, fontFamily: 'Inter', ), diff --git a/lib/widgets/profile_header.dart b/lib/widgets/profile_header.dart new file mode 100644 index 0000000..6464fcc --- /dev/null +++ b/lib/widgets/profile_header.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:models/models.dart'; +import '../theme.dart'; +import 'common/profile_avatar.dart'; + +/// Profile header widget with skeleton loading state +class ProfileHeader extends StatelessWidget { + const ProfileHeader({ + super.key, + this.profile, + this.isLoading = false, + this.radius = 40, + this.showBio = true, + this.additionalInfo, + }); + + final Profile? profile; + final bool isLoading; + final double radius; + final bool showBio; + final Widget? additionalInfo; + + @override + Widget build(BuildContext context) { + if (isLoading || profile == null) { + return _buildSkeleton(context); + } + + return Container( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // Profile avatar + ProfileAvatar(profile: profile, radius: radius), + const SizedBox(height: 16), + + // Display name + Text( + profile!.name ?? profile!.nameOrNpub, + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + + if (additionalInfo != null) ...[ + const SizedBox(height: 16), + additionalInfo!, + ], + ], + ), + ); + } + + Widget _buildSkeleton(BuildContext context) { + return SkeletonizerConfig( + data: AppColors.getSkeletonizerConfig(Theme.of(context).brightness), + child: Skeletonizer( + enabled: true, + child: Container( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // Profile avatar skeleton + CircleAvatar( + radius: radius, + backgroundColor: AppColors.darkSkeletonBase, + child: Icon( + Icons.person, + size: radius, + color: AppColors.darkSkeletonHighlight, + ), + ), + const SizedBox(height: 16), + + // Display name skeleton + Container( + height: 28, + width: 200, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + + if (showBio) ...[ + const SizedBox(height: 8), + // Bio skeleton + Column( + children: List.generate( + 2, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Container( + height: 16, + width: index == 1 ? 150 : double.infinity, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + ), + ], + + if (additionalInfo != null) ...[ + const SizedBox(height: 16), + Container( + height: 40, + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/profiles_rich_text.dart b/lib/widgets/profiles_rich_text.dart new file mode 100644 index 0000000..ec46f32 --- /dev/null +++ b/lib/widgets/profiles_rich_text.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/common/profile_avatar.dart'; + +/// Generic widget for displaying a list of user profiles in a readable sentence format +/// Handles multiple users with proper grammar ("John", "John and Jane") +/// Features: +/// - Configurable maximum display count (no automatic "others" text) +/// - Customizable leading/trailing text +/// - Separator mode: either commas-only or comma + "and" before the last item +/// - Inline avatars with names +/// Usage: Social features like "Liked by John and Jane" or follower lists +class ProfilesRichText extends StatelessWidget { + const ProfilesRichText({ + super.key, + this.leadingText, + this.trailingText, + required this.profiles, + this.maxProfilesToDisplay = 6, + this.avatarRadius = 8, + this.textStyle, + this.nameStyle, + this.maxLines, + this.commasOnly = false, + }); + + /// Text to display before the profiles list + final String? leadingText; + + /// Text to display after the profiles list + final String? trailingText; + + /// List of profiles to display + final List profiles; + + /// Maximum number of profiles to display + final int maxProfilesToDisplay; + + /// Radius for profile avatars + final double avatarRadius; + + /// Style for the main text + final TextStyle? textStyle; + + /// Style for profile names (defaults to bold version of textStyle) + final TextStyle? nameStyle; + + /// Maximum lines for the text (null = unlimited, no ellipsis) + final int? maxLines; + + /// When true, separates all names with commas only (", "), including the last two. + /// When false (default), uses a comma between items and " and " before the last item. + final bool commasOnly; + + @override + Widget build(BuildContext context) { + if (profiles.isEmpty && leadingText == null && trailingText == null) { + return const SizedBox.shrink(); + } + + // If no profiles but we have leading/trailing text, show just the text + if (profiles.isEmpty) { + return Text( + '${leadingText ?? ''}${trailingText ?? ''}', + style: + textStyle ?? + context.textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + fontWeight: FontWeight.w400, + ), + maxLines: maxLines, + overflow: maxLines != null + ? TextOverflow.ellipsis + : TextOverflow.visible, + ); + } + + final usersToDisplay = profiles.take(maxProfilesToDisplay).toList(); + final remainingCount = profiles.length - usersToDisplay.length; + + final defaultTextStyle = + textStyle ?? + context.textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + fontWeight: FontWeight.w400, + ); + + final defaultNameStyle = + nameStyle ?? defaultTextStyle?.copyWith(fontWeight: FontWeight.w600); + + final spans = []; + + // Add leading text + if (leadingText != null) { + spans.add(TextSpan(text: leadingText!, style: defaultTextStyle)); + } + + // Add user spans + for (int i = 0; i < usersToDisplay.length; i++) { + final profile = usersToDisplay[i]; + + // Avatar as inline widget aligned to text baseline + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: ProfileAvatar(profile: profile, radius: avatarRadius), + ), + ); + // Small gap between avatar and name + spans.add( + const WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: SizedBox(width: 4), + ), + ); + // Name as TextSpan to preserve baseline for following text + spans.add(TextSpan(text: profile.nameOrNpub, style: defaultNameStyle)); + + // Add separators between displayed users + if (i < usersToDisplay.length - 1) { + final bool useAnd = + !commasOnly && + (i == usersToDisplay.length - 2) && + (remainingCount == 0); + spans.add( + TextSpan(text: useAnd ? ' and ' : ', ', style: defaultTextStyle), + ); + } + } + + // No automatic "others" text; callers can provide desired trailing text + + // Add trailing text + if (trailingText != null) { + spans.add(TextSpan(text: trailingText!, style: defaultTextStyle)); + } + + return Text.rich( + TextSpan(children: spans), + maxLines: maxLines, + overflow: maxLines != null ? TextOverflow.ellipsis : TextOverflow.visible, + ); + } +} diff --git a/lib/widgets/release_card.dart b/lib/widgets/release_card.dart deleted file mode 100644 index 04fd5d7..0000000 --- a/lib/widgets/release_card.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:gap/gap.dart'; -import 'package:intl/intl.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:zapstore/models/release.dart'; - -class ReleaseCard extends StatelessWidget { - ReleaseCard({ - super.key, - required this.release, - }); - - final Release release; - final formatter = DateFormat('dd MMM yyyy'); - - @override - Widget build(BuildContext context) { - final metadata = release.app.value!.latestMetadata; - return Card( - margin: EdgeInsets.only(top: 8, bottom: 8), - elevation: 0, - child: Container( - padding: EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.grey[900], - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(release.version, - style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold)), - Gap(10), - if (DateTime.now().difference(release.event.createdAt) > - Duration(days: 365)) - Padding( - padding: const EdgeInsets.all(8), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Icon(Icons.warning_rounded, size: 18), - Gap(10), - Text('This release is over a year old', - style: TextStyle( - color: Colors.red[100], - fontWeight: FontWeight.bold, - )), - ], - ), - ), - Gap(10), - release.releaseNotes.length < 3000 - ? MarkdownBody( - data: release.releaseNotes, - onTapLink: (text, url, title) { - if (url != null) { - launchUrl(Uri.parse(url)); - } - }, - ) - : Container( - constraints: BoxConstraints( - maxHeight: 300, - ), - child: Markdown(data: release.releaseNotes), - ), - Gap(30), - if (metadata != null) - Padding( - padding: const EdgeInsets.all(8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [Text('Version'), Text(metadata.version!)], - ), - ), - if (metadata != null) - Padding( - padding: const EdgeInsets.all(8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Size'), - Text('${metadata.size! ~/ 1024 ~/ 1024} MB') - ], - ), - ), - Padding( - padding: const EdgeInsets.all(8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Date'), - Text(formatter.format(release.event.createdAt)), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/relevant_who_follow_container.dart b/lib/widgets/relevant_who_follow_container.dart index 357a0bd..359d2ca 100644 --- a/lib/widgets/relevant_who_follow_container.dart +++ b/lib/widgets/relevant_who_follow_container.dart @@ -1,72 +1,394 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:purplebase/purplebase.dart' as base; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/screens/settings_screen.dart'; -import 'package:zapstore/widgets/users_rich_text.dart'; +import 'package:models/models.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/common/profile_avatar.dart'; +import 'package:zapstore/widgets/profiles_rich_text.dart'; class RelevantWhoFollowContainer extends HookConsumerWidget { const RelevantWhoFollowContainer({ super.key, - required this.toNpub, + required this.app, this.loadingText = 'Checking reputation...', + this.size, }); final String loadingText; - final String toNpub; + final App app; + final double? size; @override Widget build(BuildContext context, WidgetRef ref) { - final signedInUser = ref.watch(signedInUserProvider); - final data = ref.watch(relevantWhoFollowProvider((to: toNpub))); + final baseStyle = (size != null + ? context.textTheme.bodyMedium?.copyWith( + fontSize: size, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + ) + : context.textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + )); + final boldStyle = baseStyle?.copyWith(fontWeight: FontWeight.w600); - if (signedInUser == null) { - return Container(); + // Get signed-in user's pubkey + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + + // Query author profile from local storage (should already be preloaded) + final authorState = ref.watch( + query(authors: {app.pubkey}, source: const LocalSource()), + ); + final author = authorState.models.firstOrNull; + + // If author not loaded yet, show loading + if (author == null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(loadingText, style: baseStyle), + const Gap(8), + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 3), + ), + ], + ), + ), + ); } - return switch (data) { - AsyncData>(value: final users) => Builder( + final targetNpub = author.npub; + + // Query preloaded zaps for the app (LocalSource only) + final zapsState = ref.watch( + query( + tags: app.event.addressableIdTagMap, + source: const LocalSource(), + and: (zap) => {zap.author, zap.zapRequest}, + subscriptionPrefix: 'trust-zaps', + ), + ); + + // Also query zaps on the latest metadata if available + final latestMetadata = app.latestFileMetadata; + final metadataZapsState = latestMetadata != null + ? ref.watch( + query( + tags: { + '#e': {latestMetadata.id}, + }, + source: const LocalSource(), + and: (zap) => {zap.author, zap.zapRequest}, + subscriptionPrefix: 'trust-metadata-zaps', + ), + ) + : null; + + // Combine all zaps + final allZaps = {...zapsState.models, ...?metadataZapsState?.models}; + + final data = ref.watch(relevantWhoFollowProvider((to: targetNpub))); + + Widget buildBody({ + required bool userFollowsTarget, + required Set? followingPubkeys, + }) { + // Extract zap author profiles + // 1. Check if signed-in user zapped + // 2. Get zappers that the user follows + bool userZapped = false; + final followedZappers = []; + final seenPubkeys = {}; + + if (allZaps.isNotEmpty) { + for (final zap in allZaps) { + final requestAuthor = zap.zapRequest.value?.author.value; + final walletAuthor = zap.author.value; + final chosenAuthor = requestAuthor ?? walletAuthor; + if (chosenAuthor == null) continue; + + final pubkey = chosenAuthor.pubkey; + + // Check if signed-in user zapped + if (signedInPubkey != null && pubkey == signedInPubkey) { + userZapped = true; + continue; // Don't add to followedZappers list + } + + // Add followed zappers (exclude duplicates) + if (followingPubkeys != null && + followingPubkeys.contains(pubkey) && + !seenPubkeys.contains(pubkey)) { + seenPubkeys.add(pubkey); + followedZappers.add(chosenAuthor); + } + } + } + + return switch (data) { + AsyncData>(value: final users) => Builder( builder: (context) { // Find relevant who follow, remove the target that comes included - final relevantWhoFollow = - users.where((u) => u.pubkey.npub != toNpub).toList(); - if (relevantWhoFollow.isEmpty) { + final relevantWhoFollow = users + .take(5) + .where( + (u) => u.pubkey.encodeShareable(type: 'npub') != targetNpub, + ) + .toList(); + + if (relevantWhoFollow.isEmpty && + !userFollowsTarget && + followedZappers.isEmpty) { return Text( - 'No reputable profiles follow the signer. Make sure you know the signer.'); + 'No reputation information found for this publisher.', + style: baseStyle, + ); } - return UsersRichText( - users: relevantWhoFollow, - signedInUser: signedInUser, - onlyUseCommaSeparator: true, - trailingText: ' and others follow this signer on nostr.', + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Main sentence: "(App) is published by (profile) (view profile link) and zapped by ..." + _buildPublishedBySection( + context, + author, + baseStyle, + boldStyle, + userZapped: userZapped, + followedZappers: followedZappers.take(4).toList(), + ), + const Gap(16), + // Reputation section + if (relevantWhoFollow.isNotEmpty || userFollowsTarget) ...[ + ProfilesRichText( + profiles: relevantWhoFollow, + leadingText: userFollowsTarget ? 'You, ' : null, + commasOnly: true, + trailingText: + ' and others follow ${author.nameOrNpub} on Nostr.', + textStyle: baseStyle, + avatarRadius: 10, + ), + const Gap(8), + ] else if (!userFollowsTarget) ...[ + Text('You are not following them.', style: baseStyle), + const Gap(8), + ], + // Disclaimer + Text( + 'These are not endorsements of the ${app.name} app.', + style: baseStyle?.copyWith(fontStyle: FontStyle.italic), + ), + ], ); }, ), - AsyncError(:final error) => Center(child: Text(error.toString())), - // Loading state - _ => Center( + AsyncError(:final error) => Center( + child: Text( + error.toString().replaceFirst('Exception: ', ''), + style: baseStyle, + ), + ), + // Loading state + _ => Center( child: Padding( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(16), child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Text(loadingText), - Gap(10), - SmallCircularProgressIndicator(), + Text(loadingText, style: baseStyle), + const Gap(8), + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 3), + ), ], ), ), - ) - }; + ), + }; + } + + if (signedInPubkey == null) { + return buildBody(userFollowsTarget: false, followingPubkeys: null); + } + + return Consumer( + builder: (context, ref, _) { + final contactListState = ref.watch( + query( + authors: {signedInPubkey}, + limit: 1, + source: const LocalAndRemoteSource(relays: 'social', stream: false), + subscriptionPrefix: 'user-contacts', + ), + ); + + final targetPubkey = targetNpub.decodeShareable(); + final contactList = contactListState.models.firstOrNull; + final followingPubkeys = contactList?.followingPubkeys; + final userFollowsTarget = + followingPubkeys?.contains(targetPubkey) ?? false; + + return buildBody( + userFollowsTarget: userFollowsTarget, + followingPubkeys: followingPubkeys, + ); + }, + ); + } + + Widget _buildPublishedBySection( + BuildContext context, + Profile author, + TextStyle? baseStyle, + TextStyle? boldStyle, { + required bool userZapped, + required List followedZappers, + }) { + final targetNpub = author.npub; + final avatarSize = (size ?? 14) * 1.4; + final hasZapInfo = userZapped || followedZappers.isNotEmpty; + + final spans = [ + TextSpan(text: '${app.name} is published by ', style: baseStyle), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: avatarSize, + height: avatarSize, + child: ProfileAvatar(profile: author, radius: avatarSize / 2), + ), + ), + ), + TextSpan(text: author.nameOrNpub, style: boldStyle), + TextSpan(text: ' (', style: baseStyle), + TextSpan( + text: 'view profile on Nostr', + style: baseStyle?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () async { + final url = Uri.parse('https://npub.world/$targetNpub'); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } + }, + ), + TextSpan(text: ')', style: baseStyle), + ]; + + // Add zap info if present: " and was zapped by you and (z1)" + if (hasZapInfo) { + spans.add(TextSpan(text: ' and was zapped by ', style: baseStyle)); + + // Add "you" if user zapped + if (userZapped) { + spans.add(TextSpan(text: 'you', style: boldStyle)); + // Add separator: "and" if only 1 more, ", " if more than 1 more + if (followedZappers.length == 1) { + spans.add(TextSpan(text: ' and ', style: baseStyle)); + } else if (followedZappers.length > 1) { + spans.add(TextSpan(text: ', ', style: baseStyle)); + } + } + + // Add followed zappers with avatars + for (int i = 0; i < followedZappers.length; i++) { + final zapper = followedZappers[i]; + final isLast = i == followedZappers.length - 1; + final isSecondToLast = i == followedZappers.length - 2; + + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: avatarSize, + height: avatarSize, + child: ProfileAvatar(profile: zapper, radius: avatarSize / 2), + ), + ), + ), + ); + spans.add(TextSpan(text: zapper.nameOrNpub, style: boldStyle)); + + // Add separator + if (!isLast) { + if (isSecondToLast && (!userZapped || followedZappers.length == 1)) { + // "and" before last item when no user prefix or only 2 total + spans.add(TextSpan(text: ' and ', style: baseStyle)); + } else if (isSecondToLast) { + // ", and" (Oxford comma) before last when user + multiple + spans.add(TextSpan(text: ', and ', style: baseStyle)); + } else { + spans.add(TextSpan(text: ', ', style: baseStyle)); + } + } + } + } + + spans.add(TextSpan(text: '.', style: baseStyle)); + + return Text.rich(TextSpan(children: spans)); } } final relevantWhoFollowProvider = FutureProvider.autoDispose - .family, ({String to})>((ref, arg) async { - final signedInUser = ref.watch(signedInUserProvider); - final users = await ref.users.userAdapter - .getRelevantWhoFollow(signedInUser!.npub, arg.to); - return users.toSet().toList(); -}); + .family, ({String to})>((ref, arg) async { + // Require active signer and profile to submit the request + final signer = ref.read(Signer.activeSignerProvider); + final pubkey = ref.watch(Signer.activePubkeyProvider); + if (signer == null || pubkey == null) { + throw 'Not signed in'; + } + + // Build and sign a reputation verification request + final partial = PartialVerifyReputationRequest( + source: pubkey.encodeShareable(type: 'npub'), + target: arg.to, + limit: 7, + ); + final request = await partial.signWith(signer); + + // Execute against the 'vertex' relay group and await response + final response = await request.run('vertex'); + + if (response is VerifyReputationResponse) { + // Fetch corresponding profiles for the returned pubkeys + final storage = ref.read(storageNotifierProvider.notifier); + final profiles = await storage.query( + Request([RequestFilter(authors: response.pubkeys)]), + source: const LocalAndRemoteSource(relays: 'social', stream: false), + ); + return profiles; + } + + // Handle DVM error responses + if (response is DVMError) { + throw Exception(response.status ?? 'DVM service error'); + } + + // Handle null (no response received - timeout or connection issue) + if (response == null) { + throw Exception('No response from reputation service'); + } + + throw Exception('Unexpected response from service'); + }); diff --git a/lib/widgets/rounded_image.dart b/lib/widgets/rounded_image.dart index 8aae573..981f974 100644 --- a/lib/widgets/rounded_image.dart +++ b/lib/widgets/rounded_image.dart @@ -1,6 +1,9 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:zapstore/utils/url_utils.dart'; +/// A rounded image widget with fallback to person icon +/// Used for user avatars and app icons with consistent styling class RoundedImage extends StatelessWidget { const RoundedImage({ super.key, @@ -15,6 +18,7 @@ class RoundedImage extends StatelessWidget { @override Widget build(BuildContext context) { + final sanitizedUrl = sanitizeHttpUrl(url); final fallbackContainer = Container( height: size, width: size, @@ -27,12 +31,13 @@ class RoundedImage extends StatelessWidget { ); return ClipRRect( borderRadius: BorderRadius.circular(radius.toDouble()), - child: url == null + child: sanitizedUrl == null ? fallbackContainer - : (url!.endsWith('svg') || url!.endsWith('xml') + : (sanitizedUrl.toLowerCase().endsWith('svg') || + sanitizedUrl.toLowerCase().endsWith('xml') ? fallbackContainer : CachedNetworkImage( - imageUrl: url!, + imageUrl: sanitizedUrl, errorWidget: (_, __, ___) => fallbackContainer, useOldImageOnUrlChange: false, fit: BoxFit.cover, diff --git a/lib/widgets/screenshots_gallery.dart b/lib/widgets/screenshots_gallery.dart new file mode 100644 index 0000000..147a7dc --- /dev/null +++ b/lib/widgets/screenshots_gallery.dart @@ -0,0 +1,140 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:easy_image_viewer/easy_image_viewer.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/utils/url_utils.dart'; +// Local shimmer duplicated to avoid private import + +class ScreenshotsGallery extends HookWidget { + const ScreenshotsGallery({super.key, required this.app}); + + final App app; + + @override + Widget build(BuildContext context) { + final imageUrls = filterValidHttpUrls(app.images); + if (imageUrls.isEmpty) return const SizedBox.shrink(); + + final scrollController = useScrollController(); + useListenable(scrollController); + + return SizedBox( + height: 200, + child: ListView.builder( + controller: scrollController, + padding: EdgeInsets.zero, + scrollDirection: Axis.horizontal, + itemCount: imageUrls.length, + itemBuilder: (context, index) { + final imageUrl = imageUrls[index]; + return GestureDetector( + onTap: () => _showImageViewer(context, imageUrls, index), + child: Container( + width: 120, + margin: EdgeInsets.only(left: index == 0 ? 16 : 0, right: 6), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + fadeInDuration: const Duration(milliseconds: 300), + fadeOutDuration: const Duration(milliseconds: 150), + errorWidget: (context, url, error) => Center( + child: Icon( + Icons.broken_image_outlined, + size: 24, + color: Colors.grey[400], + ), + ), + placeholder: (context, url) => _buildGradientLoader(context), + ), + ), + ), + ); + }, + ), + ); + } + + Widget _buildGradientLoader(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF2A2A2A), Color(0xFF3A3A3A), Color(0xFF2A2A2A)], + stops: [0.0, 0.5, 1.0], + ), + ), + child: _LocalShimmerEffect(context: context), + ); + } + + void _showImageViewer( + BuildContext context, + List imageUrls, + int initialIndex, + ) { + final imageProviders = imageUrls + .map((url) => CachedNetworkImageProvider(url) as ImageProvider) + .toList(); + + showImageViewerPager( + context, + MultiImageProvider(imageProviders, initialIndex: initialIndex), + onPageChanged: (page) {}, + onViewerDismissed: (page) {}, + swipeDismissible: false, + doubleTapZoomable: true, + immersive: false, + useSafeArea: true, + ); + } +} + +class _LocalShimmerEffect extends HookWidget { + final BuildContext context; + const _LocalShimmerEffect({required this.context}); + + @override + Widget build(BuildContext context) { + final controller = useAnimationController( + duration: const Duration(milliseconds: 1500), + )..repeat(); + final animation = useMemoized( + () => Tween( + begin: -1.0, + end: 2.0, + ).animate(CurvedAnimation(parent: controller, curve: Curves.easeInOut)), + [controller], + ); + + const shimmerColors = [ + Color(0xFF2A2A2A), + Color(0xFF3A3A3A), + Color(0xFF2A2A2A), + ]; + + return AnimatedBuilder( + animation: animation, + builder: (context, child) { + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: shimmerColors, + stops: [ + (animation.value - 0.3).clamp(0.0, 1.0), + animation.value.clamp(0.0, 1.0), + (animation.value + 0.3).clamp(0.0, 1.0), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/sign_in_button.dart b/lib/widgets/sign_in_button.dart new file mode 100644 index 0000000..d1a25e4 --- /dev/null +++ b/lib/widgets/sign_in_button.dart @@ -0,0 +1,91 @@ +import 'package:async_button_builder/async_button_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:zapstore/constants/app_constants.dart'; +import 'package:zapstore/main.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/utils/debug_utils.dart'; + +class SignInButton extends ConsumerWidget { + const SignInButton({ + super.key, + this.label = 'Sign in with Amber', + this.minimal = false, + this.requireNip55 = true, + }); + + final String label; + final bool minimal; + final bool requireNip55; // kept for API compatibility; currently unused + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pmState = ref.watch(packageManagerProvider); + final isAmberInstalled = pmState.installed.containsKey(kAmberPackageId); + + return AsyncButtonBuilder( + onPressed: () async { + if (!isAmberInstalled) { + context.showInfo( + 'Install Amber to sign in with your Nostr identity', + actions: [ + ( + 'Open Amber', + () async => context.push('/search/app/$kAmberNaddr'), + ), + ], + ); + } else { + try { + await ref.read(amberSignerProvider).signIn(); + onSignInSuccess(ref.read(refProvider)); + } catch (e) { + if (context.mounted) { + context.showError('Sign-in failed: $e'); + } + } + } + }, + builder: (context, child, callback, state) { + final onPressed = state.maybeWhen( + loading: () => null, + orElse: () => callback, + ); + final loading = state.maybeWhen( + loading: () => true, + orElse: () => false, + ); + + final buttonChild = loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.login, size: 18), + const SizedBox(width: 8), + Flexible( + child: Text( + label, + overflow: TextOverflow.visible, + softWrap: true, + ), + ), + ], + ); + + if (minimal) { + return TextButton(onPressed: onPressed, child: buttonChild); + } else { + return FilledButton(onPressed: onPressed, child: buttonChild); + } + }, + child: const SizedBox.shrink(), + ); + } +} diff --git a/lib/widgets/sign_in_container.dart b/lib/widgets/sign_in_container.dart deleted file mode 100644 index 6d0d7bd..0000000 --- a/lib/widgets/sign_in_container.dart +++ /dev/null @@ -1,232 +0,0 @@ -import 'package:async_button_builder/async_button_builder.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_data/flutter_data.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:purplebase/purplebase.dart' as base; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/settings.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/navigation/app_initializer.dart'; -import 'package:zapstore/screens/settings_screen.dart'; -import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/widgets/rounded_image.dart'; - -class SignInButton extends ConsumerWidget { - final bool minimal; - final String label; - final bool requireNip55; - - SignInButton({ - super.key, - this.minimal = false, - this.label = 'Sign in', - this.requireNip55 = false, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - var user = ref.watch(signedInUserProvider); - final signedInWithPubkey = - user?.settings.value?.signInMethod == SignInMethod.pubkey; - if (requireNip55 && signedInWithPubkey) { - user = null; - } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (!minimal) RoundedImage(url: user?.avatarUrl, size: 46), - if (!minimal) Gap(10), - if (user != null) - Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - spacing: 4, - children: [ - Text( - user.nameOrNpub, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontWeight: FontWeight.bold), - ), - if (user.settings.value!.signInMethod == - SignInMethod.nip55) - Icon(Icons.diamond, color: Colors.lightBlue, size: 18), - ], - ), - ], - ), - ], - ), - Gap(10), - ElevatedButton( - onPressed: () async { - if (user == null) { - showDialog( - context: context, - builder: (_) => AlertDialog( - title: Text( - label, - style: TextStyle(fontWeight: FontWeight.bold), - ), - content: SignInDialogBox( - publicKeyAllowed: !requireNip55, - ), - ), - ); - } else { - ref.settings.findOneLocalById('_')!.user.value = null; - } - }, - child: Text(user == null ? label : 'Sign out'), - ), - ], - ); - } -} - -class SignInDialogBox extends HookConsumerWidget { - final bool publicKeyAllowed; - const SignInDialogBox({super.key, this.publicKeyAllowed = true}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final controller = useTextEditingController(); - final isTextFieldEmpty = useState(true); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (amberSigner.isAvailable) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AsyncButtonBuilder( - loadingWidget: SmallCircularProgressIndicator(), - builder: (context, child, callback, buttonState) { - return Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SizedBox( - child: ElevatedButton( - onPressed: callback, - style: ElevatedButton.styleFrom( - disabledBackgroundColor: Colors.transparent, - backgroundColor: Colors.transparent), - child: child, - ), - ), - ); - }, - onPressed: () async { - if (!amberSigner.isAvailable) { - Navigator.of(context).pop(); - } - final signedInNpub = await amberSigner.getPublicKey(); - if (signedInNpub == null) { - if (context.mounted) { - Navigator.of(context).pop(); - context.showError('Could not sign in', - description: 'Signer did not respond'); - } - return; - } - - var user = await ref.users.findOne(signedInNpub); - - // If user was not found on relays, we create a - // local user to represent this new npub - user ??= User.fromPubkey(signedInNpub.hexKey) - .init() - .saveLocal(); - - final settings = ref.settings.findOneLocalById('_')!; - settings.signInMethod = SignInMethod.nip55; - settings.user.value = user; - settings.saveLocal(); - - if (context.mounted) { - Navigator.of(context).pop(user); - } - }, - child: Text('Sign in with Amber'), - ), - ], - ), - if (amberSigner.isAvailable && publicKeyAllowed) - Padding( - padding: const EdgeInsets.all(10), - child: Text('or'), - ), - if (publicKeyAllowed) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Input an npub or nostr address\n(read-only):'), - TextField( - autocorrect: false, - controller: controller, - onChanged: (value) { - isTextFieldEmpty.value = value.isEmpty; - }, - ), - Gap(10), - AsyncButtonBuilder( - disabled: isTextFieldEmpty.value, - loadingWidget: SmallCircularProgressIndicator(), - onPressed: () async { - try { - final input = controller.text.trim(); - if (input.startsWith('nsec')) { - controller.clear(); - throw Exception('Never give away your nsec!'); - } - final user = await ref.users.findOne(input); - - final settings = ref.settings.findOneLocalById('_')!; - settings.signInMethod = SignInMethod.pubkey; - settings.user.value = user; - settings.saveLocal(); - - if (context.mounted) { - Navigator.pop(context); - } - } on Exception catch (e, stack) { - if (context.mounted) { - context.showError(e.toString(), - description: stack.toString().safeSubstring(200)); - } - } - }, - builder: (context, child, callback, buttonState) { - return Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SizedBox( - child: ElevatedButton( - onPressed: callback, - style: ElevatedButton.styleFrom( - disabledBackgroundColor: Colors.transparent, - backgroundColor: Colors.transparent), - child: child, - ), - ), - ); - }, - child: Text('Sign in with public key'), - ), - ], - ), - ], - ), - ], - ); - } -} diff --git a/lib/widgets/signer_container.dart b/lib/widgets/signer_container.dart deleted file mode 100644 index b5d46d9..0000000 --- a/lib/widgets/signer_container.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/widgets/author_container.dart'; - -class SignerContainer extends ConsumerWidget { - const SignerContainer({ - super.key, - required this.app, - }); - - final App app; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final settingsState = ref.settings.watchOne('_', remote: false); - - if (!app.signer.isPresent) { - return Container(); - } - - return GestureDetector( - onTap: () async { - if (app.isSelfSigned) { - context.push('/developer', extra: app.signer.value); - } else { - final url = Uri.parse('https://npub.world/${app.signer.value!.npub}'); - launchUrl(url); - } - }, - child: Row( - children: [ - AuthorContainer( - user: app.signer.value!, - beforeText: 'Signed by', - oneLine: false, - afterText: settingsState.hasModel && - settingsState.model!.trustedUsers - .contains(app.signer.value!) - ? '(trusted)' - : null, - ), - ], - ), - ); - } -} diff --git a/lib/widgets/spinning_logo.dart b/lib/widgets/spinning_logo.dart deleted file mode 100644 index 860de33..0000000 --- a/lib/widgets/spinning_logo.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/material.dart'; - -class SpinningLogo extends StatefulWidget { - final double size; - const SpinningLogo({super.key, this.size = 200}); - - @override - SpinningLogoState createState() => SpinningLogoState(); -} - -class SpinningLogoState extends State - with TickerProviderStateMixin { - late AnimationController _controller; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: Duration(seconds: 2), - )..repeat(); // Repeat the animation indefinitely - } - - @override - void dispose() { - _controller.dispose(); // Dispose of the controller when done - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Center( - child: AnimatedBuilder( - animation: _controller, - builder: (context, child) { - return Transform.rotate( - angle: - _controller.value * 2.0 * 3.14159, // Full rotation in radians - child: child, - ); - }, - child: Image.asset( - 'assets/images/logo-fg.png', - height: widget.size, - width: widget.size, - ), // Adjust size as needed - ), - ); - } -} diff --git a/lib/widgets/user_avatar.dart b/lib/widgets/user_avatar.dart deleted file mode 100644 index 2445004..0000000 --- a/lib/widgets/user_avatar.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/widgets/rounded_image.dart'; - -class UserAvatar extends HookConsumerWidget { - const UserAvatar({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final signedInUser = ref.watch(signedInUserProvider); - return RoundedImage(url: signedInUser?.avatarUrl, size: 46); - } -} diff --git a/lib/widgets/users_rich_text.dart b/lib/widgets/users_rich_text.dart deleted file mode 100644 index 45555df..0000000 --- a/lib/widgets/users_rich_text.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/widgets/rounded_image.dart'; - -class UsersRichText extends StatelessWidget { - final TextSpan? leadingTextSpan; - final String? trailingText; - final List users; - final User? signedInUser; - final int? maxUsersToDisplay; - final double fontSize; - final bool onlyUseCommaSeparator; - - const UsersRichText({ - super.key, - this.leadingTextSpan, - this.trailingText, - required this.users, - this.signedInUser, - this.maxUsersToDisplay, - this.fontSize = 15, - this.onlyUseCommaSeparator = false, - }); - - @override - Widget build(BuildContext context) { - // Move signed in user to head of list - if (users.remove(signedInUser)) { - users.insert(0, signedInUser!); - } - final usersToDisplay = maxUsersToDisplay != null - ? users.take(maxUsersToDisplay!).toList() - : users; - - final spans = []; - - if (leadingTextSpan != null) { - spans.add(leadingTextSpan!); - spans.add(TextSpan(text: ' ')); - } - - usersToDisplay.forEachIndexed((i, user) { - if (user == signedInUser) { - spans.add(TextSpan( - text: leadingTextSpan != null ? 'you' : 'You', - style: TextStyle(fontWeight: FontWeight.bold))); - } else { - spans.add( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - // Add a wrap in order to treat it as a non-breaking block - child: Wrap( - children: [ - Padding( - padding: const EdgeInsets.only(right: 4), - child: RoundedImage(url: user.avatarUrl, size: 20), - ), - Text( - user.nameOrNpub, - style: TextStyle( - fontSize: fontSize, - fontWeight: FontWeight.bold, - height: 1.25, - ), - ), - ], - ), - ), - ); - } - - // Before the last span - if (i < usersToDisplay.length - 2) { - spans.add(TextSpan(text: ', ')); - } - - if (i == usersToDisplay.length - 2) { - // If onlyUseCommaSeparator, or users length goes over max to display, - // then add a comma because we'll later add "and others" - if (onlyUseCommaSeparator || usersToDisplay.length < users.length) { - spans.add(TextSpan(text: ', ')); - } else { - spans.add(TextSpan(text: ' and ')); - } - } - - // Last span - if (i == usersToDisplay.length - 1) { - if (usersToDisplay.length < users.length) { - final remainingUsersLength = users.length - usersToDisplay.length; - spans.add(TextSpan(text: ' and $remainingUsersLength others')); - } - if (trailingText != null) { - spans.add(TextSpan(text: trailingText)); - } - } - }); - - return Text.rich( - TextSpan( - style: TextStyle(fontSize: fontSize, height: 1.7), - children: spans, - ), - ); - } -} diff --git a/lib/widgets/version_pill_widget.dart b/lib/widgets/version_pill_widget.dart index 9349342..c4fd368 100644 --- a/lib/widgets/version_pill_widget.dart +++ b/lib/widgets/version_pill_widget.dart @@ -1,82 +1,202 @@ import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/local_app.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/widgets/install_button.dart'; -import 'package:zapstore/widgets/pill_widget.dart'; +import '../services/package_manager/package_manager.dart'; +import '../theme.dart'; + +/// Version pill widget showing the current release version +/// Displays version in a colored pill format similar to the old design +/// Can also show dual versions (installed vs available) for updates +class VersionPillWidget extends HookConsumerWidget { + final App app; + final bool showUpdateArrow; + final String? forceVersion; -class VersionPillWidget extends StatelessWidget { const VersionPillWidget({ super.key, required this.app, + this.showUpdateArrow = false, + this.forceVersion, }); - final App app; - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + // Watch package manager state for reactivity + ref.watch( + packageManagerProvider.select((s) => s.installed[app.identifier]), + ); + + // Resolve installed and available versions using AppExt + final installedVersion = app.installedPackage?.version; + final availableVersion = app.latestFileMetadata?.version; + final updateAvailable = app.hasUpdate; + final downgradeAvailable = app.hasDowngrade; + + // Dual version mode (when an update OR downgrade is available) + if (showUpdateArrow && + (updateAvailable || downgradeAvailable) && + installedVersion != null && + availableVersion != null) { + return _buildDualVersionPills( + context, + installedVersion, + availableVersion, + isDowngrade: downgradeAvailable, + ); + } + + // Single version mode: use forced version if provided, otherwise show installed version if installed, otherwise available version + final version = forceVersion ?? installedVersion ?? availableVersion; + + if (version == null || version.isEmpty) { + return const SizedBox.shrink(); + } + + return _buildVersionPill( + context, + version, + AppColors.darkPillBackground, + Colors.white, + ); + } + + Widget _buildDualVersionPills( + BuildContext context, + String installedVersion, + String availableVersion, { + bool isDowngrade = false, + }) { + // Get version codes + final installedVersionCode = app.installedPackage?.versionCode; + final availableVersionCode = app.latestFileMetadata?.versionCode; + + // When version strings are equal but version codes differ, show version codes in parentheses + final showVersionCodes = + installedVersion == availableVersion && + installedVersionCode != null && + availableVersionCode != null && + installedVersionCode != availableVersionCode; + return Row( + mainAxisSize: MainAxisSize.min, children: [ - if (app.canUpdate) - PillWidget( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - app.localApp.value!.installedVersion!.removeParenthesis(), - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 11, - ), - ), - ], - ), - ), - size: 10, - color: Colors.grey[800]!, + // Current version pill (muted colors for installed version) + Flexible( + child: _buildVersionPill( + context, + installedVersion, + Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), + Theme.of(context).colorScheme.onSurface, + isInstalledVersion: true, + versionCode: showVersionCodes ? installedVersionCode : null, ), - if (app.canUpdate) Icon(Icons.arrow_right), - if (app.latestMetadata != null) - PillWidget( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - app.latestMetadata!.version!.removeParenthesis(), - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 11, - ), - ), - Row( - children: [ - Gap(5), - if (app.canUpdate) Icon(Icons.update_outlined, size: 15), - if (app.canInstall) - Icon(Icons.download_rounded, size: 15), - if (app.isUpdated) Icon(Icons.check, size: 15), - if (app.isDowngrade) Icon(Icons.do_not_disturb, size: 15), - if (app.hasCertificateMismatch) - Icon(Icons.do_not_disturb, size: 15), - ], - ), - ], - ), - ), - size: 10, - color: switch (app.localApp.value?.status) { - AppInstallStatus.downgrade || - AppInstallStatus.certificateMismatch => - Colors.grey[800]!, - _ => kUpdateColor, - }, + ), + + // Arrow icon + Icon( + Icons.arrow_right, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + + // Available version pill (highlighted for update, greyed for downgrade) + Flexible( + child: _buildVersionPill( + context, + availableVersion, + isDowngrade + ? Theme.of(context).colorScheme.outline.withValues(alpha: 0.3) + : AppColors.darkPillBackground, + isDowngrade + ? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5) + : Colors.white, + isDowngrade: isDowngrade, + versionCode: showVersionCodes ? availableVersionCode : null, ), + ), ], ); } + + Widget _buildVersionPill( + BuildContext context, + String version, + Color backgroundColor, + Color textColor, { + bool isInstalledVersion = false, + bool isDowngrade = false, + int? versionCode, + }) { + // Build display version with optional version code in parentheses + String displayVersion = _displayVersion(version, versionCode: versionCode); + + // Determine app status for icon + Widget? statusIcon; + if (!isInstalledVersion) { + if (isDowngrade) { + // Downgrade forbidden + statusIcon = Icon(Icons.block, size: 12, color: textColor); + } else if (!app.isInstalled) { + // Can install + statusIcon = const Icon( + Icons.download_rounded, + size: 12, + color: Colors.white, + ); + } else { + if (app.hasUpdate) { + // Can update + statusIcon = const Icon( + Icons.update_outlined, + size: 12, + color: Colors.white, + ); + } else { + // Is updated + statusIcon = const Icon(Icons.check, size: 12, color: Colors.white); + } + } + } + + final double verticalPadding = isInstalledVersion ? 5.0 * 1.05 : 5.0; + + return Container( + padding: EdgeInsets.symmetric(horizontal: 9, vertical: verticalPadding), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + displayVersion, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + color: textColor, + ), + ), + ), + if (statusIcon != null) ...[ + const SizedBox(width: 4), + IconTheme.merge( + data: const IconThemeData(size: 12), + child: statusIcon, + ), + ], + ], + ), + ); + } + + String _displayVersion(String version, {int? versionCode}) { + if (versionCode != null) { + return '$version ($versionCode)'; + } + return version; + } } diff --git a/lib/widgets/versioned_app_header.dart b/lib/widgets/versioned_app_header.dart deleted file mode 100644 index 2ab5391..0000000 --- a/lib/widgets/versioned_app_header.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/widgets/rounded_image.dart'; -import 'package:zapstore/widgets/version_pill_widget.dart'; - -class VersionedAppHeader extends StatelessWidget { - const VersionedAppHeader({ - super.key, - required this.app, - }); - - final App app; - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - RoundedImage( - url: app.icons.firstOrNull, - size: 80, - radius: 25, - ), - Gap(16), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Gap(6), - AutoSizeText( - app.name!, - minFontSize: 16, - style: TextStyle(fontSize: 21, fontWeight: FontWeight.bold), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Gap(10), - VersionPillWidget(app: app), - // Row( - // children: [ - // if (app.localApp.value?.installedVersion != null) - // PillWidget( - // text: - // TextSpan(text: app.localApp.value!.installedVersion!), - // color: Colors.grey[800]!, - // size: 11, - // ), - // Icon(Icons.arrow_right), - // if (app.canUpdate) - // PillWidget( - // text: TextSpan(text: app.latestMetadata!.version!), - // color: Colors.blue[700]!, - // size: 11, - // ), - // if (app.canInstall) , - // ], - // ), - ], - ), - ), - ], - ); - } -} diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart deleted file mode 100644 index 8ef056b..0000000 --- a/lib/widgets/zap_button.dart +++ /dev/null @@ -1,208 +0,0 @@ -import 'package:async_button_builder/async_button_builder.dart'; -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/settings.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/navigation/app_initializer.dart'; -import 'package:zapstore/screens/settings_screen.dart'; -import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/utils/signers.dart'; -import 'package:zapstore/widgets/sign_in_container.dart'; - -import '../utils/nwc.dart'; - -class ZapButton extends HookConsumerWidget { - ZapButton({ - super.key, - required this.app, - }); - - final App app; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final nwcConnection = ref.watch(nwcConnectionProvider); - final amountController = TextEditingController(); - final commentController = TextEditingController(); - - if (app.signer.value?.lud16 == null) { - return Container(); - } - - return AsyncButtonBuilder( - loadingWidget: Text('⚡️ Zapping...').bold, - builder: (context, child, callback, state) => ElevatedButton( - onPressed: callback, - style: ElevatedButton.styleFrom( - backgroundColor: const Color.fromARGB(255, 60, 60, 54), - disabledBackgroundColor: Colors.grey[600], - ), - child: child, - ), - onPressed: nwcConnection.isLoading - ? null - : () async { - if (!nwcConnection.isPresent) { - await showDialog( - // ignore: use_build_context_synchronously - context: context, - builder: (_) => AlertDialog( - title: Text('Connect your wallet').bold, - content: NwcContainer(dialogMode: true), - ), - ); - if (!ref.read(nwcConnectionProvider).isPresent) { - return; - } - } - - final valueRecord = await showDialog<(int, String)>( - // ignore: use_build_context_synchronously - context: context, - builder: (context) { - return AlertDialog( - title: Text('Zap this release').bold, - content: SizedBox( - width: double.maxFinite, - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 10, - children: [ - if (app.isInstalled && !app.isUpdated) - Text( - '⚠️ Zaps will go toward the latest release (${app.latestMetadata!.version}), not the one currently installed (${app.localApp.value!.installedVersion})'), - Consumer( - builder: (context, ref, _) { - final signedInUser = - ref.watch(signedInUserProvider); - - if (signedInUser == null || - signedInUser.settings.value!.signInMethod != - SignInMethod.nip55) { - return Column( - children: [ - Text( - '⚠️ If you do not sign in (with an external signer like Amber), you will be zapping anonymously'), - SignInButton( - requireNip55: true, - minimal: true, - ), - ], - ); - } - return Container(); - }, - ), - TextField( - controller: amountController, - keyboardType: TextInputType.number, - decoration: InputDecoration( - hintText: 'Enter amount in sats', - ), - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly - ], - ), - Gap(10), - SizedBox( - width: double.maxFinite, - child: Wrap( - alignment: WrapAlignment.start, - spacing: 4, - children: [ - ('🤙 210', 210), - ('💜 2100', 2100), - ('🤩 4200', 4200), - ('🚀 21000', 21000), - ('💯 100K', 100000), - ('💎 210K', 210000), - ] - .map( - (r) => ElevatedButton( - onPressed: () => amountController.text = - r.$2.toString(), - child: AutoSizeText( - r.$1, - style: TextStyle(fontSize: 12.5), - ), - ), - ) - .toList(), - ), - ), - TextField( - controller: commentController, - decoration: InputDecoration( - hintText: 'Add a comment (optional)'), - ), - ], - ), - ), - actions: [ - TextButton( - child: Text('Cancel'), - onPressed: () => Navigator.of(context).pop(), - ), - TextButton( - child: Text('Zap').bold, - onPressed: () { - final record = ( - int.tryParse(amountController.text) ?? 0, - commentController.text - ); - Navigator.of(context).pop(record); - }, - ), - ], - ); - }, - ); - - if (valueRecord == null) { - return; - } - final (amount, comment) = valueRecord; - - if (amount > 0) { - try { - // Reload user as we are in a callback and it could have changed - final signedInUser = - ref.settings.findOneLocalById('_')!.user.value; - if (signedInUser != null) { - await signedInUser.zap(amount, - event: app.latestMetadata!, comment: comment); - } else { - await anonUser!.zap(amount, - event: app.latestMetadata!, - signer: pkSigner, - comment: comment); - } - // ignore: use_build_context_synchronously - context.showInfo('$amount sat${amount > 1 ? 's' : ''} sent!', - description: 'Payment was successful'); - } catch (e) { - var title = 'Unable to zap'; - var description = e.toString(); - if (e.toString().contains('Timed out')) { - title = 'Unknown state'; - description = - 'The call timed out but payment may still go through! Please watch the zaps list.\n\n$description'; - } - // ignore: use_build_context_synchronously - context.showError(title, description: description); - } - } - }, - child: Text( - '⚡️ Zap this release', - style: TextStyle( - color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15), - ), - ); - } -} diff --git a/lib/widgets/zap_receipts.dart b/lib/widgets/zap_receipts.dart deleted file mode 100644 index 2b28c66..0000000 --- a/lib/widgets/zap_receipts.dart +++ /dev/null @@ -1,220 +0,0 @@ -import 'dart:async'; - -import 'package:collection/collection.dart'; -import 'package:flutter_data/flutter_data.dart'; -import 'package:gap/gap.dart'; -import 'package:ndk/domain_layer/entities/filter.dart'; -import 'package:ndk/domain_layer/entities/nip_01_event.dart'; -import 'package:zapstore/main.data.dart'; - -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/file_metadata.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/models/zap_receipt.dart'; -import 'package:zapstore/screens/settings_screen.dart'; -import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/widgets/users_rich_text.dart'; - -class ZapReceipts extends HookConsumerWidget { - ZapReceipts({ - super.key, - required this.app, - }); - - final App app; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final signedInUser = ref.watch(signedInUserProvider); - - // NOTE: These watchers will become more efficient - ref.users.watchAll(); - - final zapReceipts = ref.watch(zapReceiptsNotifier(app.latestMetadata!)); - - if (zapReceipts.isLoading) { - return Row( - children: [ - Gap(4), - Text('Loading zaps...'), - Gap(10), - SmallCircularProgressIndicator(), - ], - ); - } - - if (zapReceipts.value!.isEmpty) { - return Container(); - } - - final receipts = zapReceipts.value!; - final satsAmount = receipts.fold(0, (acc, e) => acc + e.amount); - final formattedSatsAmount = kNumberFormatter.format(satsAmount); - - const kMaxUsersToDisplay = 6; - - final zapperIds = receipts.map((r) => r.senderPubkey).toSet(); - final zappers = ref.users.findManyLocalByIds(zapperIds).sortedBy((u) { - // Calculates total amount per user and uses `wrapped` - // that compares by descending order - final totalUserAmount = receipts - .where((r) => u.pubkey == r.senderPubkey) - .fold(0, (acc, e) => acc + e.amount); - return totalUserAmount.wrapped; - }); - - return UsersRichText( - leadingTextSpan: TextSpan( - style: TextStyle(fontSize: 16, height: 1.7), - children: [ - TextSpan(text: '⚡'), - TextSpan( - text: ' $formattedSatsAmount sats ', - style: TextStyle(fontWeight: FontWeight.bold)), - TextSpan(text: 'zapped by'), - ], - ), - users: zappers, - signedInUser: signedInUser, - maxUsersToDisplay: kMaxUsersToDisplay, - ); - } -} - -final _zapsPreviouslyLoadedProvider = - StateProvider.family((_, id) => false); - -class ZapReceiptsNotifier extends StateNotifier>> { - Ref ref; - final FileMetadata fileMetadata; - StreamSubscription>? sub; - - ZapReceiptsNotifier(this.ref, this.fileMetadata) : super(AsyncLoading()) { - final adapter = ref.users.nostrAdapter; - final developerPubkey = fileMetadata.event.pubkey; - final app = fileMetadata.release.value!.app.value!; - - if (!app.isSelfSigned) { - state = AsyncData({}); - return; - } - - final wereZapsPreviouslyLoaded = - ref.read(_zapsPreviouslyLoadedProvider(fileMetadata.event.id)); - - int? latestReceiptTimestamp; - - final localZapReceipts = ref.zapReceipts.zapReceiptAdapter - .findByEventId(fileMetadata.id!.toString()) - .toSet(); - - // If there were no zaps locally and this is the first time loading, keep the loading state - // (yes, purplebase should handle this) - if (localZapReceipts.isEmpty && !wereZapsPreviouslyLoaded) { - state = AsyncLoading(); - } else { - _loadZappers(localZapReceipts); - state = AsyncData(localZapReceipts); - - // NOTE: ideally this caching stuff should be handled by purplebase - latestReceiptTimestamp = localZapReceipts - .sortedBy((z) => z.event.createdAt) - .lastOrNull - ?.event - .createdAt - .millisecondsSinceEpoch; - } - - final receiptsResponse = adapter.socialRelays.ndk!.requests.subscription( - filters: [ - Filter( - kinds: [9735], - since: latestReceiptTimestamp != null - ? latestReceiptTimestamp ~/ 1000 + 1 - : null, - eTags: [fileMetadata.event.id], - pTags: [developerPubkey], - ) - ], - ); - - sub = bufferByTime(receiptsResponse.stream, Duration(seconds: 1)) - .listen((data) async { - final zapReceipts = - data.map((r) => ZapReceipt.fromJson(r.toJson()).init().saveLocal()); - await _loadZappers(zapReceipts); - - state = AsyncData({if (state.hasValue) ...state.value!, ...zapReceipts}); - }); - - // If after 8 seconds we receive no zaps, then change the state to empty - // (to prevent forever spinners) and set to loading attempted - Timer(Duration(seconds: 8), () { - if (mounted) { - // Mark loading done for this event - ref - .read(_zapsPreviouslyLoadedProvider(fileMetadata.event.id).notifier) - .state = true; - if (state is AsyncLoading) { - state = AsyncData({}); - } - } - }); - } - - // Load senders - this tedious work will be handled by purplebase at some point - Future _loadZappers(Iterable receipts) async { - // If we do not have the users locally, trigger a remote fetch - final receiptsPubkeys = receipts.map((r) => r.senderPubkey).toSet(); - final existingPubkeysLocally = - ref.users.nostrAdapter.existingIds(receiptsPubkeys).toSet(); - final missingPubkeysLocally = - receiptsPubkeys.difference(existingPubkeysLocally); - if (missingPubkeysLocally.isNotEmpty) { - final fetchedSenders = - await ref.users.findAll(params: {'authors': missingPubkeysLocally}); - final stillMissingPubkeys = missingPubkeysLocally - .toSet() - .difference(fetchedSenders.map((s) => s.id!.toString()).toSet()); - for (final pubkey in stillMissingPubkeys) { - // If relays do not have it, create a dummy local user - User.fromPubkey(pubkey).init().saveLocal(); - } - } - } - - @override - void dispose() { - sub?.cancel(); - super.dispose(); - } -} - -final zapReceiptsNotifier = StateNotifierProvider.autoDispose - .family>, FileMetadata>( - ZapReceiptsNotifier.new); - -// Ridiculous to have to do this in Dart - -extension _IntWrapperExt on int { - _IntDescComparableWrapper get wrapped => _IntDescComparableWrapper(this); -} - -class _IntDescComparableWrapper - implements Comparable<_IntDescComparableWrapper> { - final int value; - - _IntDescComparableWrapper(this.value); - - @override - int compareTo(_IntDescComparableWrapper other) { - return other.value.compareTo(value); - } - - @override - String toString() { - return value.toString(); - } -} diff --git a/lib/widgets/zap_widgets.dart b/lib/widgets/zap_widgets.dart new file mode 100644 index 0000000..f57b125 --- /dev/null +++ b/lib/widgets/zap_widgets.dart @@ -0,0 +1,690 @@ +import 'package:async_button_builder/async_button_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/services/secure_storage_service.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/auth_widgets.dart'; +import 'package:zapstore/widgets/common/base_dialog.dart'; +import 'package:zapstore/widgets/common/profile_avatar.dart'; + +/// Zap button for apps - shows a button to zap the app or relay +class ZapButton extends HookConsumerWidget { + const ZapButton({super.key, required this.app, required this.author}); + + final App app; + final Profile? author; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Check if author (relay or developer) has lightning address + final hasLud16 = author?.lud16?.trim().isNotEmpty ?? false; + final canZap = author != null && hasLud16; + + // Determine button text based on app signing + final buttonText = app.isRelaySigned ? 'Zap' : 'Zap this app'; + + return SizedBox( + height: 36, + child: FilledButton( + onPressed: canZap ? () => _showZapDialog(context, ref, app) : null, + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF1F2937) + : const Color(0xFF111827), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + '⚡️', + style: TextStyle(color: Colors.orange, fontSize: 16), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + buttonText, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } + + Future _showZapDialog( + BuildContext context, + WidgetRef ref, + App app, + ) async { + return showZapDialog(context, ref, app, author); + } +} + +/// Horizontal list of zappers with their profile avatars and amounts +class ZappersHorizontalList extends StatelessWidget { + const ZappersHorizontalList({ + super.key, + required this.zaps, + this.profilesMap, + }); + + final List zaps; + + /// Optional map of pubkey -> Profile. If not provided, falls back to + /// relationship-loaded profiles (legacy behavior). + final Map? profilesMap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = theme.textTheme.bodySmall?.color?.withValues(alpha: 0.8); + + // Group sats per zapper using metadata['author'] (extracted from description tag) + final Map satsPerPubkey = {}; + final Map profileByPubkey = {}; + + for (final zap in zaps) { + // The zapper's pubkey is in event.metadata['author'], extracted from description + final zapperPubkey = zap.event.metadata['author'] as String?; + if (zapperPubkey == null) continue; + + // Get profile from provided map or fall back to author relationship + Profile? chosenProfile; + if (profilesMap != null) { + chosenProfile = profilesMap![zapperPubkey]; + } else { + // Legacy: try to get from author relationship (which now points to zapper) + chosenProfile = zap.author.value; + } + + satsPerPubkey[zapperPubkey] = + (satsPerPubkey[zapperPubkey] ?? 0) + zap.amount; + if (chosenProfile != null) { + profileByPubkey[zapperPubkey] = chosenProfile; + } + } + + final entries = satsPerPubkey.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + + if (entries.isEmpty) return const SizedBox.shrink(); + + final totalSats = satsPerPubkey.values.fold(0, (sum, v) => sum + v); + + return SizedBox( + height: 44, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + '⚡️${formatSatsCompact(totalSats)}', + style: theme.textTheme.titleLarge?.copyWith( + color: Colors.white, + ), + ), + const SizedBox(width: 6), + Text( + 'sats by', + style: theme.textTheme.bodyMedium?.copyWith(color: color), + ), + const SizedBox(width: 12), + ], + ), + for (final e in entries) ...[ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + ProfileAvatar(profile: profileByPubkey[e.key], radius: 12), + const SizedBox(width: 6), + Text( + formatSatsCompact(e.value), + style: theme.textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(width: 12), + ], + ], + ), + ), + ); + } +} + +/// Format sats with compact notation (k, m) +String formatSatsCompact(int sats) { + if (sats >= 1000000) { + final value = sats / 1000000; + return value % 1 == 0 + ? '${value.toInt()}m' + : '${value.toStringAsFixed(1)}m'; + } + if (sats >= 1000) { + final value = sats / 1000; + return value % 1 == 0 + ? '${value.toInt()}k' + : '${value.toStringAsFixed(1)}k'; + } + return sats.toString(); +} + +/// Format sats with thousand separators (commas) +String formatSatsWithSeparators(int sats) { + final s = sats.toString(); + final reg = RegExp(r'\B(?=(\d{3})+(?!\d))'); + return s.replaceAllMapped(reg, (m) => ','); +} + +/// Show zap dialog for an app +Future showZapDialog( + BuildContext context, + WidgetRef ref, + App app, + Profile? author, +) async { + final result = await showBaseDialog( + context: context, + dialog: ZapAmountDialog( + app: app, + isRelaySigned: app.isRelaySigned, + author: author, + ), + ); + return result ?? false; +} + +/// Dialog for selecting zap amount and sending zap +class ZapAmountDialog extends HookConsumerWidget { + const ZapAmountDialog({ + super.key, + required this.app, + required this.isRelaySigned, + required this.author, + }); + + final App app; + final bool isRelaySigned; + final Profile? author; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedAmount = useState(2100); + final commentController = useTextEditingController(); + final customAmount = useState(null); + final quickAmounts = useMemoized( + () => [ + (label: '🤙 2100', value: 2100), + (label: '💜 10k', value: 10_000), + (label: '🤩 21k', value: 21_000), + (label: '🏃 42k', value: 42_000), + (label: '💯 100k', value: 100_000), + ], + ); + final pubkey = ref.watch(Signer.activePubkeyProvider); + final secureStorage = ref.watch(secureStorageServiceProvider); + final hasNwc = ref.watch(hasNwcStringProvider); + final knownHasNwc = hasNwc.valueOrNull; + + return BaseDialog( + titleIcon: const Text('⚡️'), + titleIconColor: Colors.orange, + title: Text( + isRelaySigned ? 'Zap the relay' : 'Zap ${app.name}', + style: Theme.of(context).textTheme.headlineSmall, + ), + content: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: BaseDialogContent( + children: [ + const SizedBox(height: 4), + + // Relay-signed app explanation + if (isRelaySigned) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.info_outline, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text.rich( + TextSpan( + style: Theme.of(context).textTheme.bodyMedium, + children: [ + const TextSpan(text: 'This app was indexed by '), + if (author != null) ...[ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: ProfileAvatar( + profile: author, + radius: 9, + ), + ), + const WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: SizedBox(width: 4), + ), + TextSpan( + text: author!.nameOrNpub, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ] else + TextSpan( + text: 'a relay', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const TextSpan( + text: + ', not directly published by a developer. Your zap will help support the service.\nIf you know the developer, ask them to self-publish to earn sats!', + ), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + + if (pubkey == null) ...[ + const SignInPrompt( + message: + 'Zapping anonymously. Sign in to zap with your identity.', + ), + const SizedBox(height: 12), + ], + + // Quick amount buttons (always visible) + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ...quickAmounts.map((q) { + final isSelected = selectedAmount.value == q.value; + return FilterChip( + label: Text(q.label), + selected: isSelected, + showCheckmark: false, + side: BorderSide.none, + onSelected: (selected) { + selectedAmount.value = q.value; + }, + ); + }), + FilterChip( + label: Text( + customAmount.value == null + ? '💎 Custom' + : '💎 ${formatSatsCompact(customAmount.value!)}', + ), + selected: + customAmount.value != null && + selectedAmount.value == customAmount.value, + showCheckmark: false, + side: BorderSide.none, + onSelected: (selected) async { + final amount = await showBaseDialog( + context: context, + dialog: const CustomAmountDialog(), + ); + if (amount != null && amount > 0) { + customAmount.value = amount; + selectedAmount.value = amount; + } + }, + ), + ], + ), + + const SizedBox(height: 12), + TextField( + controller: commentController, + decoration: const InputDecoration( + labelText: 'Add a comment (optional)', + border: OutlineInputBorder(), + ), + maxLines: 2, + ), + ], + ), + ), + actions: [ + BaseDialogAction( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + AsyncButtonBuilder( + onPressed: selectedAmount.value > 0 + ? () async { + try { + final navigator = Navigator.of(context); + + // Prepare signer (ephemeral if needed) + var signer = ref.read(Signer.activeSignerProvider); + if (signer == null) { + signer = Bip340PrivateKeySigner( + Utils.generateRandomHex64(), + ref.ref, + ); + await signer.signIn(registerSigner: false); + } + + // Read NWC from secure storage + final nwcString = (knownHasNwc == false) + ? null + : await secureStorage.getNWCString(); + + // Build zap request + final latestMetadata = app.latestFileMetadata; + final author = app.author.value; + + if (latestMetadata == null || author == null) { + throw Exception( + 'App or author not ready. Please try again.', + ); + } + + final socialRelays = await ref + .read(storageNotifierProvider.notifier) + .resolveRelays('social'); + + final zapRequest = PartialZapRequest(); + zapRequest.amount = selectedAmount.value * 1000; // msats + final c = commentController.text.trim(); + if (c.isNotEmpty) zapRequest.comment = c; + zapRequest.linkProfileByPubkey(author.pubkey); + zapRequest.linkModel(app); + zapRequest.linkModel(latestMetadata); + zapRequest.relays = socialRelays; + + final signedZapRequest = await zapRequest.signWith(signer); + + if (nwcString != null && nwcString.isNotEmpty) { + final amount = selectedAmount.value; + + // Execute payment and wait for result + try { + await _executeZapPayment( + signedZapRequest, + nwcString, + ref.ref, + ); + if (context.mounted) { + context.showInfo('⚡ Zap sent! $amount sats'); + navigator.pop(true); + } + } catch (e) { + if (context.mounted) { + context.showError('Zap failed', description: '$e'); + navigator.pop(false); + } + } + } else { + final invoice = await signedZapRequest.getInvoice(); + if (context.mounted) { + navigator.pop(true); + context.showInfo( + 'Lightning invoice ready', + description: + 'Copy the invoice and pay with your Lightning wallet. Setup NWC to zap directly from the app.', + actions: [ + ( + 'Copy Invoice', + () async { + await Clipboard.setData( + ClipboardData(text: invoice), + ); + }, + ), + ( + 'Setup NWC', + () async { + await showBaseDialog( + context: context, + dialog: const NWCConnectionDialogInline(), + ); + }, + ), + ], + ); + } + } + } catch (e) { + if (context.mounted) { + context.showError('Zap failed', description: '$e'); + Navigator.of(context).pop(false); + } + } + } + : null, + builder: (context, child, callback, state) { + return FilledButton( + onPressed: state.maybeWhen( + loading: () => null, + orElse: () => callback, + ), + child: state.maybeWhen( + loading: () => Row( + mainAxisSize: MainAxisSize.min, + children: const [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + orElse: () => Text( + 'Send ${formatSatsWithSeparators(selectedAmount.value)} sats', + ), + ), + ); + }, + child: const SizedBox.shrink(), + ), + ], + ); + } +} + +/// Execute zap payment using NWC connection string from secure storage +Future _executeZapPayment( + ZapRequest signedZapRequest, + String nwcString, + Ref ref, +) async { + final lightningInvoice = await signedZapRequest.getInvoice(); + final command = PayInvoiceCommand(invoice: lightningInvoice); + + final result = await command.execute( + connectionUri: nwcString, + ref: ref, + timeout: const Duration(seconds: 30), + ); + + return result; +} + +/// Dialog for entering a custom zap amount +class CustomAmountDialog extends HookWidget { + const CustomAmountDialog({super.key}); + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(); + final amount = useState(null); + + return BaseDialog( + title: const BaseDialogTitle('Enter custom amount'), + content: BaseDialogContent( + children: [ + TextField( + controller: controller, + decoration: const InputDecoration( + labelText: 'Amount in sats', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + onChanged: (value) => amount.value = int.tryParse(value), + ), + ], + ), + actions: [ + BaseDialogAction( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: (amount.value != null && amount.value! > 0) + ? () => Navigator.pop(context, amount.value) + : null, + child: const Text('Done'), + ), + ], + ); + } +} + +/// Inline NWC connection dialog used in zap flow +class NWCConnectionDialogInline extends HookConsumerWidget { + const NWCConnectionDialogInline({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useTextEditingController(); + final isLoading = useState(false); + final secureStorage = ref.watch(secureStorageServiceProvider); + + return BaseDialog( + titleIcon: const Text('⚡️'), + titleIconColor: Colors.orange, + title: const BaseDialogTitle('Nostr Wallet Connect'), + maxWidth: double.maxFinite, + content: BaseDialogContent( + children: [ + Text( + 'Enter your NWC connection string from your Lightning wallet:', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + TextField( + controller: controller, + decoration: InputDecoration( + labelText: 'NWC Connection String', + hintText: 'nostr+walletconnect://...', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: const Icon(Icons.paste), + onPressed: () async { + final data = await Clipboard.getData('text/plain'); + final t = data?.text; + if (t != null) controller.text = t; + }, + ), + ), + maxLines: 3, + enabled: !isLoading.value, + ), + const SizedBox(height: 12), + Text( + 'Alby, Coinos, and others support NWC.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + actions: [ + BaseDialogAction( + onPressed: isLoading.value + ? null + : () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: isLoading.value + ? null + : () async { + final nwcString = controller.text; + if (nwcString.trim().isEmpty) { + context.showError( + 'Missing connection string', + description: + 'Get a NWC connection string from your Lightning wallet (e.g., Alby, Zeus, Coinos).', + ); + return; + } + if (!nwcString.trim().startsWith('nostr+walletconnect://')) { + context.showError( + 'Invalid NWC format', + description: + 'Connection string should start with nostr+walletconnect://', + ); + return; + } + isLoading.value = true; + try { + await secureStorage.setNWCString(nwcString.trim()); + if (context.mounted) { + Navigator.pop(context, true); + context.showInfo( + '⚡ Lightning wallet connected successfully!', + ); + } + } catch (e) { + if (context.mounted) { + context.showError( + 'Wallet connection failed', + description: + 'Could not connect to the wallet. Verify the connection string and try again.\n\n$e', + ); + } + } finally { + isLoading.value = false; + } + }, + child: isLoading.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Connect'), + ), + ], + ); + } +} diff --git a/lib/widgets/zapstore_update_prompt_listener.dart b/lib/widgets/zapstore_update_prompt_listener.dart new file mode 100644 index 0000000..a9b7773 --- /dev/null +++ b/lib/widgets/zapstore_update_prompt_listener.dart @@ -0,0 +1,53 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/services/updates_service.dart'; +import 'package:zapstore/utils/extensions.dart'; + +final _zapstoreUpdatePromptHandledProvider = StateProvider( + (ref) => false, +); + +class ZapstoreUpdatePromptListener extends ConsumerWidget { + const ZapstoreUpdatePromptListener({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.listen(categorizedAppsProvider, (previous, next) { + final hasHandled = ref.read(_zapstoreUpdatePromptHandledProvider); + if (hasHandled) return; + + if (next.isLoading) return; + + final allUpdates = [...next.automaticUpdates, ...next.manualUpdates]; + final zapstoreUpdate = + allUpdates.firstWhereOrNull((app) => app.isZapstoreApp); + + if (zapstoreUpdate == null) return; + + ref.read(_zapstoreUpdatePromptHandledProvider.notifier).state = true; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + context.showInfo( + 'Update Zapstore', + description: 'A new Zapstore update is available.', + actions: [ + ( + 'Update', + () async { + if (!context.mounted) return; + context.push('/updates/app/${zapstoreUpdate.identifier}'); + }, + ), + ], + ); + }); + }); + + return const SizedBox.shrink(); + } +} + diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 9d5d809..9a75665 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -1,7 +1,5 @@ # Project-level configuration. -set ( CMAKE_CXX_COMPILER "/usr/bin/g++" ) -set ( CMAKE_C_COMPILER "/usr/bin/gcc" ) -cmake_minimum_required(VERSION 3.10) +cmake_minimum_required(VERSION 3.13) project(runner LANGUAGES CXX) # The name of the executable created for the application. Change this to change @@ -9,7 +7,7 @@ project(runner LANGUAGES CXX) set(BINARY_NAME "zapstore") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "dev.zapstore.app") +set(APPLICATION_ID "dev.zapstore.alpha") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. @@ -56,25 +54,8 @@ add_subdirectory(${FLUTTER_MANAGED_DIR}) find_package(PkgConfig REQUIRED) pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) -add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") - -# Define the application target. To change its name, change BINARY_NAME above, -# not the value here, or `flutter run` will no longer work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} - "main.cc" - "my_application.cc" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add dependency libraries. Add any application-specific dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter) -target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") # Run the Flutter tool portions of the build. This must not be removed. add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/main.cc b/linux/runner/main.cc similarity index 100% rename from linux/main.cc rename to linux/runner/main.cc diff --git a/linux/my_application.cc b/linux/runner/my_application.cc similarity index 93% rename from linux/my_application.cc rename to linux/runner/my_application.cc index 516d3ca..7517272 100644 --- a/linux/my_application.cc +++ b/linux/runner/my_application.cc @@ -117,6 +117,12 @@ static void my_application_class_init(MyApplicationClass* klass) { static void my_application_init(MyApplication* self) {} MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + return MY_APPLICATION(g_object_new(my_application_get_type(), "application-id", APPLICATION_ID, "flags", G_APPLICATION_NON_UNIQUE, diff --git a/linux/my_application.h b/linux/runner/my_application.h similarity index 100% rename from linux/my_application.h rename to linux/runner/my_application.h diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index dc32300..763252e 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,11 @@ import FlutterMacOS import Foundation import app_links -import device_info_plus -import flutter_secure_storage_macos +import connectivity_plus +import flutter_local_notifications +import flutter_secure_storage_darwin import path_provider_foundation +import share_plus import shared_preferences_foundation import sqflite_darwin import sqlite3_flutter_libs @@ -16,9 +18,11 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) - DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) - FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) diff --git a/macos/Podfile b/macos/Podfile index c795730..29c8eb3 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -28,7 +28,6 @@ flutter_macos_podfile_setup target 'Runner' do use_frameworks! - use_modular_headers! flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) target 'RunnerTests' do diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 081c155..73a2b84 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,43 +1,51 @@ PODS: - - app_links (1.0.0): - - FlutterMacOS - - device_info_plus (0.0.1): - - FlutterMacOS - FlutterMacOS (1.0.0) - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - sqflite (0.0.3): + - share_plus (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - "sqlite3 (3.46.1+1)": - - "sqlite3/common (= 3.46.1+1)" - - "sqlite3/common (3.46.1+1)" - - "sqlite3/dbstatvtab (3.46.1+1)": + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - sqlite3 (3.50.4): + - sqlite3/common (= 3.50.4) + - sqlite3/common (3.50.4) + - sqlite3/dbstatvtab (3.50.4): - sqlite3/common - - "sqlite3/fts5 (3.46.1+1)": + - sqlite3/fts5 (3.50.4): - sqlite3/common - - "sqlite3/perf-threadsafe (3.46.1+1)": + - sqlite3/math (3.50.4): - sqlite3/common - - "sqlite3/rtree (3.46.1+1)": + - sqlite3/perf-threadsafe (3.50.4): + - sqlite3/common + - sqlite3/rtree (3.50.4): + - sqlite3/common + - sqlite3/session (3.50.4): - sqlite3/common - sqlite3_flutter_libs (0.0.1): + - Flutter - FlutterMacOS - - "sqlite3 (~> 3.46.0+1)" + - sqlite3 (~> 3.50.4) - sqlite3/dbstatvtab - sqlite3/fts5 + - sqlite3/math - sqlite3/perf-threadsafe - sqlite3/rtree + - sqlite3/session - url_launcher_macos (0.0.1): - FlutterMacOS DEPENDENCIES: - - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) - - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) - - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos`) + - 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`) + - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) SPEC REPOS: @@ -45,31 +53,31 @@ SPEC REPOS: - sqlite3 EXTERNAL SOURCES: - app_links: - :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos - device_info_plus: - :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos FlutterMacOS: :path: Flutter/ephemeral path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin - sqflite: - :path: Flutter/ephemeral/.symlinks/plugins/sqflite/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 sqlite3_flutter_libs: - :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos + :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: - app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a - device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 - sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec - sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb - sqlite3_flutter_libs: 5ca46c1a04eddfbeeb5b16566164aa7ad1616e7b - url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b + sqlite3_flutter_libs: 83f8e9f5b6554077f1d93119fe20ebaa5f3a9ef1 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 -PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 +PODFILE CHECKSUM: 7eb978b976557c8c1cd717d8185ec483fd090a82 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 0a68692..3509d9f 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -21,14 +21,14 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 12F6DBBE807DDD05271558DE /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8DE038B96A25AD691A594EE8 /* Pods_RunnerTests.framework */; }; 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - E47EF6F5916311F4DF1FE604 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 35CF597B589791056A2F564B /* Pods_RunnerTests.framework */; }; - F7E68736127D0A67D2DD88D5 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 384CB716B84222C0E8B1B985 /* Pods_Runner.framework */; }; + 6D7BB3B613B99AD2CFC015EC /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 969061167FA25CD07C6D87D9 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -78,16 +78,16 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 35CF597B589791056A2F564B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 384CB716B84222C0E8B1B985 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 41A708303D4B7B5905D46A1F /* 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 = ""; }; - 687E6D5F4AE68B1F6CB94612 /* 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 = ""; }; - 6E9200766B12905B9ABE8A04 /* 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 = ""; }; + 6486F469339F0C239AED6F33 /* 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 = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 8783D3EC266EB5D20798A9A7 /* 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 = ""; }; + 88CC932D316065ED441B1757 /* 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 = ""; }; + 8DE038B96A25AD691A594EE8 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 969061167FA25CD07C6D87D9 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - A3DCEB97D9AF2744A0780D7F /* 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 = ""; }; - D7EDE1F5D6A2259DFE6F304E /* 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 = ""; }; - EBA13E7288B3B1EA6CA502DC /* 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 = ""; }; + BC4E1C42FD62E5B378E47746 /* 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 = ""; }; + E9289CFED620E4B88F31BB59 /* 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 = ""; }; + EA059DDDDAA64811E1425DBF /* 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -95,7 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - E47EF6F5916311F4DF1FE604 /* Pods_RunnerTests.framework in Frameworks */, + 12F6DBBE807DDD05271558DE /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -103,7 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - F7E68736127D0A67D2DD88D5 /* Pods_Runner.framework in Frameworks */, + 6D7BB3B613B99AD2CFC015EC /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -137,7 +137,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, - 5DB4FEC2CA85622D45FFDD28 /* Pods */, + B12F0E1EB94F4448F316E120 /* Pods */, ); sourceTree = ""; }; @@ -185,15 +185,15 @@ path = Runner; sourceTree = ""; }; - 5DB4FEC2CA85622D45FFDD28 /* Pods */ = { + B12F0E1EB94F4448F316E120 /* Pods */ = { isa = PBXGroup; children = ( - 41A708303D4B7B5905D46A1F /* Pods-Runner.debug.xcconfig */, - 6E9200766B12905B9ABE8A04 /* Pods-Runner.release.xcconfig */, - EBA13E7288B3B1EA6CA502DC /* Pods-Runner.profile.xcconfig */, - A3DCEB97D9AF2744A0780D7F /* Pods-RunnerTests.debug.xcconfig */, - 687E6D5F4AE68B1F6CB94612 /* Pods-RunnerTests.release.xcconfig */, - D7EDE1F5D6A2259DFE6F304E /* Pods-RunnerTests.profile.xcconfig */, + 8783D3EC266EB5D20798A9A7 /* Pods-Runner.debug.xcconfig */, + E9289CFED620E4B88F31BB59 /* Pods-Runner.release.xcconfig */, + EA059DDDDAA64811E1425DBF /* Pods-Runner.profile.xcconfig */, + BC4E1C42FD62E5B378E47746 /* Pods-RunnerTests.debug.xcconfig */, + 6486F469339F0C239AED6F33 /* Pods-RunnerTests.release.xcconfig */, + 88CC932D316065ED441B1757 /* Pods-RunnerTests.profile.xcconfig */, ); name = Pods; path = Pods; @@ -202,8 +202,8 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( - 384CB716B84222C0E8B1B985 /* Pods_Runner.framework */, - 35CF597B589791056A2F564B /* Pods_RunnerTests.framework */, + 969061167FA25CD07C6D87D9 /* Pods_Runner.framework */, + 8DE038B96A25AD691A594EE8 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -215,7 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - 8C46C9E8E6C78AFBB27868A5 /* [CP] Check Pods Manifest.lock */, + B05230BB9E399D42D21CD53D /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -234,13 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 1BC84A1D4A94073C3805D2E5 /* [CP] Check Pods Manifest.lock */, + EFE5BF3288E07DFB02F872E9 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - 4F902C33FAACFD67F0F7EA5C /* [CP] Embed Pods Frameworks */, + 266FDDCBF9120408071CF4DA /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -323,26 +323,21 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 1BC84A1D4A94073C3805D2E5 /* [CP] Check Pods Manifest.lock */ = { + 266FDDCBF9120408071CF4DA /* [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 = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; + name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); 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"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; 3399D490228B24CF009A79C7 /* ShellScript */ = { @@ -383,24 +378,7 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; - 4F902C33FAACFD67F0F7EA5C /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 8C46C9E8E6C78AFBB27868A5 /* [CP] Check Pods Manifest.lock */ = { + B05230BB9E399D42D21CD53D /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -422,6 +400,28 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + EFE5BF3288E07DFB02F872E9 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -473,13 +473,13 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = A3DCEB97D9AF2744A0780D7F /* Pods-RunnerTests.debug.xcconfig */; + baseConfigurationReference = BC4E1C42FD62E5B378E47746 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.zapstore.app.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.zapstore.alpha.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/zapstore.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/zapstore"; @@ -488,13 +488,13 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 687E6D5F4AE68B1F6CB94612 /* Pods-RunnerTests.release.xcconfig */; + baseConfigurationReference = 6486F469339F0C239AED6F33 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.zapstore.app.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.zapstore.alpha.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/zapstore.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/zapstore"; @@ -503,13 +503,13 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = D7EDE1F5D6A2259DFE6F304E /* Pods-RunnerTests.profile.xcconfig */; + baseConfigurationReference = 88CC932D316065ED441B1757 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.zapstore.app.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.zapstore.alpha.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/zapstore.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/zapstore"; diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index d68b669..ace183e 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -59,6 +59,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 8e02df2..b3c1761 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -6,4 +6,8 @@ class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index 7b4d860..a2ec33f 100644 --- a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,68 +1,68 @@ { - "images": [ + "images" : [ { - "filename": "app_icon_16.png", - "idiom": "mac", - "scale": "1x", - "size": "16x16" + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" }, { - "filename": "app_icon_32.png", - "idiom": "mac", - "scale": "2x", - "size": "16x16" + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" }, { - "filename": "app_icon_32.png", - "idiom": "mac", - "scale": "1x", - "size": "32x32" + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" }, { - "filename": "app_icon_64.png", - "idiom": "mac", - "scale": "2x", - "size": "32x32" + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" }, { - "filename": "app_icon_128.png", - "idiom": "mac", - "scale": "1x", - "size": "128x128" + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" }, { - "filename": "app_icon_256.png", - "idiom": "mac", - "scale": "2x", - "size": "128x128" + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" }, { - "filename": "app_icon_256.png", - "idiom": "mac", - "scale": "1x", - "size": "256x256" + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" }, { - "filename": "app_icon_512.png", - "idiom": "mac", - "scale": "2x", - "size": "256x256" + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" }, { - "filename": "app_icon_512.png", - "idiom": "mac", - "scale": "1x", - "size": "512x512" + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" }, { - "filename": "app_icon_1024.png", - "idiom": "mac", - "scale": "2x", - "size": "512x512" + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" } ], - "info": { - "author": "icons_launcher", - "version": 1 + "info" : { + "version" : 1, + "author" : "xcode" } -} \ No newline at end of file +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 79eaea5..82b6f9d 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index b8f65c0..13b35eb 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index bcc65ae..0a3f5fa 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index 57cfefc..bdb5722 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index bd1389f..f083318 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index 1fb4afc..326c0e7 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index 5a688f2..2f1632c 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index 0263612..11ff13d 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -5,10 +5,10 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = Zapstore +PRODUCT_NAME = zapstore_alpha // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = dev.zapstore.app +PRODUCT_BUNDLE_IDENTIFIER = dev.zapstore.alpha // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2024 Zapstore. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 78c36cf..05af981 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -3,12 +3,12 @@ com.apple.security.app-sandbox - + + com.apple.security.network.client + com.apple.security.cs.allow-jit com.apple.security.network.server - com.apple.security.network.client - diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 08ba3a3..779a178 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -3,8 +3,8 @@ com.apple.security.app-sandbox - - com.apple.security.network.client + com.apple.security.network.client + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift index 5418c9f..61f3bd1 100644 --- a/macos/RunnerTests/RunnerTests.swift +++ b/macos/RunnerTests/RunnerTests.swift @@ -1,5 +1,5 @@ -import FlutterMacOS import Cocoa +import FlutterMacOS import XCTest class RunnerTests: XCTestCase { diff --git a/pubspec.lock b/pubspec.lock index f070a7e..1dd021d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,43 +1,31 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" - url: "https://pub.dev" - source: hosted - version: "76.0.0" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.3" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" - url: "https://pub.dev" - source: hosted - version: "6.11.0" - android_package_manager: + amber_signer: dependency: "direct main" description: - name: android_package_manager - sha256: "2de859fae7226a7de1c1ff9a2308f1967599408800330501a1ce97927c051153" + path: "." + ref: main + resolved-ref: f7b13f41613226d2711df0ffd79bbd3f085bae4e + url: "https://github.com/purplebase/amber_signer" + source: git + version: "0.2.0" + any_link_preview: + dependency: "direct main" + description: + name: any_link_preview + sha256: "0617bd49a58dd0478cd5c4c83bcf7a2d1b7d301aad229817d13693fe86807ea1" url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "3.0.3" app_links: dependency: "direct main" description: name: app_links - sha256: "433df2e61b10519407475d7f69e470789d23d593f28224c38ba1068597be7950" + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.4.1" app_links_linux: dependency: transitive description: @@ -63,29 +51,29 @@ packages: source: hosted version: "1.0.4" archive: - dependency: transitive + dependency: "direct dev" description: name: archive - sha256: "6199c74e3db4fbfbd04f66d739e72fe11c8a8957d5f219f1f4482dbde6420b5a" + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.0.7" args: - dependency: transitive + dependency: "direct dev" description: name: args - sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.0" async: - dependency: "direct main" + dependency: transitive description: name: async - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.0" async_button_builder: dependency: "direct main" description: @@ -106,18 +94,10 @@ packages: dependency: "direct main" description: name: background_downloader - sha256: "8178f005574922941034b4a95c0721bc1ecdc4c9f040e310b108bc3d59a5e919" + sha256: a22acfa37aa06ba5cfe6eb7b1aa700c78af64770ff450c73dd3d279d7c37d4ac url: "https://pub.dev" source: hosted - version: "8.8.1" - badges: - dependency: "direct main" - description: - name: badges - sha256: a7b6bbd60dce418df0db3058b53f9d083c22cdb5132a052145dc267494df0b84 - url: "https://pub.dev" - source: hosted - version: "3.1.2" + version: "9.2.6" bech32: dependency: transitive description: @@ -134,6 +114,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.0" + bm25: + dependency: "direct dev" + description: + name: bm25 + sha256: "75f9f627fcdb884db420510d34906fb5e7ec7caef9d9cbdc35be17317b637f7b" + url: "https://pub.dev" + source: hosted + version: "2.2.3" boolean_selector: dependency: transitive description: @@ -142,70 +130,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" - build: - dependency: transitive - description: - name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - build_config: - dependency: transitive - description: - name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 - url: "https://pub.dev" - source: hosted - version: "1.1.1" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" - url: "https://pub.dev" - source: hosted - version: "4.0.2" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" - url: "https://pub.dev" - source: hosted - version: "2.4.2" - build_runner: - dependency: "direct main" - description: - name: build_runner - sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" - url: "https://pub.dev" - source: hosted - version: "2.4.13" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 - url: "https://pub.dev" - source: hosted - version: "7.3.2" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: "28a712df2576b63c6c005c465989a348604960c0958d28be5303ba9baa841ac2" - url: "https://pub.dev" - source: hosted - version: "8.9.3" cached_network_image: dependency: "direct main" description: @@ -242,10 +166,18 @@ packages: dependency: transitive description: name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -254,14 +186,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" - url: "https://pub.dev" - source: hosted - version: "4.10.1" collection: dependency: "direct main" description: @@ -270,16 +194,40 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" - convert: + connectivity_plus: dependency: "direct main" + description: + name: connectivity_plus + sha256: "33bae12a398f841c6cda09d1064212957265869104c478e5ad51e2fb26c3973c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + convert: + dependency: transitive description: name: convert sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" crypto: - dependency: "direct main" + dependency: transitive description: name: crypto sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" @@ -294,14 +242,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" - cupertino_icons: - dependency: "direct main" + csslib: + dependency: transitive description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "1.0.2" dart_emoji: dependency: "direct main" description: @@ -310,30 +258,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0+2" - dart_style: + dbus: dependency: transitive description: - name: dart_style - sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" url: "https://pub.dev" source: hosted - version: "2.3.7" - device_info_plus: - dependency: "direct main" - description: - name: device_info_plus - sha256: "4fa68e53e26ab17b70ca39f072c285562cfc1589df5bb1e9295db90f6645f431" - url: "https://pub.dev" - source: hosted - version: "11.2.0" - device_info_plus_platform_interface: - dependency: transitive - description: - name: device_info_plus_platform_interface - sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2" - url: "https://pub.dev" - source: hosted - version: "7.0.2" + version: "0.7.11" easy_image_viewer: dependency: "direct main" description: @@ -351,7 +283,7 @@ packages: source: hosted version: "0.3.11" equatable: - dependency: transitive + dependency: "direct main" description: name: equatable sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" @@ -362,18 +294,26 @@ packages: dependency: transitive description: name: fake_async - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" + faker: + dependency: transitive + description: + name: faker + sha256: "544c34e9e1d322824156d5a8d451bc1bb778263b892aded24ec7ba77b0706624" + url: "https://pub.dev" + source: hosted + version: "2.2.0" ffi: dependency: transitive description: name: ffi - sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" file: dependency: transitive description: @@ -403,31 +343,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" - flutter_data: - dependency: "direct main" - description: - path: "." - ref: d7376cf1eac8e9c563d4c6eed8e567adc877415c - resolved-ref: d7376cf1eac8e9c563d4c6eed8e567adc877415c - url: "https://github.com/zapstore/flutter_data" - source: git - version: "2.0.0-rc3" flutter_hooks: dependency: "direct main" description: name: flutter_hooks - sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70 + sha256: c3df76c62bb3a9f9bee75c57cdab40abab6123b734c1cd7e9b26a5dbd436eceb url: "https://pub.dev" source: hosted - version: "0.20.5" + version: "0.21.3" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" flutter_layout_grid: dependency: "direct main" description: name: flutter_layout_grid - sha256: "88b4f8484a0874962e27c47733ad256aeb26acc694a9f029edbef771d301885a" + sha256: "739e568db97af031d528dfd8a80d333df0e5a310a126e087690fa42cd61dfb5f" url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.0.8" flutter_lints: dependency: "direct dev" description: @@ -436,24 +375,48 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" + url: "https://pub.dev" + source: hosted + version: "19.5.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" + url: "https://pub.dev" + source: hosted + version: "9.1.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" + url: "https://pub.dev" + source: hosted + version: "1.0.3" flutter_markdown: dependency: "direct main" description: name: flutter_markdown - sha256: "255b00afa1a7bad19727da6a7780cf3db6c3c12e68d302d85e0ff1fdf173db9e" + sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27" url: "https://pub.dev" source: hosted - version: "0.7.4+3" - flutter_phoenix: - dependency: "direct main" - description: - name: flutter_phoenix - sha256: "39589dac934ea476d0e43fb60c1ddfba58f14960743640c8250dea11c4333378" - url: "https://pub.dev" - source: hosted - version: "1.1.1" + version: "0.7.7+1" flutter_riverpod: - dependency: transitive + dependency: "direct main" description: name: flutter_riverpod sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" @@ -464,50 +427,58 @@ packages: dependency: "direct main" description: name: flutter_secure_storage - sha256: "165164745e6afb5c0e3e3fcc72a012fb9e58496fb26ffb92cf22e16a821e85d0" + sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 url: "https://pub.dev" source: hosted - version: "9.2.2" + version: "10.0.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" + url: "https://pub.dev" + source: hosted + version: "0.2.0" flutter_secure_storage_linux: dependency: transitive description: name: flutter_secure_storage_linux - sha256: "4d91bfc23047422cbcd73ac684bc169859ee766482517c22172c86596bf1464b" + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" url: "https://pub.dev" source: hosted - version: "1.2.1" - flutter_secure_storage_macos: - dependency: transitive - description: - name: flutter_secure_storage_macos - sha256: "1693ab11121a5f925bbea0be725abfcfbbcf36c1e29e571f84a0c0f436147a81" - url: "https://pub.dev" - source: hosted - version: "3.1.2" + version: "3.0.0" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "2.0.1" flutter_secure_storage_web: dependency: transitive description: name: flutter_secure_storage_web - sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "2.1.0" flutter_secure_storage_windows: dependency: transitive description: name: flutter_secure_storage_windows - sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "4.1.0" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 + url: "https://pub.dev" + source: hosted + version: "2.2.0" flutter_test: dependency: "direct dev" description: flutter @@ -526,14 +497,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.4" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" gap: dependency: "direct main" description: @@ -542,38 +505,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" - glob: - dependency: transitive - description: - name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - globbing: - dependency: transitive - description: - name: globbing - sha256: "4f89cfaf6fa74c9c1740a96259da06bd45411ede56744e28017cc534a12b6e2d" - url: "https://pub.dev" - source: hosted - version: "1.0.0" go_router: dependency: "direct main" description: name: go_router - sha256: "2fd11229f59e23e967b0775df8d5948a519cd7e1e8b6e849729e010587b46539" + sha256: "8b1f37dfaf6e958c6b872322db06f946509433bec3de753c3491a42ae9ec2b48" url: "https://pub.dev" source: hosted - version: "14.6.2" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" + version: "16.1.0" gtk: dependency: transitive description: @@ -582,14 +521,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" - hex: - dependency: transitive - description: - name: hex - sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" - url: "https://pub.dev" - source: hosted - version: "0.2.0" hooks_riverpod: dependency: "direct main" description: @@ -598,86 +529,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: "direct main" description: name: http - sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 url: "https://pub.dev" source: hosted - version: "1.2.2" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" + version: "1.5.0" http_parser: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.0.2" - icons_launcher: - dependency: "direct dev" - description: - name: icons_launcher - sha256: a7c83fbc837dc6f81944ef35c3756f533bb2aba32fcca5cbcdb2dbcd877d5ae9 - url: "https://pub.dev" - source: hosted - version: "3.0.0" - iconsax_flutter: - dependency: transitive - description: - name: iconsax_flutter - sha256: "95b65699da8ea98f87c5d232f06b0debaaf1ec1332b697e4d90969ec9a93037d" - url: "https://pub.dev" - source: hosted - version: "1.0.0" + version: "4.1.2" image: dependency: transitive description: name: image - sha256: "8346ad4b5173924b5ddddab782fc7d8a6300178c8b1dc427775405a01701c4a6" + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" url: "https://pub.dev" source: hosted - version: "4.5.2" - inflection3: - dependency: transitive - description: - name: inflection3 - sha256: cb54689bcfe3c0168a6979ee3191df584dcef4f870c5ef41c798bcb35ff5aca3 - url: "https://pub.dev" - source: hosted - version: "0.5.3+2" - install_plugin: - dependency: "direct main" - description: - name: install_plugin - sha256: "6fb67ba0781e75de4f2f2266ed25e835bfd277c5bfc2ed034af52774355857c6" - url: "https://pub.dev" - source: hosted - version: "2.1.0" + version: "4.5.4" intl: dependency: "direct main" description: name: intl - sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.18.1" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" + version: "0.20.2" js: dependency: transitive description: @@ -687,29 +578,21 @@ packages: source: hosted version: "0.6.7" json_annotation: - dependency: "direct main" + dependency: transitive description: name: json_annotation sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted version: "4.9.0" - json_serializable: - dependency: "direct dev" - description: - name: json_serializable - sha256: c2fcb3920cf2b6ae6845954186420fca40bc0a8abcc84903b7801f17d7050d7c - url: "https://pub.dev" - source: hosted - version: "6.9.0" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "10.0.8" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: @@ -730,18 +613,18 @@ packages: dependency: transitive description: name: lints - sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413" + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "5.1.1" logger: dependency: transitive description: name: logger - sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 + sha256: "55d6c23a6c15db14920e037fe7e0dc32e7cdaf3b64b4b25df2d541b5b6b81c0c" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.1" logging: dependency: transitive description: @@ -750,22 +633,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - macros: - dependency: transitive - description: - name: macros - sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" - url: "https://pub.dev" - source: hosted - version: "0.1.3-main.0" markdown: - dependency: transitive + dependency: "direct main" description: name: markdown - sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" url: "https://pub.dev" source: hosted - version: "7.2.2" + version: "7.3.0" matcher: dependency: transitive description: @@ -782,6 +657,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.1" + mcp_dart: + dependency: "direct dev" + description: + name: mcp_dart + sha256: "4bc05574bc8bda0ae779e177ab5639f580b37059b5cb1248288b7a79979dd5e6" + url: "https://pub.dev" + source: hosted + version: "0.5.3" meta: dependency: transitive description: @@ -798,22 +681,21 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - mutex: + models: dependency: "direct main" description: - name: mutex - sha256: "8827da25de792088eb33e572115a5eb0d61d61a3c01acbc8bcbe76ed78f1a1f2" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - ndk: - dependency: "direct main" + path: "../../purplebase/models" + relative: true + source: path + version: "0.3.3" + nm: + dependency: transitive description: - name: ndk - sha256: "07c284a14e7800513743a84c911a546dcfcdc0c0ce2def4895c4e4c2286f159e" + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" url: "https://pub.dev" source: hosted - version: "0.2.4" + version: "0.5.0" octo_image: dependency: transitive description: @@ -822,14 +704,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" - url: "https://pub.dev" - source: hosted - version: "2.1.1" path: dependency: "direct main" description: @@ -838,6 +712,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -850,18 +732,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 url: "https://pub.dev" source: hosted - version: "2.2.15" + version: "2.2.17" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" path_provider_linux: dependency: transitive description: @@ -886,46 +768,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" - pausable_timer: - dependency: transitive - description: - name: pausable_timer - sha256: "6ef1a95441ec3439de6fb63f39a011b67e693198e7dae14e20675c3c00e86074" - url: "https://pub.dev" - source: hosted - version: "3.1.0+3" percent_indicator: dependency: "direct main" description: name: percent_indicator - sha256: "0d77d5c6fa9b7f60202cedf748b568ba9ba38d3f30405d6ceae4da76f5185462" + sha256: "157d29133bbc6ecb11f923d36e7960a96a3f28837549a20b65e5135729f0f9fd" url: "https://pub.dev" source: hosted - version: "4.2.4" + version: "4.2.5" permission_handler: dependency: "direct main" description: name: permission_handler - sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 url: "https://pub.dev" source: hosted - version: "11.3.1" + version: "12.0.1" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: "71bbecfee799e65aff7c744761a57e817e73b738fedf62ab7afd5593da21f9f1" + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" url: "https://pub.dev" source: hosted - version: "12.0.13" + version: "13.0.1" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0 + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 url: "https://pub.dev" source: hosted - version: "9.4.5" + version: "9.4.7" permission_handler_html: dependency: transitive description: @@ -938,10 +812,10 @@ packages: dependency: transitive description: name: permission_handler_platform_interface - sha256: e9c8eadee926c4532d0305dff94b85bf961f16759c3af791486613152af4b4f9 + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 url: "https://pub.dev" source: hosted - version: "4.2.3" + version: "4.3.0" permission_handler_windows: dependency: transitive description: @@ -954,10 +828,10 @@ packages: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "6.1.0" platform: dependency: transitive description: @@ -982,47 +856,37 @@ packages: url: "https://pub.dev" source: hosted version: "3.9.1" - pool: - dependency: transitive - description: - name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" - url: "https://pub.dev" - source: hosted - version: "1.5.1" posix: dependency: transitive description: name: posix - sha256: a0117dc2167805aa9125b82eee515cc891819bac2f538c83646d355b16f58b9a + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" url: "https://pub.dev" source: hosted - version: "6.0.1" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "81876843eb50dc2e1e5b151792c9a985c5ed2536914115ed04e9c8528f6647b0" - url: "https://pub.dev" - source: hosted - version: "1.4.0" + version: "6.0.3" purplebase: dependency: "direct main" description: - path: "." - ref: "512eab9828d2d1ca9e1b7e48a5742a3b08b9b99f" - resolved-ref: "512eab9828d2d1ca9e1b7e48a5742a3b08b9b99f" - url: "https://github.com/purplebase/purplebase" - source: git - version: "0.2.0" + path: "../../purplebase/purplebase" + relative: true + source: path + version: "0.3.3" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" quiver: dependency: transitive description: @@ -1055,22 +919,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" - shared_preferences: + share_plus: dependency: "direct main" description: - name: shared_preferences - sha256: "3c7e73920c694a436afaf65ab60ce3453d91f84208d761fbd83fc21182134d93" + name: share_plus + sha256: "3424e9d5c22fd7f7590254ba09465febd6f8827c8b19a44350de4ac31d92d3a6" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "12.0.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "02a7d8a9ef346c9af715811b01fbd8e27845ad2c41148eefd31321471b41863d" + sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.11" shared_preferences_foundation: dependency: transitive description: @@ -1099,10 +979,10 @@ packages: dependency: transitive description: name: shared_preferences_web - sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" shared_preferences_windows: dependency: transitive description: @@ -1115,23 +995,23 @@ packages: dependency: transitive description: name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.2" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.0" signer_plugin: - dependency: "direct main" + dependency: transitive description: path: "." - ref: baa5b2fcf7ba8db606a76794582348a1403438b0 + ref: baa5b2 resolved-ref: baa5b2fcf7ba8db606a76794582348a1403438b0 url: "https://github.com/chebizarro/flutter-signer-plugin" source: git @@ -1140,31 +1020,15 @@ packages: dependency: "direct main" description: name: skeletonizer - sha256: "3b202e4fa9c49b017d368fb0e570d4952bcd19972b67b2face071bdd68abbfae" + sha256: eebc03dc86b298e2d7f61e0ebce5713e9dbbc3e786f825909b4591756f196eb6 url: "https://pub.dev" source: hosted - version: "1.4.2" + version: "2.1.0+1" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - source_helper: - dependency: transitive - description: - name: source_helper - sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" - url: "https://pub.dev" - source: hosted - version: "1.3.5" source_span: dependency: transitive description: @@ -1185,34 +1049,34 @@ packages: dependency: transitive description: name: sqflite - sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" sqflite_android: dependency: transitive description: name: sqflite_android - sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" + sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" url: "https://pub.dev" source: hosted - version: "2.5.4+6" + version: "2.5.6" sqflite_darwin: dependency: transitive description: name: sqflite_darwin - sha256: "96a698e2bc82bd770a4d6aab00b42396a7c63d9e33513a56945cbccb594c2474" + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" sqflite_platform_interface: dependency: transitive description: @@ -1225,18 +1089,18 @@ packages: dependency: transitive description: name: sqlite3 - sha256: cb7f4e9dc1b52b1fa350f7b3d41c662e75fc3d399555fa4e5efcf267e9a4fbb5 + sha256: f393d92c71bdcc118d6203d07c991b9be0f84b1a6f89dd4f7eed348131329924 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.9.0" sqlite3_flutter_libs: dependency: "direct main" description: name: sqlite3_flutter_libs - sha256: "73016db8419f019e807b7a5e5fbf2a7bd45c165fed403b8e7681230f3a102785" + sha256: "2b03273e71867a8a4d030861fc21706200debe5c5858a4b9e58f4a1c129586a4" url: "https://pub.dev" source: hosted - version: "0.5.28" + version: "0.5.39" stack_trace: dependency: transitive description: @@ -1246,7 +1110,7 @@ packages: source: hosted version: "1.12.1" state_notifier: - dependency: transitive + dependency: "direct main" description: name: state_notifier sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb @@ -1261,14 +1125,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" string_scanner: dependency: transitive description: @@ -1277,22 +1133,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + string_validator: + dependency: transitive + description: + name: string_validator + sha256: "240f4c98027dfbe8639c8271ef18cc9de735b47067aa15a720cfed9576a512b1" + url: "https://pub.dev" + source: hosted + version: "1.2.0" synchronized: dependency: transitive description: name: synchronized - sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 url: "https://pub.dev" source: hosted - version: "3.3.0+3" - system_info2: - dependency: "direct main" - description: - name: system_info2 - sha256: "65206bbef475217008b5827374767550a5420ce70a04d2d7e94d1d2253f3efc9" - url: "https://pub.dev" - source: hosted - version: "4.0.0" + version: "3.4.0" term_glyph: dependency: transitive description: @@ -1309,22 +1165,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.4" - timing: + timezone: dependency: transitive description: - name: timing - sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 url: "https://pub.dev" source: hosted - version: "1.0.2" - toastification: - dependency: "direct main" - description: - name: toastification - sha256: "4d97fbfa463dfe83691044cba9f37cb185a79bb9205cfecb655fa1f6be126a13" - url: "https://pub.dev" - source: hosted - version: "2.3.0" + version: "0.10.1" typed_data: dependency: transitive description: @@ -1333,38 +1181,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - universal_io: - dependency: transitive - description: - name: universal_io - sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" - url: "https://pub.dev" - source: hosted - version: "2.2.2" url_launcher: dependency: "direct main" description: name: url_launcher - sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 url: "https://pub.dev" source: hosted - version: "6.3.1" + version: "6.3.2" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" + sha256: "0aedad096a85b49df2e4725fa32118f9fa580f3b14af7a2d2221896a02cd5656" url: "https://pub.dev" source: hosted - version: "6.3.14" + version: "6.3.17" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626" + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "6.3.4" url_launcher_linux: dependency: transitive description: @@ -1377,10 +1217,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.3" url_launcher_platform_interface: dependency: transitive description: @@ -1393,18 +1233,18 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.4.1" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" uuid: dependency: transitive description: @@ -1413,6 +1253,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.1" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: ca81fdfaf62a5ab45d7296614aea108d2c7d0efca8393e96174bf4d51e6725b0 + url: "https://pub.dev" + source: hosted + version: "1.1.18" vector_math: dependency: transitive description: @@ -1425,26 +1289,18 @@ packages: dependency: transitive description: name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 url: "https://pub.dev" source: hosted - version: "14.3.1" - watcher: - dependency: transitive - description: - name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" - url: "https://pub.dev" - source: hosted - version: "1.1.1" + version: "15.0.0" web: dependency: transitive description: name: web - sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web_socket: dependency: transitive description: @@ -1457,34 +1313,50 @@ packages: dependency: transitive description: name: web_socket_channel - sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "3.0.1" - web_socket_client: - dependency: transitive - description: - name: web_socket_client - sha256: "0ec5230852349191188c013112e4d2be03e3fc83dbe80139ead9bf3a136e53b5" - url: "https://pub.dev" - source: hosted - version: "0.1.5" + version: "3.0.3" win32: dependency: transitive description: name: win32 - sha256: "8b338d4486ab3fbc0ba0db9f9b4f5239b6697fcee427939a40e720cbb9ee0a69" + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "5.9.0" - win32_registry: + version: "5.15.0" + workmanager: + dependency: "direct main" + description: + name: workmanager + sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10" + url: "https://pub.dev" + source: hosted + version: "0.9.0+3" + workmanager_android: dependency: transitive description: - name: win32_registry - sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + name: workmanager_android + sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7" url: "https://pub.dev" source: hosted - version: "1.1.5" + version: "0.9.0+2" + workmanager_apple: + dependency: transitive + description: + name: workmanager_apple + sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca" + url: "https://pub.dev" + source: hosted + version: "0.9.1+2" + workmanager_platform_interface: + dependency: transitive + description: + name: workmanager_platform_interface + sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47 + url: "https://pub.dev" + source: hosted + version: "0.9.1+1" xdg_directories: dependency: transitive description: @@ -1510,5 +1382,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.7.0-0 <4.0.0" - flutter: ">=3.24.0" + dart: ">=3.8.1 <4.0.0" + flutter: ">=3.32.0" diff --git a/pubspec.yaml b/pubspec.yaml index 22a8378..4b0ded0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,111 +1,135 @@ name: zapstore description: The permissionless app store -publish_to: 'none' +publish_to: "none" -version: 0.2.7+27 +version: 1.0.0+1000 environment: - sdk: '>=3.3.0 <4.0.0' + sdk: ^3.8.1 +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - cupertino_icons: ^1.0.2 - flutter_data: ^2.0.0-rc3 - hooks_riverpod: ^2.5.3 - json_annotation: ^4.9.0 - flutter_hooks: ^0.20.4 - build_runner: ^2.4.8 - collection: ^1.18.0 - path_provider: ^2.1.2 - crypto: ^3.0.3 - go_router: ^14.0.2 - permission_handler: ^11.3.1 - path: ^1.9.0 - http: ^1.2.1 + + flutter_riverpod: ^2.6.1 + flutter_hooks: ^0.21.2 + path_provider: ^2.1.5 + go_router: ^16.0.0 async_button_builder: ^3.0.0+1 - android_package_manager: ^0.7.1 - url_launcher: ^6.2.5 - purplebase: ^0.2.0 - sqlite3_flutter_libs: ^0.5.20 - flutter_markdown: ^0.7.1 - cached_network_image: ^3.3.1 - gap: ^3.0.1 - auto_size_text: ^3.0.0 - intl: ^0.18.1 - device_info_plus: ^11.2.0 - flutter_layout_grid: ^2.0.6 - signer_plugin: ^1.0.0 - skeletonizer: ^1.1.2+1 - flutter_phoenix: ^1.1.1 - percent_indicator: ^4.2.3 - toastification: ^2.0.0 - system_info2: ^4.0.0 - badges: ^3.1.2 - convert: ^3.1.1 - async: ^2.11.0 - app_links: ^6.3.2 + sqlite3_flutter_libs: ^0.5.34 + cached_network_image: ^3.4.1 + skeletonizer: ^2.1.0+1 dart_emoji: ^0.2.0+2 - remove_markdown: ^0.0.2 - install_plugin: ^2.1.0 - shared_preferences: ^2.3.2 - background_downloader: ^8.8.0 - mutex: ^3.1.0 easy_image_viewer: ^1.5.1 - flutter_secure_storage: ^9.2.2 - ndk: ^0.2.4 + models: ^0.3.0 + purplebase: ^0.3.0 + amber_signer: ^0.1.0 + hooks_riverpod: ^2.6.1 + path: ^1.9.1 + any_link_preview: ^3.0.3 + http: ^1.2.1 + url_launcher: ^6.3.0 + flutter_markdown: ^0.7.7+1 + auto_size_text: ^3.0.0 + percent_indicator: ^4.2.5 + flutter_layout_grid: ^2.0.8 + permission_handler: ^12.0.1 + equatable: ^2.0.7 + state_notifier: ^1.0.0 + gap: ^3.0.1 + remove_markdown: ^0.0.2 + intl: ^0.20.2 + collection: ^1.19.1 + qr_flutter: ^4.1.0 + share_plus: ^12.0.0 + background_downloader: ^9.2.6 + connectivity_plus: ^7.0.0 + app_links: ^6.4.1 + flutter_secure_storage: ^10.0.0 + markdown: ^7.3.0 + workmanager: ^0.9.0+3 + flutter_local_notifications: ^19.5.0 dependency_overrides: + models: + path: ../../purplebase/models + # git: + # url: https://github.com/purplebase/models + # ref: main purplebase: - # path: ../../purplebase/purplebase + path: ../../purplebase/purplebase + # git: + # url: https://github.com/purplebase/purplebase + # ref: master + amber_signer: + # path: ../amber_signer git: - url: https://github.com/purplebase/purplebase - ref: 512eab9828d2d1ca9e1b7e48a5742a3b08b9b99f - flutter_data: - git: - url: https://github.com/zapstore/flutter_data - ref: d7376cf1eac8e9c563d4c6eed8e567adc877415c - signer_plugin: - git: - url: https://github.com/chebizarro/flutter-signer-plugin - ref: baa5b2fcf7ba8db606a76794582348a1403438b0 + url: https://github.com/purplebase/amber_signer + ref: main dev_dependencies: flutter_test: sdk: flutter - json_serializable: ^6.7.1 - flutter_lints: ^5.0.0 - icons_launcher: ^3.0.0 + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + mcp_dart: ^0.5.3 + bm25: ^2.2.3 + archive: ^4.0.7 + args: ^2.7.0 + flutter_launcher_icons: ^0.14.4 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. uses-material-design: true + # To add assets to your application, add an assets section, like this: assets: - - assets/images/logo.png - - assets/images/logo-bg.png - - assets/images/logo-fg.png + - assets/images/ + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # Custom fonts fonts: - family: Inter fonts: - - asset: assets/fonts/Inter-Light.ttf - weight: 300 - asset: assets/fonts/Inter-Regular.ttf - - asset: assets/fonts/Inter-Italic.ttf - style: italic - asset: assets/fonts/Inter-Bold.ttf weight: 700 + - asset: assets/fonts/Inter-Light.ttf + weight: 300 + - asset: assets/fonts/Inter-Italic.ttf + style: italic + - family: Inter Display + fonts: + - asset: assets/fonts/InterDisplay-Regular.ttf + - asset: assets/fonts/InterDisplay-ExtraBold.ttf + weight: 800 -icons_launcher: +flutter_launcher_icons: + android: true + ios: false image_path: "assets/images/logo.png" - platforms: - android: - enable: true - image_path: "assets/images/logo.png" - notification_image: "assets/images/logo.png" - adaptive_background_image: "assets/images/logo-bg.png" - adaptive_foreground_image: "assets/images/logo-fg.png" - adaptive_monochrome_image: "assets/images/logo-bw.png" - macos: - enable: true - image_path: "assets/images/logo.png" + adaptive_icon_background: "assets/images/logo-bg.png" + adaptive_icon_foreground: "assets/images/logo-fg.png" + adaptive_icon_monochrome: "assets/images/logo-fg.png" diff --git a/test/version_test.dart b/test/version_test.dart deleted file mode 100644 index d56771a..0000000 --- a/test/version_test.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:zapstore/utils/version.dart'; - -void main() { - group('version parsing', () { - test('returns correct comparison', () { - expect(compareVersions("1.2.3", "1.2.3"), 0); - expect(compareVersions("1.2.3", "1.2.3-alpha"), -1); - expect(compareVersions("1.2.3-alpha", "1.2.3-beta"), 1); - expect(compareVersions("1.2.3-beta", "1.2.3-alpha"), -1); - expect(compareVersions("1.9.0", "1.10.0"), 1); - expect(compareVersions("1.26.8", "1.27.2"), 1); - expect(compareVersions("0.2.7", "0.2.11"), 1); - expect(compareVersions("0.2.11", "0.2.7"), -1); - expect(compareVersions("2024.9", "2024.10-beta2"), 1); - expect(compareVersions("a.x", "b.y"), 0); - }); - }); -} diff --git a/tools/content/CONTEXT.md b/tools/content/CONTEXT.md new file mode 100644 index 0000000..8fce856 --- /dev/null +++ b/tools/content/CONTEXT.md @@ -0,0 +1,1086 @@ +# Purplestack + +Development stack designed for AI agents to build Nostr-enabled Flutter applications. It includes a complete tech stack with Purplebase and Riverpod, documentation and recipes for common implementation scenarios. + +## CRITICAL FOR AI ASSISTANTS - MUST READ FIRST + +**Reference**. The AI assistant's behavior and knowledge is defined by this context file (aliased to other IDE-specific filenames), which serves as the system prompt. **ALWAYS** refer to this document and follow these rules and recommendations. + +### Purplestack MCP Server + +**CRITICAL**: This project relies on the purplestack MCP server for essential recipes and `models` package documentation. **YOU MUST VERIFY THE MCP SERVER IS WORKING BEFORE IMPLEMENTING ANY FEATURE**. If the purplestack MCP server cannot be called or returns 0 tools, there is a configuration issue. Fixes: + - Run `flutter pub get`, then you must prompt the user to reload their IDE/environment + - the `agent.json` (or `mcp.json` or equivalent) file may need to be modified to include the correct "cwd" pointing to the current project directory for the purplestack MCP server configuration, and/or the full path to the `dart` executable in the system + +Available purplestack MCP tools: +- `list_recipes` - List all available implementation recipes +- `read_recipe` - Read a specific recipe by name +- `search_recipes` - Search recipes by keyword +- `list_docs` - List all available documentation +- `read_doc` - Read specific documentation +- `search_docs` - Search documentation by keyword + +**Usage Requirements:** +- **BEFORE IMPLEMENTING ANY FEATURE**: Check for relevant recipes using `search_recipes` **AND** API documentation using `search_docs` +- **For ANY code implementation**: Consult both recipes and documentation first +- **Always search first**: Use `search_recipes` and `search_docs` before writing any code. Always prioritize looking for documentation (`search_docs`) and recipes (purplestack MCP) before calling the nostr MCP +- If no recipes or docs are found for your use case, proceed with standard implementation +- Recipes are complete examples showing how to approach specific features + +### Nostr MCP Server + +The `nostr` MCP server provides Nostr protocol reference and documentation for understanding NIPs, event kinds, and protocol specifications. **It is only really useful when implementing a custom model**. + +### CRITICAL: First prompt checklist + +**First prompt definition**: A prompt is considered "first" if the app has not been renamed yet (i.e., app ID is still "com.example.purplestack"). This is determined by app state, not session state. + +When handling a first prompt (as defined above), you MUST ALWAYS: + +1. Run `fvm flutter pub get` (if fvm available), or `flutter pub get` +2. Initialize a fresh git repository **IF AND ONLY IF THE FIRST GIT COMMIT HASH IN THE REPOSITORY IS `c99f911fa1c2fd25e6a401f146ae5e0f53c459b1`:** + - `rm -rf .git/` (remove existing git history) + - `git init` (start fresh repository) + - Make sure you are NOT deleting commits by the user! +3. Suggest the user an app name, app ID (e.g., `com.sample.app`) and description. Based on obtained information, you MUST call the `rename_app` tool + - `dart tools/scripts/rename_app.dart --name "Your App Name" --app-id "com.sample.app"` – full reference available via purplestack MCP server: `search_recipes renaming`. +4. Summarize what you understood, and ask important questions to ensure all requirements are crystal clear before starting any code +5. Ask them which outputs they want. By default, this project builds an Android app, but Flutter supports iOS, MacOS, Linux and Windows, all of these are preconfigured. **Remove the whole folders** of platforms that the user does not care about. + +**IF THE FLUTTER EXECUTABLE OR THE PURPLESTACK MCP TOOLS ARE NOT AVAILABLE YOU _MUST_ ABORT EVERYTHING _RIGHT NOW_. THAT DOES NOT MEAN LATER, IT MEANS NOW. NO "WE'LL TAKE CARE OF IT LATER". ABORT RIGHT NOW.** + +## Technology Stack + +- **Flutter**: Cross-platform UI framework +- **Dart**: Programming language +- **Riverpod / hooks_riverpod**: State management and dependency injection +- **Flutter Hooks**: React-style hooks for Flutter +- **models**: Domain models for Nostr events +- **Purplebase**: Local-first Nostr SDK with storage and relay pool, implementation of the `models` package interface +- **amber_signer**: NIP-55 Android signer integration +- **GoRouter**: Declarative routing +- **google_fonts**: Font management +- **fluttertoast**: Toast components +- **cached_network_image**: Image loading and caching +- **flutter_markdown**: Markdown rendering +- **auto_size_text**: Responsive text sizing +- **skeletonizer**: Skeleton loading states +- **percent_indicator**: Progress indicators +- **easy_image_viewer**: Image viewing +- **flutter_layout_grid**: Grid layouts +- **table_calendar**: Highly customizable, feature-packed calendar widget +- **dart_emoji**: Emoji support and parsing +- **any_link_preview**: Link preview generation +- **async_button_builder**: Async button states and interactions +- **path_provider**: Platform-specific directory paths +- **sqlite3_flutter_libs**: SQLite3 support for Flutter +- **http**: HTTP client for API requests +- **url_launcher**: Launch URLs in external applications +- **path**: Cross-platform path manipulation +- **chewie**: Professional video player with built-in controls +- **just_audio**: High-quality audio player with advanced features + +**Important**: Flutter can produce binaries for a myriad of operating systems. **Assume the user wants an Android application (arm64-v8a), unless specifically asked otherwise**, take this into account when testing a build or launching a simulator. + +## Development Tools & Package Management + +### FVM (Flutter Version Management) + +When `fvm` is available, always use it for Flutter commands to ensure consistent Flutter version usage across development: + +```bash +# Use fvm flutter instead of direct flutter commands +fvm flutter run +fvm flutter build apk --target-platform android-arm64 --split-per-abi # For Android APK distribution +fvm flutter analyze +``` + +### Package Management + +Always manage packages via the CLI to ensure latest compatible versions are resolved: + +```bash +# Adding packages +fvm dart pub add package_name + +# Removing packages +fvm dart pub remove package_name + +# Getting dependencies +fvm dart pub get +``` + +## Project Structure + +This is a standard Flutter app with multi-platform support, but here are additional details: + +- `lib/main.dart`: App entry point with providers setup +- `lib/router.dart`: Router configuration and provider +- `lib/theme.dart`: Theme related code and providers +- `lib/widgets`: Shared UI components + - `lib/widgets/common`: Generic, reusable components that must NEVER be modified with app-specific behavior. See detailed guidelines in Code Guidelines section +- `lib/screens`: Screen components used by the router +- `lib/utils`: Utility functions and shared logic +- `test/utils`: Testing utilities +- `assets`: Static assets (remember to add any referenced assets to `pubspec.yaml`) + +**⚠️ CRITICAL: DO NOT MODIFY THE `tools` DIRECTORY** + +The `tools` directory contains essential Purplestack infrastructure including MCP servers, build scripts, and project configuration tools. **NEVER modify, delete, or add files to this directory.** Any changes to the `tools` directory will break the Purplestack development environment and MCP server functionality. + +## Storage and Relay Pool Configuration + +Configure storage behavior and relay connections. + +Search for updated configuration syntax via purplestack MCP server (`search_docs storage`) first! But here is a default: + +```dart +final config = StorageConfiguration( + // Database path (null for in-memory) + databasePath: '/path/to/database.sqlite', + + // Whether to keep signatures in local storage + keepSignatures: false, + + // Whether to skip BIP-340 verification + skipVerification: false, + + // Relay groups + relayGroups: { + 'default': { + 'wss://relay.damus.io', + 'wss://relay.primal.net', + }, + 'private': { + 'wss://my-private-relay.com', + }, + }, + + // Default relay group + defaultRelayGroup: 'default', + + // Default source for queries when not specified + defaultQuerySource: LocalAndRemoteSource(stream: false), + + // Connection timeouts + idleTimeout: Duration(minutes: 5), + responseTimeout: Duration(seconds: 6), + + // Streaming configuration + streamingBufferWindow: Duration(seconds: 2), + + // Storage limits + keepMaxModels: 20000, +); +``` + +## Routing + +The project uses a GoRouter with a centralized routing configuration in `router.dart`. To add new routes: + +1. Create your screen in `screens` +2. Import it in `router.dart` + +**Multi-Screen Navigation**: For any multi-screen application request, automatically implement a `BottomNavigationBar` with appropriate tabs and navigation structure. This provides intuitive navigation patterns that users expect on mobile platforms. + +## UI Development Guide + +Comprehensive guide to building beautiful, responsive Flutter apps with Material 3 design and Nostr-specific UI patterns. + +The project uses [Material 3](https://m3.material.io/) with `useMaterial3: true` in the default `MaterialApp`. + +### Material 3 Components + +**Navigation & Structure:** +- **AppBar**: Top app bar with title, navigation icon, and action items +- **BottomNavigationBar**: Primary navigation for multi-screen apps +- **NavigationRail**: Side navigation for larger screens +- **Drawer**: Panel sliding from screen edge for navigation +- **TabBar**: Horizontal tabs for navigating between views + +**Feedback & Overlays:** +- **Alert/AlertDialog**: Modal dialog for critical information or decisions +- **MaterialBanner**: Persistent surface for important messages +- **BottomSheet**: Surface sliding from bottom edge for additional content +- **SnackBar**: Temporary feedback message at bottom of screen +- **Tooltip**: Informative popup on hover/focus/tap + +**Input & Selection:** +- **TextField**: Text input with styling and validation options +- **Checkbox**: Multi-selection control for lists +- **Radio/RadioGroup**: Single-selection control from a set +- **Switch**: On/off toggle control +- **Slider**: Value selection from continuous/discrete range +- **DropdownButton**: Menu with selectable options +- **DatePicker**: Calendar interface for date selection +- **TimePicker**: Time selection interface + +**Actions & Buttons:** +- **Button**: Elevated, Filled, Outlined, Text variants with different emphasis +- **FloatingActionButton**: Primary action button floating above UI +- **IconButton**: Icon-only button for common actions + +**Display & Content:** +- **Card**: Container for single-topic content with elevation +- **ListTile**: Fixed-height row for lists and menus +- **CircleAvatar**: Circular widget for user representation +- **Badge**: Small notification marker for unread messages/updates +- **Chip**: Compact element for input, attributes, or actions +- **ExpansionPanel**: Expandable/collapsible container +- **DataTable**: Rows and columns with sorting/selection/pagination +- **Divider/Separator**: Visual separation between content + +**Progress & Loading:** +- **CircularProgressIndicator**: Circular loading indicator +- **LinearProgressIndicator**: Linear progress bar + +### Package Usage Guidelines + +#### Content Rendering + +##### flutter_markdown + +- **When to use**: Kind 30023 (Articles), custom kinds where Markdown is part of specification, long-form content display screens +- **When NOT to use**: Kind 1 notes (use `NoteParser.parse()` instead), profile descriptions (unless NIP specifies Markdown), any content where Markdown isn't explicitly part of the protocol + +```dart +// ✅ Correct usage for articles +MarkdownBody( + data: article.content, + styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)), +) + +// ❌ Wrong for notes - use NoteParser instead +Text(note.content) // Missing entity parsing +``` + +##### any_link_preview +Already implemented in `NoteParser`, useful for standalone hyperlink rendering: + +##### Rich Text Content with NoteParser + +**CRITICAL**: Always use `NoteParser` for Nostr text content, of kind 1 notes, kind 9 chat messages, replies, or of any other kind, **except** for fields that are known to support Markdown. In that case, Markdown parsing should be used. + +**Profile's About**: When adding profile about information (`profile.about`), automatically use `NoteParser` to handle Nostr entities, hashtags, and links. + +**IMPORTANT FOR NOTE PARSER**: When using `NoteParser`, by default use `NostrEntityWidget` and similar widgets to make replaced text tappable and interactive. + +```dart +import 'package:purplestack/widgets/common/note_parser.dart'; + +// ✅ Always use ParsedContentWidget for note content display +ParsedContentWidget( + content: note.content, + colorPair: [Colors.blue, Colors.blueAccent], + onProfileTap: (pubkey) => context.push('/profile/$pubkey'), + onHashtagTap: (hashtag) => context.push('/hashtag/$hashtag'), +) + +// ✅ Or use NoteParser.parse() directly with custom callbacks +NoteParser.parse( + context, + note.content, + textStyle: Theme.of(context).textTheme.bodyMedium, + onNostrEntity: (entity) => NostrEntityWidget(entity: entity), // Default: makes entities tappable + onHttpUrl: (url) => UrlChipWidget(url: url), + onMediaUrl: (url) => MediaWidget(url: url), + onHashtag: (hashtag) => HashtagWidget(hashtag: hashtag), +) + +// ✅ Profile about example with NoteParser +ParseContentWidget( + content: profile.about ?? '', + colorPair: [Theme.of(context).colorScheme.primary, Theme.of(context).colorScheme.secondary], + onProfileTap: (pubkey) => context.push('/profile/$pubkey'), + onHashtagTap: (hashtag) => context.push('/hashtag/$hashtag'), +) +``` + +#### Layout and Responsiveness + +##### flutter_layout_grid + +- **When to use**: Dashboard layouts with multiple panels, complex responsive designs requiring specific column/row spanning, when CSS Grid-like behavior is needed. + +##### auto_size_text +- **When to use**: User-generated content with varying lengths, responsive cards or tiles with dynamic content, navigation labels that might overflow, any UI where text length is unpredictable and space is limited + +#### Progress and File Operations + +##### percent_indicator +Perfect for Nostr file operations - file upload/download progress with Blossom protocol, media processing operations, sync operations with relays, any long-running operation with measurable progress + +##### table_calendar +Highly customizable calendar widget for any calendar functionality. + +### Loading States & Async Patterns + +**Use skeleton loading** for structured content (feeds, profiles, forms). **Never use spinners for whole screen** - always use skeletons that roughly match what is being loaded. Skeletons should be per-widget, not per-screen, showing various skeleton widgets for different screen areas. + +**Use spinners only for**: buttons, images, and other media loading. + +**Use `async_button_builder` for ALL async operations** to provide proper user feedback and prevent multiple simultaneous operations. + +### Source Configuration & Behavior + +The `Source` parameter controls where data comes from and how queries behave. Choose the appropriate source based on your use case: + +#### LocalSource - Local Storage Only + +Only query local storage, never contact relays. Perfect for offline scenarios or when you only want cached data: + +```dart +// Query only local storage +final localNotes = ref.watch( + query( + authors: {pubkey}, + source: LocalSource(), + ), +); + +// Use in offline mode or for cached data display +final offlineProfile = ref.watch( + query( + authors: {pubkey}, + source: LocalSource(), + ), +); +``` + +#### RemoteSource - Relays Only + +Only query relays, never use local storage. Useful for real-time data or when you want fresh data: + +```dart +// Query specific relay group +final liveNotes = ref.watch( + query( + authors: {pubkey}, + source: RemoteSource( + group: 'social', // Use 'social' relay group + stream: true, // Enable streaming (default) + background: false, // Wait for EOSE before returning + ), + ), +); + +// Query custom relay URLs (overrides group) +final customNotes = ref.watch( + query( + authors: {pubkey}, + source: RemoteSource( + relayUrls: { + 'wss://custom1.relay.io', + 'wss://custom2.relay.io', + }, + stream: true, + background: true, // Don't wait for EOSE + ), + ), +); + +// Non-streaming query (one-time fetch) +final staticNotes = ref.watch( + query( + authors: {pubkey}, + source: RemoteSource( + stream: false, // Disable streaming + background: false, // Wait for complete response + ), + ), +); +``` + +#### LocalAndRemoteSource - Hybrid Approach + +Query both local storage and relays. This is the most common pattern for responsive UIs: + +```dart +// Default hybrid behavior - show local data immediately, update with remote +final hybridNotes = ref.watch( + query( + authors: {pubkey}, + source: LocalAndRemoteSource( + stream: true, // Enable streaming (default) + background: true, // Don't wait for EOSE (default) + ), + ), +); + +// Wait for relay response before returning +final completeNotes = ref.watch( + query( + authors: {pubkey}, + source: LocalAndRemoteSource( + background: false, // Wait for EOSE from relays + ), + ), +); + +// Use specific relay group for remote queries +final groupedNotes = ref.watch( + query( + authors: {pubkey}, + source: LocalAndRemoteSource( + group: 'private', // Use 'private' relay group + background: true, + ), + ), +); + +// Override with custom relays at runtime +final customHybridNotes = ref.watch( + query( + authors: {pubkey}, + source: LocalAndRemoteSource( + relayUrls: { + 'wss://priority.relay.io', + 'wss://backup.relay.io', + }, + background: true, + ), + ), +); +``` + +#### Relay Selection Priority + +The framework selects relays in this order: +1. **`relayUrls`** - When provided, these specific URLs are used +2. **`group`** - Falls back to the relay group from `StorageConfiguration.relayGroups` +3. **`defaultRelayGroup`** - Uses the default group when neither is specified + +#### Query Behavior Summary + +- **All queries** block until local storage returns results +- **`background: false`** - Additionally blocks until EOSE from relays +- **`background: true`** - Returns immediately after local results, relay results stream in +- **Streaming phase** never blocks regardless of `background` setting +- **`stream: false`** - Disables real-time updates after initial fetch + +#### Source Usage Examples + +```dart +// Real-time feed with local cache +final feedState = ref.watch( + query( + limit: 50, + source: LocalAndRemoteSource(stream: true, background: true), + ), +); + +// Profile lookup with fallback +final profileState = ref.watch( + query( + authors: {pubkey}, + source: LocalAndRemoteSource(background: false), // Wait for fresh data + ), +); + +// Offline-first notes +final offlineNotes = ref.watch( + query( + authors: {pubkey}, + source: LocalSource(), // Local only + ), +); + +// Live chat messages +final chatMessages = ref.watch( + query( + tags: {'#e': {channelId}}, + source: RemoteSource( + group: 'chat', + stream: true, + background: true, + ), + ), +); +``` + +### Storage Operations + +#### Saving and Publishing Models + +```dart +// Save locally only +await ref.storage.save({model}); + +// Publish to relays only +await ref.storage.publish({model}); + +// Save locally AND publish to relays +await ref.storage.save({model}); +await ref.storage.publish({model}); + +// Publish to specific relay group +await ref.storage.publish( + {model}, + source: RemoteSource(group: 'social'), +); + +// Publish to custom relays +await ref.storage.publish( + {model}, + source: RemoteSource( + relayUrls: {'wss://my-relay.com'}, + ), +); +``` + +#### Advanced Storage Operations + +```dart +// Query storage asynchronously with source control +final notes = await ref.storage.query( + RequestFilter(authors: {pubkey}).toRequest(), + source: LocalAndRemoteSource(background: false), +); + +// Synchronous local-only query +final localNotes = ref.storage.querySync( + RequestFilter(authors: {pubkey}).toRequest(), +); + +// Clear specific models from storage +await ref.storage.clear( + RequestFilter(authors: {pubkey}).toRequest(), +); + +// Clear all models from storage +await ref.storage.clear(); + +// Cancel ongoing subscriptions +await ref.storage.cancel(request); +``` + +#### Building Complex Queries with RequestFilter + +```dart +// Basic RequestFilter usage +final basicRequest = RequestFilter( + authors: {pubkey1, pubkey2}, + limit: 50, + since: DateTime.now().subtract(Duration(days: 7)), +).toRequest(); + +// Tag-based filtering +final taggedRequest = RequestFilter( + tags: { + '#t': {'nostr', 'flutter'}, + '#e': {replyToEventId}, + '#p': {mentionedPubkey}, + }, +).toRequest(); + +// Time-based filtering +final recentRequest = RequestFilter( + since: DateTime.now().subtract(Duration(hours: 24)), + until: DateTime.now(), + limit: 100, +).toRequest(); + +// Search queries (if supported by storage implementation) +final searchRequest = RequestFilter( + search: 'hello world', + limit: 20, +).toRequest(); + +// Specific event IDs +final specificRequest = RequestFilter( + ids: {eventId1, eventId2, eventId3}, +).toRequest(); + +// Multiple filters in one request +final complexRequest = Request([ + RequestFilter( + authors: {pubkey1}, + kinds: {1}, // Notes only + limit: 10, + ), + RequestFilter( + authors: {pubkey2}, + kinds: {6}, // Reposts only + limit: 5, + ), +]); + +// Use RequestFilter with storage operations +final notes = await ref.storage.query(basicRequest); +``` + +**Pull-to-refresh guidelines:** +DO NOT use pull-to-refresh when streaming data. Check the query `Source` stream property: + +```dart +// ❌ Wrong - streaming data doesn't need pull-to-refresh +final notesState = ref.watch(query(source: LocalAndRemoteSource(stream: true))); + +// ✅ Correct - non-streaming queries can use pull-to-refresh +final notesState = ref.watch(query(source: LocalAndRemoteSource(stream: false))); +if (!source.stream) { + return RefreshIndicator( + onRefresh: () async { + // Refresh logic here + }, + child: NotesList(), + ); +} +``` + +### Design & Theming + +**Typography**: Use `google_fonts` package with Material 3 typography accessed through `Theme.of(context).textTheme`. + +**Theme System**: Complete light/dark theme system controlled via `brightnessProvider`. + +**Recommended Styles:** + +If the user does not specify, **Modern/Clean** style is the default. + +Always adjust palettes to ensure a good contrast ratio, especially with text over backgrounds. + +- **Modern/Clean**: + - **Fonts**: Inter Variable, Outfit Variable, or Manrope + - **Color Scheme**: Minimalist palette with subtle grays (#F8F9FA, #E9ECEF, #6C757D) and a single accent color (#007BFF) + - **UI Elements**: Rounded corners (8-12px), subtle shadows, generous whitespace, clean typography hierarchy + - **Best For**: Productivity apps, dashboards, professional tools + +- **Professional/Corporate**: + - **Fonts**: Roboto, Open Sans, or Source Sans Pro + - **Color Scheme**: Conservative blues and grays (#1E3A8A, #374151, #F3F4F6) with muted accents + - **UI Elements**: Sharp corners (4-6px), structured layouts, consistent spacing, formal typography + - **Best For**: Business applications, enterprise software, financial tools + +- **Creative/Artistic**: + - **Fonts**: Poppins, Nunito, or Comfortaa + - **Color Scheme**: Vibrant, diverse palette with gradients (#FF6B6B, #4ECDC4, #45B7D1, #96CEB4) + - **UI Elements**: Organic shapes, bold colors, playful animations, creative layouts + - **Best For**: Design tools, creative platforms, entertainment apps + +- **Technical/Code**: + - **Fonts**: JetBrains Mono, Fira Code, or Source Code Pro (for monospace) + - **Color Scheme**: Dark theme with syntax highlighting colors (#0D1117, #21262D, #58A6FF, #7EE787) + - **UI Elements**: Monospace fonts, code-style layouts, terminal aesthetics, minimal distractions + - **Best For**: Development tools, code editors, technical documentation + +### Color Scheme Implementation + +When users specify color schemes, use Material 3's color system: +- Use `colorSchemeSeed` to generate cohesive color schemes from a single color +- Apply colors consistently across components (buttons, links, accents) using theme colors +- Test both light and dark mode variants + +### Component Styling Patterns + +- Follow Material 3 design patterns and component variants +- **Always prioritize using Theme colors**: Fetch colors from `Theme.of(context).colorScheme` unless they don't fit the theme +- Use theme-based styling: `Theme.of(context).colorScheme.primary` +- **Deprecated Methods**: Try to use `surface` and `onSurface` instead of `background` and `onBackground`. Try to use `.withValues(alpha: 0.5)` instead of `.withOpacity(0.5)` +- Implement responsive design with breakpoints +- Add hover and focus states for interactive elements + +Always use `Theme.of(context).colorScheme` for consistent theming and test both light/dark variants. + +### Media Loading & Display + +**Always include `errorBuilder`** to prevent crashes: + +```dart +// ✅ Proper image loading with error handling +CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: Colors.grey[200], + child: Center(child: CircularProgressIndicator()), + ), + errorBuilder: (context, error, stackTrace) => Container( + color: Colors.grey[300], + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.broken_image, color: Colors.grey[600], size: 48), + SizedBox(height: 8), + Text('Failed to load image', style: TextStyle(color: Colors.grey[600])), + ], + ), + ), +) + +// ✅ Profile avatars with fallback +CircleAvatar( + radius: 24, + backgroundImage: author?.pictureUrl != null + ? CachedNetworkImageProvider( + author!.pictureUrl!, + errorListener: (error) => debugPrint('Avatar failed: $error'), + ) + : null, + child: author?.pictureUrl == null + ? Icon(Icons.person, size: 24) + : null, +) +``` + +For video playback, use the `chewie` package. For audio playback, use the `just_audio` package. For viewing larger images with zoom, use the `easy_image_viewer` package. + +## Essential Nostr Integration + +This project uses the `models` and `purplebase` packages as the ONLY way to interact with the Nostr network. + +### ⚠️ CRITICAL: Model vs Direct Event Manipulation + +**ALWAYS use model constructors, setters, and relationships instead of direct `model.event` manipulation.** + +**❌ WRONG - Direct event manipulation:** +```dart +// Don't do this - bypasses model interface and breaks abstraction +note.event.addTag('custom', ['value']); +note.event.content = 'modified content'; +``` + +**✅ CORRECT - Use model interface:** +```dart +// Use proper model constructors with parameters +final partialNote = PartialNote("content", tags: {'farming'}); + +// Use model setters and methods +final partialProfile = PartialProfile() + ..displayName = 'New Name' + ..about = 'Updated about'; +``` + +### ⚠️ CRITICAL: Use Existing Models First + +**BEFORE implementing ANY Nostr feature:** + +1. **Analyze the user prompt** to identify exactly which model makes most sense for their use-case (e.g. if they mention "chat message" use `ChatMessage`, not `Note`) +2. **Check existing models** via purplestack MCP server (`search_docs models`) +3. **Search all NIPs** using `mcp_nostr_read_nips_index` tool +4. **Investigate thoroughly** with `mcp_nostr_read_nip` for relevant NIPs +5. **Only create custom kinds** after proving no existing solution works + +**Custom kinds sacrifice interoperability** - use as last resort only. + +### ⚠️ CRITICAL: Model Creation, Updates & Registration + +**For ANY model creation, updating, or registration work, ALWAYS search for recipes first:** + +Use `search_recipes` to find comprehensive implementation guidance before writing any model-related code. There are excellent recipes with step-by-step instructions for: +- Creating custom models +- Updating existing models +- Registering new event kinds +- Model relationships and validation +- Best practices and common patterns + +**Always search recipes before implementing:** `search_recipes models` or `search_recipes custom-kinds` + +**For already registered kinds:** Also search for recipes to understand existing implementations - there should be information in `models-package-summary` (names are subject to change, always use recipe search to find current documentation). + +### Query Providers Usage + +The framework provides three reactive query providers for different use cases: + +#### Typed Query Provider (`query`) + +Query specific model types with full type safety and relationship loading: + +```dart +import 'package:models/models.dart'; + +// Basic typed query +final notesState = ref.watch( + query( + authors: {pubkey1, pubkey2}, + limit: 20, + since: DateTime.now().subtract(Duration(days: 7)), + ), +); + +// With relationship loading using the `and` operator +final notesWithRelationsState = ref.watch( + query( + authors: {userPubkey}, + limit: 10, + and: (note) => { + note.author, // Load author profile + note.reactions, // Load reactions + note.zaps, // Load zaps + // Nested relationships + ...note.reactions.map((reaction) => reaction.author), + ...note.zaps.map((zap) => zap.author), + }, + ), +); + +// Tag-based filtering +final taggedNotes = ref.watch( + query( + tags: { + '#t': {'nostr', 'flutter'}, + '#e': {replyToEventId}, + }, + limit: 50, + ), +); +``` + +#### Multi-Kind Query Provider (`queryKinds`) + +Query events across multiple kinds without type constraints: + +```dart +// Query multiple event kinds simultaneously +final feedState = ref.watch( + queryKinds( + kinds: {1, 6}, // Notes and reposts + authors: {pubkey1, pubkey2, pubkey3}, + limit: 30, + since: DateTime.now().subtract(Duration(hours: 24)), + ), +); + +// Handle mixed model types +switch (feedState) { + case StorageData(:final models): + return ListView.builder( + itemCount: models.length, + itemBuilder: (context, index) { + final model = models[index]; + return switch (model.runtimeType) { + Note => NoteCard(model as Note), + Repost => RepostCard(model as Repost), + _ => GenericEventCard(model), + }; + }, + ); + // ... handle other states +} +``` + +#### Single Model Provider (`model`) + +Watch a specific model instance for real-time updates: + +```dart +// Watch an existing note for updates (reactions, zaps, etc.) +final noteState = ref.watch( + model( + existingNote, + and: (note) => {note.author, note.reactions, note.zaps}, + ), +); + +// Automatically updates when relationships change +final updatedNote = noteState.models.firstOrNull; +if (updatedNote != null) { + return NoteDetailCard(updatedNote); +} +``` + +#### Provider State Handling + +All query providers return `StorageState` which can be pattern matched: + +```dart +final notesState = ref.watch(query(authors: {pubkey})); + +switch (notesState) { + case StorageLoading(): + return CircularProgressIndicator(); + case StorageError(:final exception): + return ErrorWidget(exception.toString()); + case StorageData(:final models): + return NotesList(models); +} + +// Listen for state changes +ref.listen(query(authors: {pubkey}), (previous, next) { + if (next is StorageData && previous is StorageLoading) { + print('Notes loaded: ${next.models.length}'); + } +}); +``` + +**Use `ref.storage` extension**: `await ref.storage.save({model});` instead of verbose provider syntax. + +**Never call query inside loops** - use the `and` operator for relationship loading instead. + +**Always prefer using `query` provider directly on widgets** - only wrap them if logic is complex. Do not create unnecessary indirection layers. + +**CRITICAL: Never put `ref.watch` in conditionals** - Riverpod requires providers to be called unconditionally at the top level of widgets/hooks. + +**Relationship loading requirements**: When using any relationship in your code, ensure it has been previously loaded via the `and` argument in `query`. In `query`'s `and` argument, avoid complex operations using where/map - these should only be used for simple Dart filtering. + +**Relationship resolution optimization**: If relationships are available, resolve them at the last possible point as they are slightly expensive to retrieve. Only access relationship data when actually needed for display or logic. + +### Authentication Basics + +Always use `amber_signer` package first for NIP-55 compatible authentication. See recipe: `search_recipes amber-authentication`. + +### Publishing Basics + +Use `storage.publish(...)` for relay publishing. Note that `storage.save()` and `storage.publish()` are independent - call both for local+remote storage. + +### NIP-19 Identifiers + +Nostr identifiers (`npub`, `nsec`, `note`, `nprofile`, `nevent`, `naddr`) can be encoded/decoded via `Utils.encodeShareableIdentifier` and `Utils.decodeShareableIdentifier`. + +**Never show pubkeys in UI** - always prefer npub format for user-facing display. + +**Profile name display**: Never use `profile.name ?? 'Anonymous'` - always use `profile.nameOrNpub` which handles fallbacks properly. + +## Advanced Nostr Features + +For detailed implementations of advanced Nostr features, search for recipes: + +- **Custom Event Kinds** +- **File Uploads** +- **Lightning Zaps** +- **Direct Messages & Encryption** +- **Feed Building** +- **DVM Integration** +- **Engagement UI** +- **Async UI Patterns** + +There are many more. + +### Signing in a Profile + +Authentication implementation with Amber signer. See recipe. + +### Displaying Engagement Information + +Social engagement metrics (likes, reposts, zaps) for Nostr notes. See recipe. + +### Uploading Files on Nostr + +File upload implementation using the Blossom protocol. See recipe. + +### Nostr Encryption and Decryption + +NIP-44 and NIP-04 encryption for direct messages. See recipe. + +### Custom Data Storage + +Use the `CustomData` model for storing user preferences, app settings, and configuration data. Use encryption (NIP-44 > NIP-04) for sensitive data (NWC strings, cashu tokens). + +## Error Handling and Debugging + +### Automatic Error Handling + +The `purplebase` package automatically handles all low-level Nostr protocol errors including relay connections, malformed events, signature verification, network timeouts, and rate limiting. + +### Debug Information Provider + +For debugging and monitoring, Purplebase exposes the `infoNotifierProvider` which streams diagnostic messages about operations. This is one helpful tool for debugging storage and relay pool issues. + +**Use `infoNotifierProvider` for debugging:** +- **Storage operations**: Database queries, saves, and cache hits +- **Relay pool status**: Connection states, subscription management, publishing results + +**Pro tip**: Create a debug overlay or dedicated debug screen in development builds to continuously monitor the `infoNotifierProvider` information. + +## Code Guidelines + +### Code Style + +- Use latest Dart features and idioms (pattern matching, switch statements, sealed classes, collection operators) +- Always use meaningful variable and function names +- Fix ALL compiler warnings and errors immediately - goal is ZERO compiler messages +- Never use artificial waits (`Future.delayed`) - properly await futures instead +- **NEVER use polling** - always subscribe to listeners and streams +- **NO code repetition** - follow DRY principle (minor exceptions allowed) +- **Widget/function variations**: If there are minor variations on a widget or function, try to add a parameter to it - not copy/paste an entire new one. Prompt the user if unsure +- Use Flutter best practices + +### Architecture + +- Local-first architecture: data from local storage, continuously synced from remote sources +- State management: **Always use `flutter_hooks` for widget-local state**, **Riverpod providers for global state** +- **Never use StatefulWidgets** - hooks provide better composition +- Component-based architecture with shared components in `lib/widgets` +- **SafeArea**: Always use by default to handle device notches and system UI +- Use Dart constants for magic numbers and strings (`kConstant`) +- **Data availability understanding**: Understand if data is available at a certain point in the route structure (or ask the user), and from that point on stop coding defensively - just assume data is there + +### Git Guidelines + +**NEVER commit code changes on behalf of the user.** Always let the user review and commit their own changes. + +### AI Agent Workflow Guidelines + +**When finishing a feature or fix:** +- **DO NOT run the app** (`flutter run...`) unless user explicitly asks to +- **Be BRIEF when summarizing** what you produced - focus on key functionality implemented +- **DO NOT celebrate** - instead ask the user if they are satisfied or need further changes + +### Common Widget Architecture + +The widgets in `widgets/common` are generic, reusable components that must remain pure and framework-agnostic. + +**Do not modify common widgets with app-specific behavior** - use their callback systems for customization instead. + +**✅ Generic (belongs in `/common/`):** +- Takes data through parameters, never hardcodes app-specific values +- Uses callback functions for handling actions +- Focuses on rendering and interaction patterns, not business logic +- Configurable through props and styling parameters + +**❌ App-Specific (belongs in app screens/widgets):** +- Hardcoded business rules or app-specific behavior +- Direct navigation to specific screens +- App-specific styling that can't be configured + +**Current Generic Widgets (DO NOT MODIFY):** +1. `NoteParser` - Parses Nostr content with customizable callbacks +2. `EngagementRow` - Social engagement metrics with callback-based interaction +3. `TimeUtils` & `TimeAgoText` - Time formatting utilities +4. `ProfileAvatar` - Avatar component with Profile model and styling parameters + +### Testing + +**⚠️ DO NOT WRITE TESTS** unless the user is experiencing a specific problem or explicitly requests tests. Focus on building functionality that works. + +### Performance + +- Optimize images and assets +- Use const constructors where possible +- Implement proper error handling +- Consider lazy loading for large lists + +### Utility Functions + +Always check for existing utilities before creating new ones: +- **TimeUtils & TimeAgoText**: For timestamp formatting (generic components) +- **Utils (from models package)**: For Nostr-related utilities +- **NoteParser**: REQUIRED for displaying note content (generic component) + +Always use available MCP servers before searching the web. + +## Security and Environment + +### API Key Management + +**No API keys are required** in Purplestack projects. The Nostr protocol is decentralized. + +### Private Key Security + +Default: Private keys handled in-memory only with `Bip340PrivateKeySigner`. For persistent nsec storage, see recipe: `search_recipes secure-nsec-storage`. + +## Releasing to Production + +For correct app renaming and icon generation, use: `search_recipes renaming`. + +When building for Android distribution: `fvm flutter build apk --target-platform android-arm64 --split-per-abi` + +Consider using [Zapstore](https://zapstore.dev) for distribution. + +### README Guidelines + +Update README to be short and concise: +- Remove "Purplestack" from title +- Include 1-2 paragraphs describing app and features +- Brief development instructions +- Footer: "Powered by [Purplestack](https://purplestack.io)" \ No newline at end of file diff --git a/tools/content/agent.json b/tools/content/agent.json new file mode 100644 index 0000000..3e1a7be --- /dev/null +++ b/tools/content/agent.json @@ -0,0 +1,29 @@ +{ + "model": "claude-sonnet-4", + "temperature": 0.2, + "mcpServers": { + "purplestack": { + "type": "stdio", + "command": "bash", + "args": [ + "tools/scripts/run-mcp.sh" + ] + }, + "nostr": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@nostrbook/mcp@latest" + ] + }, + "developer": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@soapbox.pub/developer-mcp@latest" + ] + } + } +} \ No newline at end of file diff --git a/tools/content/mcp-content.zip b/tools/content/mcp-content.zip new file mode 100644 index 0000000..9d59d57 Binary files /dev/null and b/tools/content/mcp-content.zip differ diff --git a/tools/content/update-mcp-content.sh b/tools/content/update-mcp-content.sh new file mode 100755 index 0000000..4f3cfcd --- /dev/null +++ b/tools/content/update-mcp-content.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -e + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +echo "Generating MCP content..." +cd "$SCRIPT_DIR/../../../../purplebase/purplestack-context" +./generate-content.sh + +echo "Copying MCP content to project..." +cp "$SCRIPT_DIR/../../../../purplebase/purplestack/tools/content/mcp-content.zip" "$SCRIPT_DIR/mcp-content.zip" + +echo "MCP content updated successfully!" + diff --git a/tools/mcp-content.zip b/tools/mcp-content.zip new file mode 100644 index 0000000..e8a5e3f Binary files /dev/null and b/tools/mcp-content.zip differ diff --git a/tools/scripts/purplestack_mcp.dart b/tools/scripts/purplestack_mcp.dart new file mode 100644 index 0000000..20e8a20 --- /dev/null +++ b/tools/scripts/purplestack_mcp.dart @@ -0,0 +1,372 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:archive/archive.dart'; +import 'package:path/path.dart' as path; +import 'package:bm25/bm25.dart'; +import 'package:mcp_dart/mcp_dart.dart'; + +/// Main entry point for the MCP server +void main() async { + final server = PurpleStackMcpServer(); + + try { + await server.initialize(); + server.start(); + } catch (e) { + stderr.writeln('Failed to initialize server: $e'); + exit(1); + } +} + +/// MCP server that serves recipes and API documentation +class PurpleStackMcpServer { + static String get contentZipPath { + final scriptUri = Platform.script; + final scriptDir = path.dirname(scriptUri.toFilePath()); + return path.normalize( + path.join(scriptDir, '..', 'content', 'mcp-content.zip'), + ); + } + + late McpServer _server; + + // Content storage - now using full paths as keys + final Map _recipes = {}; + final Map _docs = {}; + + // Search indexes + BM25? _recipeSearchIndex; + BM25? _docSearchIndex; + List _recipePaths = []; + List _docPaths = []; + + PurpleStackMcpServer(); + + /// Initialize the server and load content + Future initialize() async { + // Create MCP server + _server = McpServer( + Implementation(name: 'Purplestack Context Server', version: '1.0.0'), + options: ServerOptions( + capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), + ), + ); + + // Load content from zip file + await _loadContent(); + + // Build search indexes + await _buildSearchIndexes(); + + // Register tools + _registerTools(); + } + + /// Start the MCP server + void start() { + _server.connect(StdioServerTransport()); + } + + /// Load content from the zip file + Future _loadContent() async { + final file = File(contentZipPath); + if (!file.existsSync()) { + throw Exception('Content zip file not found: $contentZipPath'); + } + + final bytes = await file.readAsBytes(); + final archive = ZipDecoder().decodeBytes(bytes); + + for (final file in archive) { + if (file.isFile && + (file.name.endsWith('.md') || file.name.endsWith('.html'))) { + final content = utf8.decode(file.content as List); + + if (file.name.startsWith('recipes/')) { + // Use full path as key, removing only the 'recipes/' prefix + final recipePath = file.name.substring('recipes/'.length); + _recipes[recipePath] = content; + } else if (file.name.startsWith('api-docs/')) { + // Use full path as key, removing only the 'api-docs/' prefix + final docPath = file.name.substring('api-docs/'.length); + _docs[docPath] = content; + } + } + } + } + + /// Build search indexes for recipes and docs + Future _buildSearchIndexes() async { + if (_recipes.isNotEmpty) { + // Build recipe search index + _recipePaths = _recipes.keys.toList(); + final recipeDocuments = _recipes.entries.map((entry) { + return '${entry.key} ${entry.value}'; + }).toList(); + _recipeSearchIndex = await BM25.build(recipeDocuments); + } + + if (_docs.isNotEmpty) { + // Build docs search index + _docPaths = _docs.keys.toList(); + final docDocuments = _docs.entries.map((entry) { + return '${entry.key} ${entry.value}'; + }).toList(); + _docSearchIndex = await BM25.build(docDocuments); + } + } + + /// Register all tools with the MCP server + void _registerTools() { + // List recipes tool + _server.tool( + 'list_recipes', + description: 'List all available recipes', + callback: ({args, extra}) async { + final result = await _listRecipes({}); + return CallToolResult.fromContent(content: [TextContent(text: result)]); + }, + ); + + // Read recipe tool + _server.tool( + 'read_recipe', + description: 'Read a specific recipe by name', + inputSchemaProperties: { + 'name': {'type': 'string', 'description': 'Name of the recipe to read'}, + }, + callback: ({args, extra}) async { + final result = await _readRecipe(args ?? {}); + return CallToolResult.fromContent(content: [TextContent(text: result)]); + }, + ); + + // Search recipes tool + _server.tool( + 'search_recipes', + description: 'Search recipes by query', + inputSchemaProperties: { + 'query': {'type': 'string', 'description': 'Search query for recipes'}, + }, + callback: ({args, extra}) async { + final result = await _searchRecipes(args ?? {}); + return CallToolResult.fromContent(content: [TextContent(text: result)]); + }, + ); + + // List docs tool + _server.tool( + 'list_docs', + description: 'List all available documentation', + callback: ({args, extra}) async { + final result = await _listDocs({}); + return CallToolResult.fromContent(content: [TextContent(text: result)]); + }, + ); + + // Read doc tool + _server.tool( + 'read_doc', + description: 'Read a specific document by name', + inputSchemaProperties: { + 'name': { + 'type': 'string', + 'description': 'Name of the document to read', + }, + }, + callback: ({args, extra}) async { + final result = await _readDoc(args ?? {}); + return CallToolResult.fromContent(content: [TextContent(text: result)]); + }, + ); + + // Search docs tool + _server.tool( + 'search_docs', + description: 'Search documentation by query', + inputSchemaProperties: { + 'query': { + 'type': 'string', + 'description': 'Search query for documentation', + }, + }, + callback: ({args, extra}) async { + final result = await _searchDocs(args ?? {}); + return CallToolResult.fromContent(content: [TextContent(text: result)]); + }, + ); + } + + // Tool handlers + + Future _listRecipes(Map arguments) async { + if (_recipes.isEmpty) { + return 'No recipes available.'; + } + + final recipeList = _recipes.keys.toList()..sort(); + return 'Available recipes:\n${recipeList.map((path) => '- $path').join('\n')}'; + } + + Future _readRecipe(Map arguments) async { + final name = arguments['name'] as String?; + if (name == null) { + return 'Error: Recipe name is required'; + } + + // Try exact match first + var recipe = _recipes[name]; + if (recipe != null) { + return recipe; + } + + // Try partial match for backwards compatibility + final matchingKeys = _recipes.keys + .where( + (key) => + key.toLowerCase().contains(name.toLowerCase()) || + path.basenameWithoutExtension(key).toLowerCase() == + name.toLowerCase(), + ) + .toList(); + + if (matchingKeys.length == 1) { + return _recipes[matchingKeys.first]!; + } else if (matchingKeys.length > 1) { + return 'Multiple recipes found. Please be more specific:\n${matchingKeys.map((key) => '- $key').join('\n')}'; + } + + final suggestions = _findSimilarKeys(name, _recipes.keys.toList()); + final suggestionText = suggestions.isNotEmpty + ? '\n\nDid you mean: ${suggestions.join(', ')}?' + : ''; + return 'Recipe "$name" not found.$suggestionText'; + } + + Future _searchRecipes(Map arguments) async { + final query = arguments['query'] as String?; + if (query == null || query.isEmpty) { + return 'Error: Search query is required'; + } + + if (_recipeSearchIndex == null) { + return 'Search index not available'; + } + + final results = await _recipeSearchIndex!.search(query); + if (results.isEmpty) { + return 'No recipes found for query: "$query"'; + } + + // Build documents list for index lookup + final recipeDocuments = _recipes.entries.map((entry) { + return '${entry.key} ${entry.value}'; + }).toList(); + + final resultText = StringBuffer('Search results for "$query":\n\n'); + for (final result in results.take(5)) { + final index = recipeDocuments.indexOf(result.doc.text); + if (index != -1) { + final recipePath = _recipePaths[index]; + final score = result.score.toStringAsFixed(2); + resultText.writeln('$recipePath ($score)'); + } + } + + return resultText.toString().trim(); + } + + Future _listDocs(Map arguments) async { + if (_docs.isEmpty) { + return 'No documentation available.'; + } + + final docList = _docs.keys.toList()..sort(); + return 'Available documentation:\n${docList.map((path) => '- $path').join('\n')}'; + } + + Future _readDoc(Map arguments) async { + final name = arguments['name'] as String?; + if (name == null) { + return 'Error: Document name is required'; + } + + // Try exact match first + var doc = _docs[name]; + if (doc != null) { + return doc; + } + + // Try partial match for backwards compatibility + final matchingKeys = _docs.keys + .where( + (key) => + key.toLowerCase().contains(name.toLowerCase()) || + path.basenameWithoutExtension(key).toLowerCase() == + name.toLowerCase(), + ) + .toList(); + + if (matchingKeys.length == 1) { + return _docs[matchingKeys.first]!; + } else if (matchingKeys.length > 1) { + return 'Multiple documents found. Please be more specific:\n${matchingKeys.map((key) => '- $key').join('\n')}'; + } + + final suggestions = _findSimilarKeys(name, _docs.keys.toList()); + final suggestionText = suggestions.isNotEmpty + ? '\n\nDid you mean: ${suggestions.join(', ')}?' + : ''; + return 'Document "$name" not found.$suggestionText'; + } + + Future _searchDocs(Map arguments) async { + final query = arguments['query'] as String?; + if (query == null || query.isEmpty) { + return 'Error: Search query is required'; + } + + if (_docSearchIndex == null) { + return 'Search index not available'; + } + + final results = await _docSearchIndex!.search(query); + if (results.isEmpty) { + return 'No documentation found for query: "$query"'; + } + + // Build documents list for index lookup + final docDocuments = _docs.entries.map((entry) { + return '${entry.key} ${entry.value}'; + }).toList(); + + final resultText = StringBuffer('Search results for "$query":\n\n'); + for (final result in results.take(5)) { + final index = docDocuments.indexOf(result.doc.text); + if (index != -1) { + final docPath = _docPaths[index]; + final score = result.score.toStringAsFixed(2); + resultText.writeln('$docPath ($score)'); + } + } + + return resultText.toString().trim(); + } + + /// Find similar keys for suggestions + List _findSimilarKeys(String input, List keys) { + final inputLower = input.toLowerCase(); + return keys + .where( + (key) => + key.toLowerCase().contains(inputLower) || + inputLower.contains(key.toLowerCase()) || + path + .basenameWithoutExtension(key) + .toLowerCase() + .contains(inputLower), + ) + .take(3) + .toList(); + } +} diff --git a/tools/scripts/rename_app.dart b/tools/scripts/rename_app.dart new file mode 100644 index 0000000..aa59040 --- /dev/null +++ b/tools/scripts/rename_app.dart @@ -0,0 +1,545 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; +import 'package:args/args.dart'; + +const String kOriginalAppId = 'com.example.purplestack'; +const String kOriginalAppName = 'Purplestack'; +const String kOriginalAppNameSnakeCase = 'purplestack'; + +/// Converts a string to PascalCase (removes spaces and capitalizes each word) +String _toPascalCase(String input) { + return input + .split(RegExp(r'[\s_-]+')) // Split on spaces, underscores, and hyphens + .where((word) => word.isNotEmpty) + .map((word) => word[0].toUpperCase() + word.substring(1).toLowerCase()) + .join(''); +} + +void main(List args) async { + final parser = ArgParser() + ..addOption('name', abbr: 'n', help: 'App display name') + ..addOption( + 'app-id', + abbr: 'i', + help: 'App ID in reverse domain notation (e.g., com.company.app)', + ) + ..addOption( + 'description', + abbr: 'd', + help: 'App description for pubspec.yaml', + ) + ..addOption( + 'version', + abbr: 'v', + help: 'App version for pubspec.yaml (default: 0.1.0)', + ) + ..addOption('icon', help: 'Path to main app icon image') + ..addOption( + 'adaptive-background', + help: 'Path to adaptive background image (Android)', + ) + ..addOption( + 'adaptive-foreground', + help: 'Path to adaptive foreground image (Android)', + ) + ..addOption( + 'adaptive-monochrome', + help: 'Path to adaptive monochrome image (Android)', + ) + ..addOption( + 'notification-icon', + help: 'Path to notification icon image (Android)', + ) + ..addFlag( + 'help', + abbr: 'h', + help: 'Show usage information', + negatable: false, + ); + + late ArgResults results; + try { + results = parser.parse(args); + } catch (e) { + print('Error: $e\n'); + _printUsage(parser); + exit(1); + } + + if (results['help'] as bool) { + _printUsage(parser); + exit(0); + } + + // Check for mandatory parameters + if (results['name'] == null) { + print('Error: --name is required\n'); + _printUsage(parser); + exit(1); + } + + if (results['app-id'] == null) { + print('Error: --app-id is required\n'); + _printUsage(parser); + exit(1); + } + + final appName = results['name'] as String; + final appId = results['app-id'] as String; + final appDescription = results['description'] as String?; + final appVersion = results['version'] as String? ?? '0.1.0'; + + // Create PascalCase version for Dart code (removes spaces, capitalizes each word) + final appNamePascalCase = _toPascalCase(appName); + final iconPath = results['icon'] as String?; + final adaptiveBackground = results['adaptive-background'] as String?; + final adaptiveForeground = results['adaptive-foreground'] as String?; + final adaptiveMonochrome = results['adaptive-monochrome'] as String?; + final notificationIcon = results['notification-icon'] as String?; + + // Validate app ID format + if (!RegExp(r'^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$').hasMatch(appId)) { + print( + 'Error: App ID must be in reverse domain notation (e.g., com.company.app)', + ); + print( + 'Use only lowercase letters, numbers, and dots. Each segment must start with a letter.', + ); + exit(1); + } + + // Validate version format + if (!RegExp(r'^\d+\.\d+\.\d+(\+\d+)?$').hasMatch(appVersion)) { + print( + 'Error: Version must be in format x.y.z or x.y.z+build (e.g., 1.0.0 or 1.0.0+1)', + ); + exit(1); + } + + // Validate icon paths exist if provided + final iconPaths = [ + iconPath, + adaptiveBackground, + adaptiveForeground, + adaptiveMonochrome, + notificationIcon, + ].where((path) => path != null).cast(); + + for (final iconPathToCheck in iconPaths) { + if (!File(iconPathToCheck).existsSync()) { + print('Error: Icon file not found: $iconPathToCheck'); + exit(1); + } + } + + final appNameSnakeCase = appName.toLowerCase().replaceAll( + RegExp(r'[^a-z0-9]'), + '_', + ); + + print('Renaming Electric app...'); + print('Original App ID: $kOriginalAppId → $appId'); + print('Original App Name: $kOriginalAppName → $appName'); + print('App Name (PascalCase): $appNamePascalCase'); + print('Original Snake Case: $kOriginalAppNameSnakeCase → $appNameSnakeCase'); + if (appDescription != null) print('Description: $appDescription'); + print('Version: $appVersion'); + if (iconPath != null) print('Main Icon: $iconPath'); + if (adaptiveBackground != null) { + print('Adaptive Background: $adaptiveBackground'); + } + if (adaptiveForeground != null) { + print('Adaptive Foreground: $adaptiveForeground'); + } + if (adaptiveMonochrome != null) { + print('Adaptive Monochrome: $adaptiveMonochrome'); + } + if (notificationIcon != null) print('Notification Icon: $notificationIcon'); + print(''); + + final renamer = AppRenamer( + appName, + appNamePascalCase, + appId, + appNameSnakeCase, + appDescription, + appVersion, + iconPath: iconPath, + adaptiveBackground: adaptiveBackground, + adaptiveForeground: adaptiveForeground, + adaptiveMonochrome: adaptiveMonochrome, + notificationIcon: notificationIcon, + ); + + try { + await renamer.renameApp(); + + // Clean and get dependencies + await _cleanAndGetDependencies(); + + // Generate icons if any icon paths were provided + if (renamer._hasIconPaths()) { + await renamer._generateIcons(); + } + + print('\n✅ Electric app renamed successfully!'); + print('\nNext steps:'); + print('1. Test the app on your target platforms'); + print('2. Commit your changes to version control'); + } catch (e) { + print('\n❌ Error renaming app: $e'); + exit(1); + } +} + +void _printUsage(ArgParser parser) { + print( + 'Electric App Renamer - Rename your Electric app across all platforms\n', + ); + print( + 'Usage: dart rename_app.dart --name "App Name" --app-id "com.company.app" [options]\n', + ); + print( + 'This tool will search all files in android/, ios/, lib/, linux/, macos/, test/, windows/ and update pubspec.yaml', + ); + print('(excludes tools/ directory)'); + print('and replace:'); + print('- "$kOriginalAppId" with your new app ID (FIRST)'); + print( + '- "$kOriginalAppName" with your new app name in PascalCase (for code)', + ); + print('- "$kOriginalAppNameSnakeCase" with your new app name in snake_case'); + print('- "com.example" with your new app name (LAST)'); + print('- pubspec.yaml name, description, and version fields specifically'); + print('- version defaults to 0.1.0 if not provided\n'); + print('Options:'); + print(parser.usage); + print('\nExamples:'); + print(' # Basic rename (spaces in name are allowed)'); + print( + ' dart rename_app.dart --name "My Super App" --app-id "com.mycompany.myapp"', + ); + print(''); + print(' # With description and version'); + print( + ' dart rename_app.dart --name "My App" --app-id "com.mycompany.myapp" --description "A Flutter app for managing tasks" --version "1.0.0"', + ); + print(''); + print(' # With main icon'); + print( + ' dart rename_app.dart --name "My App" --app-id "com.mycompany.myapp" --icon "assets/icon.png"', + ); + print(''); + print(' # With Android adaptive icons'); + print( + ' dart rename_app.dart --name "My App" --app-id "com.mycompany.myapp" \\', + ); + print(' --icon "assets/icon.png" \\'); + print(' --adaptive-background "assets/adaptive-bg.png" \\'); + print(' --adaptive-foreground "assets/adaptive-fg.png" \\'); + print(' --adaptive-monochrome "assets/adaptive-mono.png"'); +} + +class AppRenamer { + final String appName; + final String appNamePascalCase; + final String appId; + final String appNameSnakeCase; + final String? appDescription; + final String appVersion; + final String? iconPath; + final String? adaptiveBackground; + final String? adaptiveForeground; + final String? adaptiveMonochrome; + final String? notificationIcon; + + AppRenamer( + this.appName, + this.appNamePascalCase, + this.appId, + this.appNameSnakeCase, + this.appDescription, + this.appVersion, { + this.iconPath, + this.adaptiveBackground, + this.adaptiveForeground, + this.adaptiveMonochrome, + this.notificationIcon, + }); + + Future renameApp() async { + print('🔍 Searching and replacing in all files...'); + + // Directories to search (excluding tools) + final searchDirs = [ + 'android', + 'ios', + 'lib', + 'linux', + 'macos', + 'test', + 'windows', + ]; + + int filesProcessed = 0; + int filesChanged = 0; + + // Process pubspec.yaml specifically + final pubspecFile = File('pubspec.yaml'); + if (pubspecFile.existsSync()) { + final result = await _processPubspecFile(pubspecFile); + filesProcessed++; + if (result) filesChanged++; + } + + // Process all files in search directories + for (final dirName in searchDirs) { + final dir = Directory(dirName); + if (!dir.existsSync()) { + print('⚠️ Directory $dirName not found, skipping'); + continue; + } + + await for (final entity in dir.list(recursive: true)) { + if (entity is File) { + final result = await _processFile(entity); + filesProcessed++; + if (result) filesChanged++; + } + } + } + + print('✅ Processed $filesProcessed files, changed $filesChanged files'); + } + + Future _processPubspecFile(File file) async { + try { + String content = await file.readAsString(); + String originalContent = content; + + // Replace the exact snake_case project name + content = content.replaceAll( + RegExp('^name:\\s+$kOriginalAppNameSnakeCase\$', multiLine: true), + 'name: $appNameSnakeCase', + ); + + // Replace description if provided + if (appDescription != null) { + content = content.replaceAll( + RegExp(r'^description:.*$', multiLine: true), + 'description: $appDescription', + ); + } + + // Replace version (always overwrite with default 0.1.0 or provided value) + content = content.replaceAll( + RegExp(r'^version:\s+[\d\.]+(\+\d+)?$', multiLine: true), + 'version: $appVersion', + ); + + // Write back if content changed + if (content != originalContent) { + await file.writeAsString(content); + print(' ✓ Updated ${file.path}'); + return true; + } + + return false; + } catch (e) { + print(' ⚠️ Error updating ${file.path}: $e'); + return false; + } + } + + Future _processFile(File file) async { + try { + String content = await file.readAsString(); + String originalContent = content; + + // Perform exact string replacements - APP ID FIRST! + content = content.replaceAll(kOriginalAppId, appId); + + // Use PascalCase for Dart code identifiers (no spaces) + content = content.replaceAll(kOriginalAppName, appNamePascalCase); + content = content.replaceAll(kOriginalAppNameSnakeCase, appNameSnakeCase); + + // For display names in platform files, we might want the full name with spaces + // Check if this is a platform-specific file that needs display names + if (_shouldUseDisplayName(file.path)) { + // In platform files, replace PascalCase back with display name for labels + content = content.replaceAll(appNamePascalCase, appName); + } + + content = content.replaceAll('com.example', appName); + + // Write back if content changed + if (content != originalContent) { + await file.writeAsString(content); + print(' ✓ Updated ${file.path}'); + return true; + } + + return false; + } catch (e) { + // Skip binary files or files we can't read/write + return false; + } + } + + /// Determines if a file should use the display name (with spaces) instead of PascalCase + /// Platform files like AndroidManifest.xml and Info.plist need display names for labels + bool _shouldUseDisplayName(String filePath) { + return filePath.contains('AndroidManifest.xml') || + filePath.contains('Info.plist') || + filePath.contains('strings.xml') || + filePath.contains('.xml') && filePath.contains('android') || + filePath.endsWith('.plist'); + } + + bool _hasIconPaths() { + return iconPath != null || + adaptiveBackground != null || + adaptiveForeground != null || + adaptiveMonochrome != null || + notificationIcon != null; + } + + Future _generateIcons() async { + print('🎨 Generating app icons...'); + + final pubspecFile = File('pubspec.yaml'); + if (!pubspecFile.existsSync()) { + throw Exception('pubspec.yaml not found'); + } + + String originalContent = await pubspecFile.readAsString(); + String modifiedContent = originalContent; + + try { + // Add icons_launcher configuration to pubspec.yaml + final iconsConfig = _buildIconsLauncherConfig(); + modifiedContent = '$originalContent\n$iconsConfig'; + await pubspecFile.writeAsString(modifiedContent); + + // Run icons_launcher + final result = await Process.run('dart', [ + 'run', + 'icons_launcher:create', + ]); + + if (result.exitCode == 0) { + print('✅ Icons generated successfully'); + if (result.stdout.toString().isNotEmpty) { + print('Icons output: ${result.stdout}'); + } + } else { + print('❌ Failed to generate icons'); + print('Error: ${result.stderr}'); + throw Exception('Icon generation failed: ${result.stderr}'); + } + } finally { + // Restore original pubspec.yaml + await pubspecFile.writeAsString(originalContent); + } + } + + String _buildIconsLauncherConfig() { + final buffer = StringBuffer(); + buffer.writeln('icons_launcher:'); + + // Main icon path (required by icons_launcher) + final mainIcon = iconPath ?? adaptiveForeground ?? adaptiveBackground; + if (mainIcon != null) { + buffer.writeln(' image_path: "$mainIcon"'); + } + + buffer.writeln(' platforms:'); + + // Android configuration + buffer.writeln(' android:'); + buffer.writeln(' enable: true'); + if (iconPath != null) { + buffer.writeln(' image_path: "$iconPath"'); + } + if (notificationIcon != null) { + buffer.writeln(' notification_image: "$notificationIcon"'); + } else if (iconPath != null) { + buffer.writeln(' notification_image: "$iconPath"'); + } + if (adaptiveBackground != null) { + buffer.writeln(' adaptive_background_image: "$adaptiveBackground"'); + } + if (adaptiveForeground != null) { + buffer.writeln(' adaptive_foreground_image: "$adaptiveForeground"'); + } + if (adaptiveMonochrome != null) { + buffer.writeln(' adaptive_monochrome_image: "$adaptiveMonochrome"'); + } + + // iOS configuration + buffer.writeln(' ios:'); + buffer.writeln(' enable: true'); + if (iconPath != null) { + buffer.writeln(' image_path: "$iconPath"'); + } + + // macOS configuration + buffer.writeln(' macos:'); + buffer.writeln(' enable: true'); + if (iconPath != null) { + buffer.writeln(' image_path: "$iconPath"'); + } + + // Windows configuration + buffer.writeln(' windows:'); + buffer.writeln(' enable: true'); + if (iconPath != null) { + buffer.writeln(' image_path: "$iconPath"'); + } + + // Linux configuration + buffer.writeln(' linux:'); + buffer.writeln(' enable: true'); + if (iconPath != null) { + buffer.writeln(' image_path: "$iconPath"'); + } + + return buffer.toString(); + } +} + +Future _cleanAndGetDependencies() async { + print('\n🧹 Cleaning pub cache and getting dependencies...'); + + try { + // Check if we have flutter command available + print('Running flutter clean...'); + final cleanResult = await Process.run('flutter', ['clean']); + if (cleanResult.exitCode != 0) { + print('⚠️ Flutter clean failed, but continuing...'); + } + + print('Running dart pub cache clean...'); + final cacheResult = await Process.run('dart', ['pub', 'cache', 'clean']); + if (cacheResult.exitCode != 0) { + print('⚠️ Pub cache clean failed, but continuing...'); + } + + print('Running flutter pub get...'); + final pubGetResult = await Process.run('flutter', ['pub', 'get']); + if (pubGetResult.exitCode != 0) { + print('❌ Flutter pub get failed:'); + print(pubGetResult.stderr); + throw Exception('Failed to run flutter pub get'); + } + + print('✓ Dependencies updated successfully'); + } catch (e) { + print('⚠️ Error cleaning dependencies: $e'); + print('Please run the following commands manually:'); + print(' flutter clean'); + print(' dart pub cache clean'); + print(' flutter pub get'); + } +} diff --git a/tools/scripts/run-mcp.sh b/tools/scripts/run-mcp.sh new file mode 100755 index 0000000..fce04cc --- /dev/null +++ b/tools/scripts/run-mcp.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Run the purplestack MCP server +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +(fvm flutter pub get || flutter pub get) && dart run "$SCRIPT_DIR/purplestack_mcp.dart" \ No newline at end of file diff --git a/tools/scripts/sync-tools.sh b/tools/scripts/sync-tools.sh new file mode 100755 index 0000000..75db0f0 --- /dev/null +++ b/tools/scripts/sync-tools.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -e + +UPSTREAM_URL="https://github.com/purplebase/purplestack.git" +UPSTREAM_BRANCH="main" +UPSTREAM_LOCAL_DIR=/tmp/purplestack-tmp +FOLDER="tools" + +# 1. shallow clone upstream into a hidden mirror +if [ ! -d "$UPSTREAM_LOCAL_DIR" ]; then + git clone --depth 1 --branch "$UPSTREAM_BRANCH" "$UPSTREAM_URL" "$UPSTREAM_LOCAL_DIR" +fi + +# 2. fetch latest +git -C "$UPSTREAM_LOCAL_DIR" fetch --depth 1 origin "$UPSTREAM_BRANCH" +git -C "$UPSTREAM_LOCAL_DIR" reset --hard "origin/$UPSTREAM_BRANCH" + +# 3. copy only the folder we care about +rm -rf "$FOLDER" +cp -a "$UPSTREAM_LOCAL_DIR/$FOLDER" "$(dirname "$FOLDER")" + +fvm flutter pub upgrade || flutter pub upgrade \ No newline at end of file diff --git a/zapstore.yaml b/zapstore.yaml index 2d39dc9..df7eb61 100644 --- a/zapstore.yaml +++ b/zapstore.yaml @@ -1,3 +1,3 @@ repository: https://github.com/zapstore/zapstore assets: - - .*.apk + - build/app/outputs/flutter-apk/.*arm64-v8a-release.apk