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 b6a479d..b0b266b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/zapstore/zapstore) +# 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..5a9ac8c --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,96 @@ +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 + } + + 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 = "../.." +} 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..a016f2a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,24 +1,32 @@ + 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..24d65b3 --- /dev/null +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/MainActivity.kt @@ -0,0 +1,14 @@ +package dev.zapstore.alpha + +import dev.zapstore.alpha.plugins.AndroidPackageManagerPlugin +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine + +class MainActivity: FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + // Register the unified package manager plugin + flutterEngine.plugins.add(AndroidPackageManagerPlugin()) + } +} \ 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..126e95e --- /dev/null +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt @@ -0,0 +1,617 @@ +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.app.role.RoleManager +import android.app.admin.DevicePolicyManager +import android.net.Uri +import android.os.Build +import android.os.UserManager +import android.provider.Settings +import android.system.Os +import android.util.Log +import io.flutter.embedding.engine.plugins.FlutterPlugin +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.io.IOException +import java.security.MessageDigest + +private const val TAG = "AndroidPackageManager" + +/** + * AndroidPackageManagerPlugin following Accrescent's proven silent installation approach. + * + * Silent Installation Behavior: + * - Apps installed BY Zapstore: Updates are completely silent (no user prompt) + * - Apps installed by OTHER sources: First update requires user confirmation to transfer + * installer ownership. After accepting once, all subsequent updates are silent. + * + * This is Android's security model to prevent malicious "store hijacking" - even with + * APP_MARKET category and UPDATE_PACKAGES_WITHOUT_USER_ACTION permission. + * + * Requirements: + * - Android 12+ (API 31+) for USER_ACTION_NOT_REQUIRED + * - APP_MARKET category in AndroidManifest.xml + * - UPDATE_PACKAGES_WITHOUT_USER_ACTION permission + * - ENFORCE_UPDATE_OWNERSHIP permission (Android 14+) + */ +class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { + private lateinit var channel: MethodChannel + private lateinit var context: Context + + companion object { + private var staticChannel: MethodChannel? = null + + fun notifyInstallResult(result: Map) { + staticChannel?.invokeMethod("onInstallResult", result) + } + } + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "android_package_manager") + channel.setMethodCallHandler(this) + context = flutterPluginBinding.applicationContext + staticChannel = channel + + Log.d(TAG, "AndroidPackageManagerPlugin initialized") + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + staticChannel = null + } + + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + "install" -> { + val filePath = call.argument("filePath") + if (filePath == null) { + result.error("MISSING_ARGUMENT", "File path is required", null) + return + } + val expectedHash = call.argument("expectedHash") + // Dart integers come as Integer, not Long, so we need to safely convert + val expectedSize = call.argument("expectedSize")?.toLong() + val skipVerification = call.argument("skipVerification") ?: false + installApk(filePath, expectedHash, expectedSize, skipVerification, result) + } + "canInstallSilently" -> { + val packageName = call.argument("packageName") + result.success(canInstallSilently(packageName)) + } + "hasUnknownSourcesPermission" -> { + result.success(hasUnknownSourcesPermission()) + } + "getInstalledApps" -> { + val includeSystem = call.argument("includeSystemApps") ?: false + result.success(getInstalledApps(includeSystem)) + } + "requestInstallPermission" -> { + requestInstallPermission(result) + } + "uninstall" -> { + val packageName = call.argument("packageName") + if (packageName == null) { + result.error("MISSING_ARGUMENT", "Package name is required", null) + return + } + uninstallApp(packageName, result) + } + "launchApp" -> { + val packageName = call.argument("packageName") + if (packageName == null) { + result.error("MISSING_ARGUMENT", "Package name is required", null) + return + } + launchApp(packageName, result) + } + else -> { + result.notImplemented() + } + } + } + + private fun canInstallSilently(packageName: String? = null): Boolean { + try { + // Android 12+ (API 31+) is required for USER_ACTION_NOT_REQUIRED to work + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return false + } + + // 1) UPDATE_PACKAGES_WITHOUT_USER_ACTION permission (the main one we rely on) + val hasUpdateWithoutUserActionPerm = try { + context.checkSelfPermission("android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION") == PackageManager.PERMISSION_GRANTED + } catch (_: Throwable) { false } + + // 2) Device owner (enterprise) can generally install without user action + val isDeviceOwner = try { + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + dpm.isDeviceOwnerApp(context.packageName) + } catch (_: Throwable) { false } + + // 3) Default package installer role (rare; typically system store) + val hasInstallerRole = try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val roleManager = context.getSystemService(Context.ROLE_SERVICE) as RoleManager + val installerRole = "android.app.role.PACKAGE_INSTALLER" + roleManager.isRoleAvailable(installerRole) && + roleManager.isRoleHeld(installerRole) + } else false + } catch (_: Throwable) { false } + + // 4) Check if we can request package installs (basic requirement) + val canRequestInstalls = try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.packageManager.canRequestPackageInstalls() + } else true + } catch (_: Throwable) { false } + + // Additional check for installer ownership if package name provided + val weAreInstaller = if (packageName != null) { + try { + val installerPackageName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + context.packageManager.getInstallSourceInfo(packageName).installingPackageName + } else { + @Suppress("DEPRECATION") + context.packageManager.getInstallerPackageName(packageName) + } + val isInstaller = installerPackageName == context.packageName + isInstaller + } catch (e: PackageManager.NameNotFoundException) { + // Package not installed yet, so we can install it silently (first install) + Log.d(TAG, "Package $packageName not installed yet (NameNotFoundException)") + true + } catch (e: Exception) { + // If we can't verify we're the installer, assume we're not + Log.w(TAG, "Failed to check installer for $packageName", e) + false + } + } else { + true // No specific package check + } + + // Can install silently if we have the permission OR (we're the installer/device owner/have role) + val hasSpecialPrivilege = hasUpdateWithoutUserActionPerm || isDeviceOwner || hasInstallerRole + + // CRITICAL: Even with UPDATE_PACKAGES_WITHOUT_USER_ACTION permission, + // Android requires one-time user confirmation to transfer installer ownership. + // For UI categorization, we must check if we're already the installer. + // If a specific package is being checked, installer ownership is REQUIRED for truly silent updates. + val canSilently = if (packageName != null) { + // For specific package checks: must be installer OR have special privileges AND be installer + // Actually, even with privileges, first update of non-owned app requires user action + canRequestInstalls && weAreInstaller + } else { + // General capability check (no specific package) + canRequestInstalls && hasSpecialPrivilege + } + + return canSilently + } catch (e: Throwable) { + Log.w(TAG, "canInstallSilently check failed", e) + return false + } + } + + 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) { + if (!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, + "message" to "Permission request launched" + )) + } catch (e: Exception) { + result.success(mapOf( + "success" to false, + "message" to "Failed to launch permission request: ${e.message}" + )) + } + } else { + result.success(mapOf( + "success" to true, + "message" to "Permission already granted" + )) + } + } else { + result.success(mapOf( + "success" to true, + "message" to "Permission not required on this Android version" + )) + } + } + + private fun isValidApk(apkFile: File): Boolean { + // Check if file is a valid ZIP/APK by verifying ZIP magic bytes + // APK files are ZIP archives, so they should start with PK\x03\x04 (0x504B0304) + try { + FileInputStream(apkFile).use { fis -> + val magicBytes = ByteArray(4) + val bytesRead = fis.read(magicBytes) + if (bytesRead < 4) { + Log.w(TAG, "File too small to be a valid APK: ${apkFile.length()} bytes") + return false + } + + // Check for ZIP magic number: 0x504B0304 (PK\x03\x04) + val isZip = magicBytes[0] == 0x50.toByte() && + magicBytes[1] == 0x4B.toByte() && + magicBytes[2] == 0x03.toByte() && + magicBytes[3] == 0x04.toByte() + + if (!isZip) { + Log.w(TAG, "File does not have valid ZIP/APK magic bytes") + return false + } + + return true + } + } catch (e: Exception) { + Log.e(TAG, "Error checking APK validity", e) + return false + } + } + + private fun verifyApk( + apkFile: File, + expectedHash: String, + expectedSize: Long, + result: Result + ): Boolean { + val actualSize = apkFile.length() + + // First, verify this is a valid APK/ZIP file + if (!isValidApk(apkFile)) { + val errorMsg = """ + Invalid APK file. The downloaded file is not a valid Android package. + This may indicate: + • Incomplete download + • Corrupted file + • Wrong file type + + File size: ${String.format("%.2f", actualSize / 1024.0 / 1024.0)} MB ($actualSize bytes) + Please try downloading again. + """.trimIndent() + + result.success(mapOf( + "isSuccess" to false, + "errorMessage" to errorMsg + )) + apkFile.delete() + return false + } + + Log.d(TAG, "APK file format validation passed") + + // Calculate SHA-256 hash for verification + // This is the primary integrity check - if hash matches, file is correct + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(8192) + FileInputStream(apkFile).use { fis -> + 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()) { + // Hash mismatch - include size information in error + val expectedSizeMb = String.format("%.2f", expectedSize / 1024.0 / 1024.0) + val actualSizeMb = String.format("%.2f", actualSize / 1024.0 / 1024.0) + val sizeDiff = actualSize - expectedSize + val sizeDiffMb = String.format("%.2f", kotlin.math.abs(sizeDiff) / 1024.0 / 1024.0) + val sizeDiffSign = if (sizeDiff > 0) "+" else "" + + val errorMsg = """ + Hash verification failed. File may be corrupted or tampered. + Expected hash: $expectedHash + Actual hash: $actualHash + Expected size: $expectedSizeMb MB ($expectedSize bytes) + Actual size: $actualSizeMb MB ($actualSize bytes) + Difference: $sizeDiffSign$sizeDiffMb MB ($sizeDiffSign$sizeDiff bytes) + """.trimIndent() + + result.success(mapOf( + "isSuccess" to false, + "errorMessage" to errorMsg + )) + // Don't delete the file - user might want to proceed anyway + return false + } + + Log.d(TAG, "APK verification successful: size=$actualSize bytes (expected $expectedSize), hash=$actualHash") + return true + } + + private fun installApk( + filePath: String, + expectedHash: String?, + expectedSize: Long?, + skipVerification: Boolean, + result: Result + ) { + val file = File(filePath) + if (!file.exists()) { + result.success(mapOf( + "isSuccess" to false, + "errorMessage" to "The APK file was not found" + )) + return + } + + // Verify APK if hash and size are provided, unless skipping verification + if (skipVerification) { + Log.w(TAG, "⚠️ RECKLESS MODE: APK verification SKIPPED by user request ⚠️") + Log.w(TAG, "Installing APK without hash verification: $filePath") + } else if (expectedHash != null && expectedSize != null) { + if (!verifyApk(file, expectedHash, expectedSize, result)) { + return // Verification failed, error already sent + } + } else { + Log.w(TAG, "APK verification skipped - hash or size not provided") + } + + try { + installApp(file, result) + } catch (e: SecurityException) { + // Fallback to system installer + launchSystemInstaller(filePath, result) + } catch (e: Exception) { + result.success(mapOf( + "isSuccess" to false, + "errorMessage" to "Installation failed: ${e.message}" + )) + } + } + + private fun installApp(apkFile: File, result: Result) { + // Check UserManager restrictions (like Accrescent does) + 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) { + Log.w(TAG, "Installation blocked by UserManager policy") + result.success(mapOf( + "isSuccess" to false, + "errorMessage" to "Installation blocked by device policy" + )) + return + } + + val packageInstaller = context.packageManager.packageInstaller + + // Get package info from the APK + val pkgInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0) + if (pkgInfo == null) { + throw IOException("Invalid APK file") + } + + val packageName = pkgInfo.packageName + val isUpdate = try { + context.packageManager.getPackageInfo(packageName, 0) + true + } catch (e: PackageManager.NameNotFoundException) { + false + } + + Log.d(TAG, "Installing $packageName (update=$isUpdate)") + + // Match Accrescent's session setup exactly + // The APP_MARKET category in the manifest grants silent install privileges + val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + sessionParams.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) + } + sessionParams.setInstallLocation(pkgInfo.installLocation) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + sessionParams.setPackageSource(PackageInstaller.PACKAGE_SOURCE_STORE) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + sessionParams.setRequestUpdateOwnership(true) + } + + val sessionId = packageInstaller.createSession(sessionParams) + val session = packageInstaller.openSession(sessionId) + + // Transfer the APK file - match Accrescent's approach exactly + FileInputStream(apkFile).use { fileInputStream -> + // Use Os.fstat for accurate file size (like Accrescent) + val fileDescriptor = fileInputStream.fd + val fileSize = Os.fstat(fileDescriptor).st_size + + // Use APK file name instead of generic "package" + val sessionStream = session.openWrite(apkFile.name, 0, fileSize) + + fileInputStream.copyTo(sessionStream) + + // Close in Accrescent's order: file first, then session + fileInputStream.close() + session.fsync(sessionStream) + sessionStream.close() + } + + // Create pending intent for installation result + val intent = Intent(context.applicationContext, InstallResultReceiver::class.java) + val pendingIntent = PendingIntent.getBroadcast( + context.applicationContext, + 0, + intent, + PendingIntent.FLAG_MUTABLE + ) + + // Commit the session + session.commit(pendingIntent.intentSender) + session.close() + + // Return success - actual result will come via broadcast + result.success(mapOf( + "isSuccess" to true, + "errorMessage" to "", + "isUpdate" to isUpdate, + "packageName" to packageName, + "sessionId" to sessionId + )) + } + + private fun launchSystemInstaller(filePath: String, result: Result) { + try { + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(Uri.parse("file://$filePath"), "application/vnd.android.package-archive") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + context.startActivity(intent) + + result.success(mapOf( + "isSuccess" to true, + "errorMessage" to "", + "fallbackMethod" to "system_installer" + )) + } catch (e: Exception) { + result.success(mapOf( + "isSuccess" to false, + "errorMessage" to "Failed to launch installer: ${e.message}" + )) + } + } + + private fun uninstallApp(packageName: String, result: Result) { + try { + // Check if the package is installed + val pm = context.packageManager + try { + pm.getPackageInfo(packageName, 0) + } catch (e: PackageManager.NameNotFoundException) { + result.success(mapOf( + "isSuccess" to false, + "errorMessage" to "Package not found: $packageName" + )) + return + } + + // Launch system uninstaller + val intent = Intent(Intent.ACTION_DELETE).apply { + data = Uri.parse("package:$packageName") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + context.startActivity(intent) + + result.success(mapOf( + "isSuccess" to true, + "errorMessage" to "", + "packageName" to packageName + )) + + } catch (e: Exception) { + Log.e(TAG, "Uninstall failed for $packageName", e) + result.success(mapOf( + "isSuccess" to false, + "errorMessage" to "Uninstall failed: ${e.message}" + )) + } + } + + private fun launchApp(packageName: String, result: Result) { + try { + val pm = context.packageManager + + // Check if the package is installed + try { + pm.getPackageInfo(packageName, 0) + } catch (e: PackageManager.NameNotFoundException) { + result.success(mapOf( + "isSuccess" to false, + "errorMessage" to "Package not found: $packageName" + )) + return + } + + // Get the launch intent for the package + val launchIntent = pm.getLaunchIntentForPackage(packageName) + if (launchIntent == null) { + result.success(mapOf( + "isSuccess" to false, + "errorMessage" to "No launch activity found for package: $packageName" + )) + return + } + + // Add flags to ensure the app launches properly + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + launchIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + + // Launch the app + context.startActivity(launchIntent) + + result.success(mapOf( + "isSuccess" to true, + "errorMessage" to "", + "packageName" to packageName + )) + + } catch (e: Exception) { + Log.e(TAG, "Launch failed for $packageName", e) + result.success(mapOf( + "isSuccess" to false, + "errorMessage" to "Launch failed: ${e.message}" + )) + } + } + + private fun getInstalledApps(includeSystemApps: Boolean): List> { + val pm = context.packageManager + val packages = pm.getInstalledPackages(0) + val out = mutableListOf>() + + for (pkg in packages) { + val appInfo = pkg.applicationInfo ?: continue + val isSystem = (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0 + if (!includeSystemApps && isSystem) continue + + val name = appInfo.loadLabel(pm)?.toString() + val bundleId = pkg.packageName + val versionName = pkg.versionName + val versionCode: Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + pkg.longVersionCode + } else { + @Suppress("DEPRECATION") + pkg.versionCode.toLong() + } + + out.add(mapOf( + "name" to name, + "bundleId" to bundleId, + "versionName" to versionName, + "versionCode" to versionCode, + )) + } + return out + } + +} \ No newline at end of file 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..dba1063 --- /dev/null +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/InstallResultReceiver.kt @@ -0,0 +1,140 @@ +package dev.zapstore.alpha.plugins + +import android.app.NotificationManager +import android.app.PendingIntent +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 +import androidx.core.app.NotificationCompat +import androidx.core.content.getSystemService + +private const val TAG = "InstallResultReceiver" + +/** + * BroadcastReceiver to handle APK installation results + * Enhanced with Accrescent-inspired silent install handling + */ +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) ?: "unknown" + val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + val sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1) + + Log.d(TAG, "Install result: status=$status, package=$packageName, message=$message") + + when (status) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + Log.d(TAG, "User confirmation required for $packageName (installer takeover)") + + // Launch confirmation dialog + val confirmIntent = 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) + } + if (confirmIntent != null) { + confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(confirmIntent) + } catch (e: Exception) { + Log.w(TAG, "Failed to launch confirmation dialog", e) + showUserActionNotification(context, sessionId, confirmIntent, packageName) + } + } + + AndroidPackageManagerPlugin.notifyInstallResult(mapOf( + "success" to false, + "packageName" to packageName, + "message" to "User action required", + "status" to status, + "requiresUserAction" to true + )) + } + + PackageInstaller.STATUS_SUCCESS -> { + Log.d(TAG, "Installation successful: $packageName") + AndroidPackageManagerPlugin.notifyInstallResult(mapOf( + "success" to true, + "packageName" to packageName, + "message" to (message ?: "Installation completed successfully") + )) + } + + PackageInstaller.STATUS_FAILURE -> { + Log.w(TAG, "Installation failed: $packageName - $message") + AndroidPackageManagerPlugin.notifyInstallResult(mapOf( + "success" to false, + "packageName" to packageName, + "message" to (message ?: "Installation failed"), + "status" to status + )) + } + + PackageInstaller.STATUS_FAILURE_ABORTED -> { + Log.d(TAG, "Installation cancelled: $packageName") + AndroidPackageManagerPlugin.notifyInstallResult(mapOf( + "success" to false, + "packageName" to packageName, + "message" to "Installation was cancelled", + "status" to status, + "aborted" to true + )) + } + + else -> { + Log.w(TAG, "Unknown installation status $status: $packageName") + AndroidPackageManagerPlugin.notifyInstallResult(mapOf( + "success" to false, + "packageName" to packageName, + "message" to (message ?: "Installation failed with unknown status"), + "status" to status + )) + } + } + } + + private fun showUserActionNotification( + context: Context, + sessionId: Int, + confirmIntent: Intent, + packageName: String + ) { + // Check notification permission on Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) + != android.content.pm.PackageManager.PERMISSION_GRANTED) { + return + } + } + + val notificationManager = context.getSystemService() + if (notificationManager?.areNotificationsEnabled() == true) { + val pendingIntent = PendingIntent.getActivity( + context, + sessionId, + confirmIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT + ) + + val notification = NotificationCompat.Builder(context, "install_channel") + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle("Installation requires confirmation") + .setContentText("Tap to continue installing $packageName") + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + + try { + notificationManager.notify(sessionId, notification) + } catch (e: Exception) { + Log.w(TAG, "Failed to show notification", e) + } + } + } +} 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/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..56c9f7a --- /dev/null +++ b/lib/constants/app_constants.dart @@ -0,0 +1,22 @@ +/// 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'; + +/// Shareable identifier for the Nostr curation set +const kNostrCurationSetShareableId = '30267:$kFranzapPubkey:nostr'; + +/// Anonymous private key for error reporting and anonymous operations +/// This key is used when the user is not signed in +const kAnonymousPrivateKey = + 'c86eda2daae768374526bc54903f388d9a866c00740ec8db418d7ef2dca77b5b'; + +/// Identifier for storing user bookmarks +const kAppBookmarksIdentifier = 'zapstore-bookmarks'; + diff --git a/lib/main.dart b/lib/main.dart index b692811..976172d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,97 +1,298 @@ 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:flutter_phoenix/flutter_phoenix.dart'; +import 'package:zapstore/router.dart'; +import 'package:zapstore/services/error_reporting_service.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/services/package_manager/android_package_manager.dart'; +import 'package:zapstore/services/package_manager/dummy_package_manager.dart'; +import 'package:zapstore/utils/extensions.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: 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) + 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'; + final theme = ref.watch(themeProvider); + + // Watch initialization state for error overlay display + final initState = ref.watch(appInitializationProvider); + + // Keep categorizedAppsProvider alive for badge count updates + // Using listen instead of watch since we don't use the value directly + ref.listen(categorizedAppsProvider, (_, __) {}); + + // 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.ensureConnected(); + }); + + // Listen to connectivity changes + subscription = connectivity.onConnectivityChanged.listen((results) { + notifier.ensureConnected(); + }); + + return () => subscription?.cancel(); + }, []); + + // Always show the main app UI, even during initialization return MaterialApp.router( - builder: materialErrorBuilder, - routerConfig: appRouter, - debugShowCheckedModeBanner: false, + title: title, theme: theme, + routerConfig: ref.watch(routerProvider), + debugShowCheckedModeBanner: false, + 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.3); + 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: [ + Icon( + Icons.rocket_launch, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + 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(); + + // Initialize storage first + await ref.read( + initializationProvider( + StorageConfiguration( + databasePath: path.join(dir.path, 'zapstore.db'), + defaultRelays: { + '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'}, + }, + ), + ).future, + ); + + // Initialize package manager + final packageManager = ref.read(packageManagerProvider.notifier); + await packageManager.syncInstalledPackages(); + + 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 +/// TODO: Fix ref type +Future onSignInSuccess(dynamic ref) async { + final pubkey = ref.read(Signer.activePubkeyProvider); + if (pubkey == null) return; + + final storage = + ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier; + + await storage.query( + RequestFilter(authors: {pubkey}).toRequest(), + source: const RemoteSource( + relays: 'bootstrap', + background: false, + stream: false, + ), + ); +} + +/// Observes app lifecycle events and re-syncs package state when app regains focus +class _AppLifecycleObserver with WidgetsBindingObserver { + _AppLifecycleObserver(this._ref); + + final WidgetRef _ref; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + // App regained focus - re-sync installed packages from Android system + // This will detect any apps installed/uninstalled outside of Zapstore + unawaited( + _ref.read(packageManagerProvider.notifier).syncInstalledPackages(), + ); + // Note: UpdateNotifier automatically triggers checkForUpdates() when + // packageManagerProvider state changes, so we don't need to call it explicitly + + // Force immediate health check on storage/relay connections + // This detects stale connections after system sleep and triggers reconnection + + final notifier = + _ref.read(storageNotifierProvider.notifier) + as PurplebaseStorageNotifier; + notifier.ensureConnected(); + } + } } 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..4f1b77c --- /dev/null +++ b/lib/router.dart @@ -0,0 +1,133 @@ +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/screens/main_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/updates_screen.dart'; +import 'package:zapstore/screens/profile_screen.dart'; +import 'package:zapstore/screens/app_from_naddr_screen.dart'; + +/// Fallback screen shown when app data is not available +class _AppNotFoundScreen extends StatelessWidget { + const _AppNotFoundScreen(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('App Not Found')), + body: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 64), + SizedBox(height: 16), + Text('App data not available'), + SizedBox(height: 8), + Text('Please try navigating from the app list'), + ], + ), + ), + ); + } +} + +/// Helper to build app detail route with fallback +GoRoute _appDetailRoute() { + return GoRoute( + path: 'app/:id', + builder: (context, state) { + final app = state.extra as App?; + if (app != null) { + return AppDetailScreen(app: app); + } + return const _AppNotFoundScreen(); + }, + ); +} + +/// Helper to build developer route +GoRoute _developerRoute() { + return GoRoute( + path: 'developer/:pubkey', + builder: (context, state) { + final pubkey = state.pathParameters['pubkey']!; + return DeveloperScreen(pubkey: pubkey); + }, + ); +} + +final routerProvider = Provider((ref) { + return GoRouter( + initialLocation: '/search', + routes: [ + // Top-level deep link route: /apps/:naddr + // Redirect to profile branch to show with nav bar + GoRoute( + path: '/apps/:naddr', + redirect: (context, state) { + final naddr = state.pathParameters['naddr']; + if (naddr == null || naddr.isEmpty) { + return '/search'; + } + return '/profile/apps/$naddr'; + }, + ), + // 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(), + _developerRoute(), + ], + ), + ], + ), + // Updates tab branch with nested detail routes + StatefulShellBranch( + routes: [ + GoRoute( + path: '/updates', + builder: (context, state) => const UpdatesScreen(), + routes: [ + _appDetailRoute(), + _developerRoute(), + ], + ), + ], + ), + // Profile tab branch with nested detail routes + StatefulShellBranch( + routes: [ + GoRoute( + path: '/profile', + builder: (context, state) => const ProfileScreen(), + routes: [ + _appDetailRoute(), + GoRoute( + path: 'apps/:naddr', + builder: (context, state) { + final naddr = state.pathParameters['naddr']!; + return AppFromNaddrScreen(naddr: naddr); + }, + ), + _developerRoute(), + ], + ), + ], + ), + ], + ), + ], + ); +}); diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index c4c3ed0..a5818ad 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -1,356 +1,256 @@ -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 '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: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:models/models.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:zapstore/utils/debug_utils.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/services/profile_service.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'; +import 'package:zapstore/widgets/version_pill_widget.dart'; class AppDetailScreen extends HookConsumerWidget { - final App model; - AppDetailScreen({ - required this.model, - super.key, - }); + const AppDetailScreen({super.key, required this.app}); + + final App app; @override Widget build(BuildContext context, WidgetRef ref) { - final scrollController = ScrollController(); + // Check if debug mode should be enabled + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + final showDebugSections = isDebugMode(signedInPubkey); - 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}); - })); + // Watch the app for real-time updates using model provider + final appState = ref.watch( + model( + app, + and: (a) => { + a.latestRelease, + if (a.latestRelease.value != null) + a.latestRelease.value!.latestMetadata, + }, + source: LocalAndRemoteSource(relays: 'AppCatalog', stream: false), + ), + ); - final state = ref.apps.watchOne(model.id!, - alsoWatch: (_) => { - _.localApp, - _.releases, - _.releases.artifacts, - _.signer, - }); + // Query author profile from social relays + final authorAsync = ref.watch(profileProvider(app.pubkey)); + final author = authorAsync.value; - final app = state.model ?? model; + // Use loaded version from state + final currentApp = appState.models.firstOrNull ?? app; + final latestRelease = currentApp.latestRelease.value; + final latestMetadata = currentApp.latestFileMetadata; - final curatedBy = ref.appCurationSets - .findAllLocal() - .where((s) => s.appIds.contains(app.identifier)) - .map((s) => s.signer.value) - .nonNulls - .toSet(); - - // 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.', + // Show skeleton while loading essential relationships + if (latestRelease == null || latestMetadata == null) { + return Scaffold( + body: SafeArea( + child: Stack( + children: [ + RefreshIndicator( + onRefresh: () => [app].loadMetadata(), + child: SingleChildScrollView( + padding: const EdgeInsets.only(top: 16, bottom: 80), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: AppDetailSkeleton(), + ), + ), + ), + InstallButton(app: currentApp, release: latestRelease), + ], ), ), ); } - 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), + return Scaffold( + body: SafeArea( + child: Stack( + children: [ + // Main scrollable content + RefreshIndicator( + onRefresh: () => [app].loadMetadata(), + child: SingleChildScrollView( + padding: const EdgeInsets.only( + top: 16, + bottom: 80, + ), // Horizontal padding applied per-section to allow screenshots to be full-bleed + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // App header with icon, name, version, and author (always show app info) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: AppHeader(app: currentApp), + ), + + // Published by / Released at section + if (app.isRelaySigned) Padding( - padding: const EdgeInsets.only(right: 14), - child: SignerContainer(app: app), + 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/developer/${author.pubkey}', + ); + }, + ) + : const AuthorSkeleton(), ), - if (app.latestMetadata != null && - app.signer.isPresent && - app.isSelfSigned) - Column( + + // Screenshots gallery + if (currentApp.images.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ScreenshotsGallery(app: currentApp), + ), + + // App description (always available from app) + 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, + ), + ), + ), + ), + + // Social action buttons (Zap + Share + Save) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + SocialActionsRow(app: currentApp, author: author), + const SizedBox(height: 16), + ], + ), + ), + + if (!currentApp.isInstalled || currentApp.hasUpdate) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: 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)), - ), + const SizedBox(height: 12), + + // Latest release title + Text( + 'Latest release', + style: context.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + + const SizedBox(height: 8), + + // Latest version info + Row( + children: [ + VersionPillWidget( + app: currentApp, + forceVersion: latestMetadata.version, + ), + const SizedBox(width: 12), + Text( + formatDate(latestMetadata.createdAt), + style: context.textTheme.bodyMedium?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), ), - ], - ), - ), - 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) - ], - ), - ), - ), - ], - ), - ), + ], + ), + + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: ReleaseNotes(release: latestRelease), + ), ], ), ), - Divider(height: 60), - Text( - 'Latest release'.toUpperCase(), - style: TextStyle( - fontSize: 16, - letterSpacing: 3, - fontWeight: FontWeight.w300, - ), + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: AppInfoTable( + app: currentApp, + fileMetadata: latestMetadata, ), - Gap(10), - if (app.releases.isEmpty) Text('No available releases'), - if (app.releases.isNotEmpty) - ReleaseCard( - release: - app.releases.toList().sortedByLatest.first), - ], - ), + ), + + CommentsSection(fileMetadata: latestMetadata), + + // Debug section - only visible for specific pubkey + if (showDebugSections) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: DebugVersionsSection(app: currentApp), + ), + + const SizedBox(height: 100), // Space for install button + ], ), - ], + ), ), - ), - SizedBox( - height: 50, - child: Center( - child: InstallButton(app: app), - ), - ), - ], + + // Sticky install/uninstall row (show even if release is loading) + InstallButton(app: currentApp, release: latestRelease), + ], + ), ), ); } diff --git a/lib/screens/app_from_naddr_screen.dart b/lib/screens/app_from_naddr_screen.dart new file mode 100644 index 0000000..69e23b7 --- /dev/null +++ b/lib/screens/app_from_naddr_screen.dart @@ -0,0 +1,86 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/widgets/app_detail_widgets.dart'; + +import 'package:zapstore/screens/app_detail_screen.dart'; + +class AppFromNaddrScreen extends ConsumerWidget { + final String naddr; + + const AppFromNaddrScreen({super.key, required this.naddr}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Decode naddr + AddressData? address; + try { + final decoded = Utils.decodeShareableIdentifier(naddr); + if (decoded is AddressData) { + address = decoded; + } + } catch (_) { + // ignore - handled below + } + + if (address == null || address.identifier.isEmpty) { + return _ErrorScaffold(message: 'Invalid link. Not a valid naddr.'); + } + + // Query the App by its identifier (d-tag) + final appsState = ref.watch( + query( + tags: { + '#d': {address.identifier}, + }, + limit: 1, + source: LocalAndRemoteSource(stream: false, background: true), + subscriptionPrefix: 'naddr-app', + ), + ); + + final app = appsState.models.firstOrNull; + + if (app != null) { + return AppDetailScreen(app: app); + } + + // Handle error state + switch (appsState) { + case StorageError(:final exception): + return _ErrorScaffold(message: exception.toString()); + default: + break; + } + + // Loading state + return const Scaffold( + body: SafeArea( + child: Padding(padding: EdgeInsets.all(16), child: AppDetailSkeleton()), + ), + ); + } +} + +class _ErrorScaffold extends StatelessWidget { + final String message; + const _ErrorScaffold({required this.message}); + + @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), + Text(message, textAlign: TextAlign.center), + ], + ), + ), + ); + } +} diff --git a/lib/screens/developer_screen.dart b/lib/screens/developer_screen.dart index 2939aa8..d2b34d0 100644 --- a/lib/screens/developer_screen.dart +++ b/lib/screens/developer_screen.dart @@ -1,87 +1,493 @@ -import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.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/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'; +import 'package:models/models.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:zapstore/utils/extensions.dart'; +import '../theme.dart'; +import '../widgets/common/note_parser.dart'; +import '../widgets/app_card.dart'; +import '../widgets/profile_header.dart'; +import '../widgets/zap_widgets.dart'; +import '../services/notification_service.dart'; +import '../services/profile_service.dart'; + +/// Developer profile and apps screen class DeveloperScreen extends HookConsumerWidget { - final User model; + const DeveloperScreen({super.key, required this.pubkey}); - DeveloperScreen({ - required this.model, - super.key, - }); + final String pubkey; @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 scrollController = useScrollController(); - final state = ref.users.watchOne(model.id!, alsoWatch: (_) => {_.apps}); + // Load developer profile + final profileAsync = ref.watch(profileProvider(pubkey)); + final profile = profileAsync.value; - 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), + return Scaffold( + body: SafeArea( + child: Column( + children: [ + // Developer header - always show, with skeleton if loading + _DeveloperHeader( + profile: profile, + isLoading: profileAsync.isLoading, + hasError: profileAsync.hasError, + ), + + // Zaps list for all developer's apps + _DeveloperZapsList(pubkey: pubkey), + + // Tabs for apps and info + Expanded( + child: DefaultTabController( + length: 2, + child: Column( + children: [ + const TabBar( + tabs: [ + Tab(text: 'Apps'), + Tab(text: 'About'), + ], + ), + Expanded( + child: TabBarView( + children: [ + _DeveloperAppsTab( + pubkey: pubkey, + scrollController: scrollController, + ), + _DeveloperAboutTab( + profile: profile, + isLoading: profileAsync.isLoading, + hasError: profileAsync.hasError, + ), + ], + ), + ), + ], ), ), - ], - ), - 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), - ], - ), - ], + ], + ), ), ); } } + +class _DeveloperHeader extends HookConsumerWidget { + const _DeveloperHeader({ + required this.profile, + this.isLoading = false, + this.hasError = false, + }); + + final Profile? profile; + final bool isLoading; + final bool hasError; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Show skeleton when loading or error + if (isLoading || hasError || profile == null) { + return ProfileHeader(isLoading: true, radius: 40); + } + + return ProfileHeader(profile: profile, radius: 40); + } +} + +class _DeveloperAppsTab extends HookConsumerWidget { + const _DeveloperAppsTab({ + required this.pubkey, + required this.scrollController, + }); + + final String pubkey; + final ScrollController scrollController; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Query apps by this specific developer + final appsState = ref.watch( + query( + authors: {pubkey}, // Filter by developer pubkey + limit: 100, + tags: { + '#f': {'android-arm64-v8a'}, + }, + and: (app) => {app.latestRelease}, + subscriptionPrefix: 'developer-apps', + ), + ); + + // Query author profile from 'social' relay group + final authorAsync = ref.watch(profileProvider(pubkey)); + final author = authorAsync.value; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Apps List + Expanded( + child: switch (appsState) { + StorageLoading() => ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: 5, + itemBuilder: (context, index) => AppCard(isLoading: true), + ), + StorageError() => _buildErrorState( + context, + appsState.toString(), + ), + StorageData(:final models) when models.isEmpty => _buildEmptyState(context), + StorageData(:final models) => _buildAppsList( + context, + models, + author, + ), + }, + ), + ], + ); + } + + Widget _buildErrorState(BuildContext context, String error) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + 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, + ), + ], + ), + ), + ); + } + + Widget _buildEmptyState(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.apps_outlined, size: 48, color: Colors.grey[400]), + const SizedBox(height: 16), + Text('No apps published', style: context.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'This developer hasn\'t published any apps yet', + style: context.textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildAppsList( + BuildContext context, + List apps, + Profile? author, + ) { + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: apps.length, + itemBuilder: (context, index) { + final app = apps[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: AppCard(app: app, author: author, showSignedBy: false), + ); + }, + ); + } +} + +class _DeveloperZapsList extends HookConsumerWidget { + const _DeveloperZapsList({required this.pubkey}); + + final String pubkey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Query all apps by this developer with their latest metadata + final appsState = ref.watch( + query( + authors: {pubkey}, + tags: { + '#f': {'android-arm64-v8a'}, + }, + limit: 100, + and: (app) => {app.latestRelease}, + source: LocalAndRemoteSource(stream: false, background: true), + subscriptionPrefix: 'developer-all-apps', + ), + ); + + // Get all apps + final apps = appsState is StorageData ? appsState.models : []; + + if (apps.isEmpty) { + return const SizedBox.shrink(); + } + + // Create combined tag maps for: + // 1. App addressable IDs (for zaps on apps) + // 2. FileMetadata event IDs (for zaps on metadata) + final allAppTags = >{}; + final metadataIds = {}; + + for (final app in apps) { + // Add app addressable ID tags + final appTags = app.event.addressableIdTagMap; + for (final entry in appTags.entries) { + allAppTags[entry.key] = {...?allAppTags[entry.key], ...entry.value}; + } + + // Add metadata event IDs + final metadata = app.latestFileMetadata; + if (metadata != null) { + metadataIds.add(metadata.id); + } + } + + // Query zaps on app addressable IDs + final zapsOnAppsState = ref.watch( + query( + tags: allAppTags, + source: LocalAndRemoteSource(relays: 'social'), + and: (zap) => {zap.author, zap.zapRequest}, + andSource: LocalAndRemoteSource(relays: 'social', stream: false), + subscriptionPrefix: 'developer-zaps-apps', + ), + ); + + // Query zaps on file metadata + final zapsOnMetadataState = metadataIds.isNotEmpty + ? ref.watch( + query( + tags: {'#e': metadataIds}, + source: LocalAndRemoteSource(relays: 'social'), + and: (zap) => {zap.author, zap.zapRequest}, + andSource: LocalAndRemoteSource(relays: 'social', stream: false), + subscriptionPrefix: 'developer-zaps-metadata', + ), + ) + : null; + + // Combine all zaps + final allZaps = { + if (zapsOnAppsState is StorageData) ...zapsOnAppsState.models, + if (zapsOnMetadataState != null && zapsOnMetadataState is StorageData) + ...zapsOnMetadataState.models, + }; + + if (allZaps.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: ZappersHorizontalList(zaps: allZaps.toList()), + ); + } +} + +class _DeveloperAboutTab extends StatelessWidget { + const _DeveloperAboutTab({ + required this.profile, + this.isLoading = false, + this.hasError = false, + }); + + final Profile? profile; + final bool isLoading; + final bool hasError; + + @override + Widget build(BuildContext context) { + // Show skeleton when loading or profile is null + if (isLoading || hasError || profile == null) { + return SkeletonizerConfig( + data: AppColors.getSkeletonizerConfig(Theme.of(context).brightness), + child: Skeletonizer( + enabled: true, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Bio section skeleton + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 20, + width: 100, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 12), + Container( + height: 16, + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Contact section skeleton + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 20, + width: 100, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 12), + const ListTile( + leading: Icon(Icons.verified), + title: Text('Loading...'), + subtitle: Text('---'), + contentPadding: EdgeInsets.zero, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // Bio section + if (profile!.about != null && profile!.about!.isNotEmpty) ...[ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: 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, + ], + onProfileTap: (pubkey) { + final segments = GoRouterState.of(context).uri.pathSegments; + final first = segments.isNotEmpty + ? segments.first + : 'search'; + context.push('/$first/developer/$pubkey'); + }, + onHashtagTap: (hashtag) => context.push('/search?q=$hashtag'), + ), + ), + ), + ), + const SizedBox(height: 16), + ], + + // Contact info + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // npub + ListTile( + leading: const Icon(Icons.share), + title: const Text('npub'), + subtitle: Text( + '${Utils.encodeShareableFromString(profile!.pubkey, type: 'npub').substring(0, 16)}...', + ), + trailing: IconButton( + icon: const Icon(Icons.copy), + onPressed: () { + final npub = Utils.encodeShareableFromString( + profile!.pubkey, + type: 'npub', + ); + Clipboard.setData(ClipboardData(text: npub)); + context.showInfo('npub copied to clipboard'); + }, + ), + contentPadding: EdgeInsets.zero, + ), + + // NIP-05 identifier + if (profile!.nip05 != null) ...[ + ListTile( + leading: const Icon(Icons.verified), + title: const Text('NIP-05 Verified'), + subtitle: Text(profile!.nip05!), + contentPadding: EdgeInsets.zero, + onTap: () { + Clipboard.setData(ClipboardData(text: profile!.nip05!)); + context.showInfo('NIP-05 copied to clipboard'); + }, + ), + ], + + // Lightning address + if (profile!.lud16?.trim().isNotEmpty ?? false) ...[ + ListTile( + leading: const Icon(Icons.bolt), + title: const Text('Lightning Address'), + subtitle: Text(profile!.lud16!), + contentPadding: EdgeInsets.zero, + onTap: () { + Clipboard.setData(ClipboardData(text: profile!.lud16!)); + context.showInfo('Lightning address copied to clipboard'); + }, + ), + ], + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart new file mode 100644 index 0000000..3742284 --- /dev/null +++ b/lib/screens/main_scaffold.dart @@ -0,0 +1,357 @@ +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/services/updates_service.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: !router.canPop(), // Allow app to close only when router can't pop + onPopInvokedWithResult: (didPop, result) { + if (!didPop && router.canPop()) { + // If we couldn't pop but router has history, pop from router + router.pop(); + } + }, + child: 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, + ); + } + }, + ), + ); + } +} + +/// 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 updateCount = ref.watch(updateCountProvider); + + 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 (updateCount > 0) + Positioned( + right: -8, + top: -8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(10), + ), + constraints: const BoxConstraints( + minWidth: 18, + minHeight: 18, + ), + child: Text( + updateCount > 99 ? '99+' : updateCount.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ), + 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 updateCount = ref.watch(updateCountProvider); + + 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 (updateCount > 0) + Positioned( + right: -8, + top: -8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(10), + ), + constraints: const BoxConstraints( + minWidth: 18, + minHeight: 18, + ), + child: Text( + updateCount > 99 + ? '99+' + : updateCount.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + 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..cb2746a --- /dev/null +++ b/lib/screens/profile_screen.dart @@ -0,0 +1,1459 @@ +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:flutter_phoenix/flutter_phoenix.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:purplebase/purplebase.dart'; +import 'package:zapstore/main.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/theme.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/services/bookmarks_service.dart'; +import 'package:zapstore/services/updates_service.dart'; +import 'package:zapstore/widgets/app_card.dart'; +import 'package:zapstore/widgets/common/note_parser.dart'; +import 'package:zapstore/widgets/latest_releases_container.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) { + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + final isSignedIn = signedInPubkey != null; + + return Scaffold( + body: ListView( + children: [ + // Authentication Section + const _AuthenticationSection(), + + const SizedBox(height: 24), + + // Debug Messages Section + const _DebugMessagesSection(), + const SizedBox(height: 24), + + // Bookmarks Section + const _BookmarksSection(), + + const SizedBox(height: 24), + + // Settings Heading + if (isSignedIn) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text('Settings', style: context.textTheme.headlineSmall), + ), + + if (isSignedIn) ...[ + const SizedBox(height: 16), + + // Lightning Wallet Section + const NWCConnectionCard(), + ], + + const SizedBox(height: 24), + + // Advanced Heading + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text('Advanced', style: context.textTheme.headlineSmall), + ), + + const SizedBox(height: 16), + + // Relay Management Section - disabled due to API changes + // const _RelayManagementSection(), + const SizedBox(height: 16), + + // Data Management Section + const _DataManagementSection(), + + const SizedBox(height: 24), + + // About Section + _AboutSection(), + const SizedBox(height: 24), + ], + ), + ); + } +} + +class _AuthenticationSection extends ConsumerWidget { + const _AuthenticationSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pubkey = ref.watch(Signer.activePubkeyProvider); + final profile = ref.watch( + Signer.activeProfileProvider( + LocalAndRemoteSource(relays: 'vertex', stream: false), + ), + ); + final isSignedIn = pubkey != null; + + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isSignedIn) ...[ + // Signed In - Show Profile + _buildSignedInProfile(context, ref, pubkey, profile), + ] else ...[ + // Not Signed In - Show Sign In Options + _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.bodySmall, + onNostrEntity: (entity) => NostrEntityWidget( + entity: entity, + colorPair: [ + Theme.of(context).colorScheme.primary, + Theme.of(context).colorScheme.secondary, + ], + // No tap callbacks - make it non-interactive + ), + ), + ], + ), + ), + ], + ), + ], + ); + } + + Widget _buildSignInOptions(BuildContext context, WidgetRef ref) { + final packageManager = ref.watch(packageManagerProvider); + const amberPackageId = 'com.greenart7c3.nostrsigner'; + final isAmberInstalled = packageManager.any( + (p) => p.appId == amberPackageId, + ); + + 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(); + if (context.mounted) { + context.showInfo('Signed out successfully'); + } + } catch (e) { + if (context.mounted) { + context.showError('Sign out failed: $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) { + // Navigate to Amber app page (within profile tab to keep bottom nav) + context.push( + '/profile/apps/naddr1qqdkxmmd9enhyet9deshyaphvvejumn0wd68yumfvahx2uszyp6hjpmdntls5n8aa7n7ypzlyjrv0ewch33ml3452wtjx0smhl93jqcyqqq8uzcgpp6ky', + ); + } 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(), + ); + } +} + +// Bookmarks Section +class _BookmarksSection extends HookConsumerWidget { + const _BookmarksSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + + if (signedInPubkey == null) { + return const SizedBox.shrink(); + } + + // Watch bookmarks from centralized provider (handles decryption) + final bookmarksAsync = ref.watch(bookmarksProvider); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: bookmarksAsync.when( + loading: () => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.bookmark, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Bookmarked Apps', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 24), + Center( + child: CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 24), + ], + ), + error: (_, __) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.bookmark, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Bookmarked Apps', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + Center( + child: Text( + 'Error loading bookmarks', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ], + ), + data: (bookmarkedAddressableIds) { + // Extract just the identifier part from addressable IDs (kind:pubkey:identifier) + final bookmarkedIdentifiers = bookmarkedAddressableIds + .map((id) { + final parts = id.split(':'); + return parts.length >= 3 ? parts[2] : null; + }) + .where((id) => id != null) + .cast() + .toSet(); + + // Query for bookmarked apps (local-only since all apps are managed through Zapstore) + final bookmarkedAppsState = bookmarkedIdentifiers.isNotEmpty + ? ref.watch( + query( + tags: {'#d': bookmarkedIdentifiers}, + and: (app) => {app.latestRelease}, + source: const LocalSource(), + subscriptionPrefix: 'bookmark-apps', + ), + ) + : StorageData(const []); + + // Get bookmarked apps and sort alphabetically + final savedApps = bookmarkedAppsState.models.toList() + ..sort( + (a, b) => (a.name ?? a.identifier).toLowerCase().compareTo( + (b.name ?? b.identifier).toLowerCase(), + ), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.bookmark, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Bookmarked Apps', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + savedApps.length > 99 + ? '99+' + : savedApps.length.toString(), + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onPrimaryContainer, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Your private bookmarked apps', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + if (savedApps.isEmpty) ...[ + const SizedBox(height: 24), + Center( + child: Text( + 'No bookmarked apps yet', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), + fontStyle: FontStyle.italic, + ), + ), + ), + const SizedBox(height: 24), + ] else ...[ + const SizedBox(height: 16), + ...savedApps.map( + (app) => AppCard(app: app, showUpdateArrow: false), + ), + ], + ], + ); + }, + ), + ), + ); + } +} + +/// 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 isExpanded = useState(true); + final selectedTab = useState(0); // 0=Subscriptions, 1=Logs + final now = useState(DateTime.now()); + final expandedSubs = useState>({}); + + 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 []; + + // Aggregate relay status across all subscriptions + final allRelayUrls = []; + final relayDebugEntries = + < + ({ + String url, + String shortUrl, + RelaySubPhase phase, + String? lastError, + String subscriptionId, + }) + >[]; + final connectedRelayUrls = {}; + for (final subEntry in subscriptions.entries) { + final subscriptionId = subEntry.key; + final sub = subEntry.value; + for (final entry in sub.relays.entries) { + final url = entry.key; + final relay = entry.value; + final phase = relay.phase; + final shortUrl = url + .replaceAll('wss://', '') + .replaceAll('ws://', '') + .replaceAll(RegExp(r'/$'), ''); + allRelayUrls.add(url); + relayDebugEntries.add(( + url: url, + shortUrl: shortUrl, + phase: phase, + lastError: relay.lastError, + subscriptionId: subscriptionId, + )); + if (phase == RelaySubPhase.streaming || + phase == RelaySubPhase.loading) { + connectedRelayUrls.add(url); + } + } + } + + 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 + InkWell( + onTap: () => isExpanded.value = !isExpanded.value, + child: 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?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + Icon( + isExpanded.value ? Icons.expand_less : Icons.expand_more, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + + if (isExpanded.value) ...[ + 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 + : Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + entry.key, + style: const TextStyle( + fontSize: 11, + fontFamily: 'monospace', + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + size: 16, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ], + ), + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + _StatusChip( + icon: Icons.cloud_done, + label: '$activeRelays/$totalRelays', + color: allEose ? Colors.green : Colors.blue, + ), + if (allEose) + _StatusChip( + icon: Icons.check_circle, + label: 'EOSE', + color: Colors.green, + ), + if (sub.stream) + _StatusChip( + icon: Icons.wifi_tethering, + label: 'Streaming', + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + 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 $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('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, Subscription 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 copied'); + }, + ), + ], + ), + const SizedBox(height: 8), + SelectableText( + req, + style: const TextStyle( + fontSize: 11, + height: 1.4, + fontFamily: 'monospace', + ), + ), + ], + ), + ); + } + + String _formatReq(Subscription 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?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + ListTile( + leading: Icon( + Icons.delete_sweep, + color: Theme.of(context).colorScheme.error, + ), + title: Text( + 'Clear All Data', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w500, + ), + ), + subtitle: const Text( + 'Remove all cached data and restart the app. This action will sign you out.', + ), + trailing: Icon( + Icons.warning, + color: Theme.of(context).colorScheme.error, + size: 20, + ), + 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 All Data'), + ], + ), + content: const Text( + 'This will permanently delete all cached data including:\n\n' + '• Downloaded app information\n' + '• Profile data\n' + '• Settings and preferences\n' + '• Authentication tokens\n\n' + 'The app will restart after clearing data. This action cannot be undone.', + ), + 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 All Data'), + ), + ], + ), + ); + } + + Future _clearAllData(BuildContext context, WidgetRef ref) async { + // Capture the navigator before async operations + final navigator = Navigator.of(context, rootNavigator: true); + + 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('Clearing data...'), + ], + ), + ), + ); + + // Sign out if signed in (do this first to avoid any storage-related issues) + final signer = ref.read(amberSignerProvider); + await signer.signOut(); + + // CRITICAL: Invalidate all providers to close active storage subscriptions + // This prevents deadlock when clearing storage + ref.invalidate(latestReleasesProvider); + ref.invalidate(categorizedAppsProvider); + + // Clear local storage with safety checks + final storageNotifier = ref.read(storageNotifierProvider.notifier); + // Prefer obliterate to remove DB files entirely to avoid half-cleared schema + await storageNotifier.obliterate(); + + // Restart app - Phoenix.rebirth restarts the entire app so dialogs are automatically closed + // Use the original context parameter which remains valid + // ignore: use_build_context_synchronously + Phoenix.rebirth(context); + } catch (e) { + // Close loading dialog and show error - check mounted here since we're not restarting + try { + navigator.pop(); + } catch (_) { + // Dialog might already be closed, ignore + } + + if (context.mounted) { + context.showError('Failed to clear data: ${e.toString()}'); + } + } + } +} + +class _AboutSection extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final manager = ref.watch(packageManagerProvider); + final zsPackage = manager.firstWhereOrNull( + (i) => i.appId == kZapstoreAppIdentifier, + ); + + if (zsPackage == null) { + final isLoading = manager.isEmpty; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'About', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + 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?.copyWith(fontWeight: FontWeight.bold), + ), + 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..11bf8a1 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -1,150 +1,289 @@ -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_pack_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(''); + // Check app initialization state + final initState = ref.watch(appInitializationProvider); + + // Get platform from package manager + final platform = ref.read(packageManagerProvider.notifier).platform; + + // Use reactive query instead of manual search + // Query system handles relay connectivity internally + final searchResultsState = searchQuery.value.trim().isNotEmpty + ? ref.watch( + query( + search: searchQuery.value.trim(), + limit: 20, + tags: { + '#f': {platform}, + }, + and: (app) => { + app.latestRelease, + // Load nested FileMetadata from latestRelease (same relay group) + if (app.latestRelease.value != null) + app.latestRelease.value!.latestMetadata, + }, + // 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, + background: false, + ), + subscriptionPrefix: 'search-results', + ), + ) + : null; + + // Function to perform search + final performSearch = useCallback((String query) { + if (query.trim().isEmpty) { + searchQuery.value = ''; + return; + } + searchQuery.value = query.trim(); + }, []); + + // 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]); + + // Extract search results and states + final searchResults = searchResultsState is StorageData + ? (searchResultsState as StorageData).models + : []; + final isSearching = searchResultsState is StorageLoading; + final searchError = searchResultsState is StorageError + ? (searchResultsState as StorageError).exception.toString() + : null; + + // Show search results (query system handles relay connectivity internally) + final effectiveResults = searchResults; + // Show loading if: query exists and either loading or query hasn't started yet + final effectiveIsSearching = + searchQuery.value.trim().isNotEmpty && + (isSearching || searchResultsState == null); + + // 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, + ), + 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), + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.3), + width: 1, + ), + ), + ), + ); + }, + ), + ), + // Scrollable content + Expanded( + child: SingleChildScrollView( + controller: scrollController, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Search Results Section + if (searchQuery.value.isNotEmpty) + _buildSearchResults( + context, + searchQuery.value, + effectiveResults, + effectiveIsSearching, + searchError, + ), + + // App Curation Container with professional spacing + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: AppPackContainer( + showSkeleton: !(initState.hasValue || initState.hasError), + ), + ), + + // Latest Releases Container + LatestReleasesContainer( + scrollController: scrollController, + showSkeleton: !(initState.hasValue || initState.hasError), + ), + + const SizedBox(height: 16), // Bottom padding + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildSearchResults( + BuildContext context, + String query, + List results, + bool isSearching, + String? error, + ) { 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), - ), - ), - leading: Padding( - padding: const EdgeInsets.fromLTRB(10, 8, 6, 6), - child: Icon( - Icons.search, - color: Colors.blueGrey, - size: 20, - ), - ), - trailing: [ - if (searchResultState.isLoading) - Padding( - padding: const EdgeInsets.only(right: 10), - child: SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 3), - ), - ), - 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; + if (isSearching) + Column( + children: List.generate(3, (index) => AppCard(isLoading: true)), + ) + else if (error != null) + _buildSearchErrorState(context, error) + else if (results.isEmpty) + _buildSearchEmptyState(context, query) + else + _buildSearchResultsList(context, results), - 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, - ); - }, - ), + const SizedBox(height: 24), + ], + ); + } + + Widget _buildSearchErrorState(BuildContext context, String error) { + return 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, ), ], ), - 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), - ], + ), + ); + } + + Widget _buildSearchEmptyState(BuildContext context, String query) { + // Show empty state when no results found + return 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 "$query"', + style: context.textTheme.bodySmall, + textAlign: TextAlign.center, ), - ), + ], ), + ), + ); + } + + Widget _buildSearchResultsList(BuildContext context, List results) { + return Column( + children: [ + // App Cards - authors loaded via profileProvider in AppCard + ...results.map((app) => _SearchResultCard(app: app)), ], ); } } -final searchQueryProvider = StateProvider((ref) => null); +/// Helper widget for search result cards with version information +class _SearchResultCard extends ConsumerWidget { + const _SearchResultCard({required this.app}); -final searchResultProvider = - AsyncNotifierProvider?>( - SearchResultNotifier.new); + final App app; -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) { + // Check if app has updates + // Author loaded via profileProvider in AppCard + return AppCard(app: app, showUpdateArrow: app.hasUpdate); } } 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..49a6bd5 100644 --- a/lib/screens/updates_screen.dart +++ b/lib/screens/updates_screen.dart @@ -1,121 +1,392 @@ -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/download_service.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/widgets/app_card.dart'; -import 'package:zapstore/widgets/spinning_logo.dart'; +import 'package:zapstore/theme.dart'; +import 'package:background_downloader/background_downloader.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); + // Watch categorized apps from StateNotifier + final categorized = ref.watch(categorizedAppsProvider); + final automaticUpdates = categorized.automaticUpdates; + final manualUpdates = categorized.manualUpdates; + final upToDateApps = categorized.upToDateApps; - final (updatedApps, updatableApps, unupdatableApps) = - state.value ?? ([], [], []); + // Watch download service for installing apps + final downloads = ref.watch(downloadServiceProvider); - return SingleChildScrollView( - physics: AlwaysScrollableScrollPhysics(), - child: Column( - key: UniqueKey(), - crossAxisAlignment: CrossAxisAlignment.start, - 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, + // Get installing app IDs from download service + final installingAppIds = downloads.entries + .where( + (entry) => + entry.value.isInstalling || + entry.value.status == TaskStatus.running || + entry.value.status == TaskStatus.enqueued || + entry.value.status == TaskStatus.paused || + entry.value.status == TaskStatus.waitingToRetry, + ) + .map((entry) => entry.key) + .toSet(); + + // Query for installing apps that might not be in the updates list + final installingAppsState = installingAppIds.isNotEmpty + ? ref.watch( + query( + tags: {'#d': installingAppIds}, + and: (app) => {app.latestRelease, app.author}, + andSource: const LocalAndRemoteSource(relays: 'social'), + subscriptionPrefix: 'installing-apps', + ), + ) + : StorageData(const []); + + // Get installing apps from the query, excluding apps already in update sections + 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 Scaffold( + body: RefreshIndicator( + onRefresh: () => _handleRefresh(ref), + child: ListView( + children: [ + // Installing section + if (installingApps.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Icon( + Icons.downloading, + size: 20, + color: AppColors.darkActionPrimary, + ), + const SizedBox(width: 8), + Text( + 'Installing', + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.darkPillBackground, + borderRadius: BorderRadius.circular(10), + ), + constraints: const BoxConstraints( + minWidth: 18, + minHeight: 18, + ), + child: Text( + installingApps.length > 99 + ? '99+' + : installingApps.length.toString(), + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ...installingApps.map( + (app) => AppCard( + app: app, + showUpdateArrow: false, + showUpdateButton: true, + showZapEncouragement: true, + ), + ), + ], + + // Automatic updates section + if (automaticUpdates.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Icon( + Icons.system_update, + size: 20, + color: AppColors.darkActionPrimary, + ), + const SizedBox(width: 8), + Text( + 'Ready to Update', + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.darkPillBackground, + borderRadius: BorderRadius.circular(10), + ), + constraints: const BoxConstraints( + minWidth: 18, + minHeight: 18, + ), + child: Text( + automaticUpdates.length > 99 + ? '99+' + : automaticUpdates.length.toString(), + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + const Spacer(), + AsyncButtonBuilder( + onPressed: () async { + final downloadService = ref.read( + downloadServiceProvider.notifier, + ); + // Start downloads for all automatic updates + for (final app in automaticUpdates) { + final release = app.latestRelease.value; + if (release != null) { + try { + await downloadService.downloadApp(app, release); + } catch (e) { + // Failed to start download + } + } + } + }, + builder: (context, child, callback, buttonState) { + const pillBg = AppColors.darkPillBackground; + const pillText = Colors.white; + + return TextButton.icon( + onPressed: buttonState.maybeWhen( + loading: () => null, + orElse: () => callback, + ), + icon: buttonState.maybeWhen( + loading: () => SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + color: pillText, + ), + ), + orElse: () => + Icon(Icons.download, size: 14, color: pillText), + ), + label: 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(), + ), + ], + ), + ), + ...automaticUpdates.map( + (app) => AppCard( + app: app, + showUpdateArrow: true, + showUpdateButton: true, + showZapEncouragement: true, + ), + ), + ], + + // Manual updates section + if (manualUpdates.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Icon( + Icons.touch_app, + size: 20, + color: AppColors.darkActionPrimary, + ), + const SizedBox(width: 8), + Text( + 'Ready to Manually Update', + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.darkPillBackground, + borderRadius: BorderRadius.circular(10), + ), + constraints: const BoxConstraints( + minWidth: 18, + minHeight: 18, + ), + child: Text( + manualUpdates.length > 99 + ? '99+' + : manualUpdates.length.toString(), + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + 'These updates require confirming the Android system prompt', + style: context.textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), ), ), - 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, + ...manualUpdates.map( + (app) => AppCard( + app: app, + showUpdateArrow: true, + showUpdateButton: true, + showZapEncouragement: true, + ), ), - ), - Gap(10), - for (final app in updatedApps) AppCard(model: app), - ], + ], + + // Up to date section + if (upToDateApps.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Icon( + Icons.check_circle_outline, + size: 20, + color: AppColors.darkActionPrimary, + ), + const SizedBox(width: 8), + Text( + 'Up to Date', + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.darkPillBackground, + borderRadius: BorderRadius.circular(10), + ), + constraints: const BoxConstraints( + minWidth: 18, + minHeight: 18, + ), + child: Text( + upToDateApps.length > 99 + ? '99+' + : upToDateApps.length.toString(), + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + // Show informative message when no updates available + if (automaticUpdates.isEmpty && manualUpdates.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + 'All apps are up to date', + style: context.textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ), + ...upToDateApps.map( + (app) => AppCard( + app: app, + showUpdateArrow: false, // Up-to-date apps show single version + ), + ), + ], + ], + ), ), ); } -} -// Provider - -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(); - - 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); + /// Handle pull-to-refresh action + Future _handleRefresh(WidgetRef ref) async { + // Sync installed packages to detect uninstalled apps + await ref.read(packageManagerProvider.notifier).syncInstalledPackages(); } } - -extension on Iterable { - List sortByName() => toList().sorted((a, b) => - (a.name?.toLowerCase() ?? '').compareTo(b.name?.toLowerCase() ?? '')); -} - -final updatesProvider = - AsyncNotifierProvider, List, List)>( - UpdatesNotifier.new); diff --git a/lib/services/bookmarks_service.dart b/lib/services/bookmarks_service.dart new file mode 100644 index 0000000..4511f97 --- /dev/null +++ b/lib/services/bookmarks_service.dart @@ -0,0 +1,87 @@ +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 bookmark pack and provides decrypted bookmark 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 bookmark pack - stream enabled to auto-update when bookmarks change locally + // Initial fetch from remote, then watches local storage for changes + final packState = ref.watch( + query( + authors: {signedInPubkey}, + tags: { + '#d': {kAppBookmarksIdentifier}, + }, + source: const LocalAndRemoteSource( + relays: 'social', + stream: false, + background: true, // Don't block on relay response + ), + subscriptionPrefix: 'user-bookmarks', + ), + ); + + // Handle different storage states + final pack = switch (packState) { + StorageLoading() => null, + StorageError() => null, + StorageData(:final models) => models.firstOrNull, + }; + + if (pack == null || pack.content.isEmpty) { + return {}; + } + + try { + // Content is always encrypted after signing - must explicitly decrypt + final decryptedContent = await signer.nip44Decrypt( + pack.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 bookmarked +extension BookmarkChecker on WidgetRef { + /// Check if the given app is bookmarked + bool isAppBookmarked(App app) { + final bookmarksState = watch(bookmarksProvider); + final appAddressableId = + '${app.event.kind}:${app.pubkey}:${app.identifier}'; + + return bookmarksState.when( + data: (bookmarks) => bookmarks.contains(appAddressableId), + loading: () => false, + error: (_, __) => false, + ); + } + + /// Get all bookmarked app addressable IDs + Set getBookmarkedIds() { + final bookmarksState = watch(bookmarksProvider); + + return bookmarksState.when( + data: (bookmarks) => bookmarks, + loading: () => {}, + error: (_, __) => {}, + ); + } +} diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart new file mode 100644 index 0000000..0b645da --- /dev/null +++ b/lib/services/download_service.dart @@ -0,0 +1,719 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/utils/extensions.dart'; + +import '../services/package_manager/package_manager.dart'; + +/// Simplified Download Service leveraging background_downloader package +/// +/// This service acts as a thin wrapper around background_downloader, +/// providing integration with our app models and package manager. +/// +/// Key features: +/// - Platform-agnostic - works with any package manager implementation +/// - Uses FileMetadata.hash as filename for uniqueness and verification +/// - Automatic installation after download completion +/// - Leverages package's built-in retry, pause/resume, and progress tracking +/// - Minimal state management - relies on background_downloader's task tracking + +/// Minimal wrapper around Task with app-specific metadata +class DownloadInfo { + const DownloadInfo({ + required this.appId, + required this.task, + required this.fileMetadata, + this.status = TaskStatus.enqueued, + this.progress = 0.0, + this.isInstalling = false, + this.isReadyToInstall = false, + this.errorDetails, + }); + + final String appId; + final DownloadTask task; + final FileMetadata fileMetadata; // Store the actual metadata being downloaded + final TaskStatus status; + final double progress; + final bool isInstalling; + final bool isReadyToInstall; // Downloaded but installation failed/canceled + final String? errorDetails; // Store error details for display + + String get taskId => task.taskId; + String get fileName => task.filename; + + String get formattedProgress { + final percent = (progress * 100).round(); + return switch (status) { + TaskStatus.enqueued => 'Queued', + TaskStatus.running => '$percent%', + TaskStatus.complete => 'Completed', + TaskStatus.failed => 'Failed', + TaskStatus.canceled => 'Canceled', + TaskStatus.paused => 'Paused', + TaskStatus.waitingToRetry => 'Retrying...', + TaskStatus.notFound => 'Not found', + }; + } + + DownloadInfo copyWith({ + TaskStatus? status, + double? progress, + bool? isInstalling, + bool? isReadyToInstall, + String? errorDetails, + }) { + return DownloadInfo( + appId: appId, + task: task, + fileMetadata: fileMetadata, + status: status ?? this.status, + progress: progress ?? this.progress, + isInstalling: isInstalling ?? this.isInstalling, + isReadyToInstall: isReadyToInstall ?? this.isReadyToInstall, + errorDetails: errorDetails ?? this.errorDetails, + ); + } +} + +/// Simplified download service +class DownloadService extends StateNotifier> { + DownloadService(this.ref) : super({}) { + _initialize(); + } + + final Ref ref; + late final FileDownloader _downloader; + + // Installation queue to prevent concurrent installations + final List _installQueue = []; + bool _isInstallingFromQueue = false; + + Future _initialize() async { + _downloader = FileDownloader(); + + // Configure downloader with optimized settings + try { + // Configure timeouts - the package uses iOS URL session timeout on iOS + // and OkHttp timeouts on Android + await _downloader.configure( + globalConfig: [ + ( + Config.requestTimeout, + const Duration(seconds: 20), + ), // 20 second timeout + ( + Config.resourceTimeout, + const Duration(minutes: 30), + ), // 30 minute total timeout + ( + Config.checkAvailableSpace, + Config.never, + ), // Don't check space as requested + ], + androidConfig: [ + (Config.useCacheDir, false), // Use external storage for APKs + ], + ); + + // Note: maxConcurrentTasks is controlled per-platform: + // - iOS: Set FDMaximumConcurrentTasks in Info.plist + // - Android: The library automatically manages concurrency + // We'll manually track and limit to 3 downloads in our service + } catch (e) { + // Continue with defaults if configuration fails + } + + // Configure notifications + _downloader.configureNotificationForGroup( + FileDownloader.defaultGroup, + running: const TaskNotification( + 'Downloading {filename}', + 'File: {filename}', + ), + complete: const TaskNotification('Download complete', 'File: {filename}'), + error: const TaskNotification('Download failed', 'File: {filename}'), + paused: const TaskNotification('Download paused', 'File: {filename}'), + progressBar: true, + ); + + // Set up global callbacks + _downloader.registerCallbacks( + taskStatusCallback: _handleTaskUpdate, + taskProgressCallback: _handleTaskUpdate, + ); + + // Set up install result callback via the abstract interface + ref.read(packageManagerProvider.notifier).onInstallResult = + _handleInstallResult; + + // Clear any existing downloads on startup (no resume) + await _clearExistingDownloads(); + } + + /// Clear any existing downloads on startup (no resume) + Future _clearExistingDownloads() async { + try { + // Cancel all existing tasks + final tasks = await _downloader.allTasks(); + + for (final task in tasks) { + if (task is DownloadTask && task.group == FileDownloader.defaultGroup) { + await _downloader.cancelTaskWithId(task.taskId); + + // Try to clean up the file + try { + final filePath = await task.filePath(); + final file = File(filePath); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + // File might not exist yet, ignore + } + } + } + + // Clear the database + await _downloader.reset(); + } catch (e) { + // Failed to clear existing downloads + } + } + + /// Start downloading an app with specific file metadata + Future downloadAppWithMetadata( + String appId, + String appName, + FileMetadata fileMetadata, + ) async { + // Get download URL for current platform + final packageManager = ref.read(packageManagerProvider.notifier); + final platform = packageManager.platform; + + String? downloadUrl; + if (fileMetadata.platforms.contains(platform) || + fileMetadata.platforms.isEmpty) { + downloadUrl = fileMetadata.urls.firstOrNull; + } + + if (downloadUrl == null || downloadUrl.isEmpty) { + throw Exception( + 'No download URL available for platform: $platform. ' + 'Available platforms: ${fileMetadata.platforms.join(', ')}', + ); + } + + // Check if already downloading + if (state.containsKey(appId)) { + final existing = state[appId]!; + if (existing.status == TaskStatus.running || + existing.status == TaskStatus.enqueued) { + return; + } + } + + // Use FileMetadata hash as filename with platform-specific extension + final packageExtension = packageManager.packageExtension; + final fileName = '${fileMetadata.hash}$packageExtension'; + + // Create download task + final taskId = + '${appId}_${DateTime.now().millisecondsSinceEpoch}_${UniqueKey().toString()}'; + final task = DownloadTask( + taskId: taskId, + url: downloadUrl, + filename: fileName, + updates: Updates.statusAndProgress, + requiresWiFi: false, + retries: 3, + allowPause: true, + metaData: appId, + displayName: appName, + ); + + // Update state + state = { + ...state, + appId: DownloadInfo( + appId: appId, + task: task, + fileMetadata: fileMetadata, + progress: 0.0, + ), + }; + + try { + final result = await _downloader.enqueue(task); + if (!result) { + throw Exception('Failed to start download'); + } + } catch (e) { + state = Map.from(state)..remove(appId); + rethrow; + } + } + + /// Start downloading an app + Future downloadApp(App app, Release release) async { + final fileMetadata = app.latestFileMetadata; + if (fileMetadata == null) { + throw Exception('No file metadata available for this release'); + } + + // Get download URL for current platform + final packageManager = ref.read(packageManagerProvider.notifier); + final platform = packageManager.platform; + + String? downloadUrl; + if (fileMetadata.platforms.contains(platform) || + fileMetadata.platforms.isEmpty) { + downloadUrl = fileMetadata.urls.firstOrNull; + } + + if (downloadUrl == null || downloadUrl.isEmpty) { + throw Exception( + 'No download URL available for platform: $platform. ' + 'Available platforms: ${fileMetadata.platforms.join(', ')}', + ); + } + + // Check if already downloading + if (state.containsKey(app.identifier)) { + final existing = state[app.identifier]!; + if (existing.status == TaskStatus.running || + existing.status == TaskStatus.enqueued) { + return; // Already downloading + } + } + + // Enforce maximum 3 concurrent downloads + final activeDownloads = state.values + .where( + (info) => + info.status == TaskStatus.running || + info.status == TaskStatus.enqueued || + info.status == TaskStatus.waitingToRetry, + ) + .length; + + if (activeDownloads >= 3) { + throw Exception( + 'Maximum concurrent downloads (3) reached. ' + 'Please wait for current downloads to complete.', + ); + } + + // Use FileMetadata hash as filename with platform-specific extension + final packageExtension = packageManager.packageExtension; + final fileName = '${fileMetadata.hash}$packageExtension'; + + // Create download task with built-in retry + // Use a more unique task ID to prevent collisions + final taskId = + '${app.identifier}_${DateTime.now().millisecondsSinceEpoch}_${UniqueKey().toString()}'; + final task = DownloadTask( + taskId: taskId, + url: downloadUrl, + filename: fileName, + updates: Updates.statusAndProgress, + requiresWiFi: false, + retries: 3, // Built-in retry logic + allowPause: true, + metaData: app.identifier, // Store app ID for easy lookup + displayName: app.name ?? app.identifier, // Show app name in notifications + ); + + // Update state with initial progress of 0.0 + state = { + ...state, + app.identifier: DownloadInfo( + appId: app.identifier, + task: task, + fileMetadata: fileMetadata, + progress: 0.0, // Explicitly set initial progress + ), + }; + + try { + // Enqueue the download + final result = await _downloader.enqueue(task); + + if (!result) { + throw Exception('Failed to start download'); + } + } catch (e) { + // Remove from state on failure + state = Map.from(state)..remove(app.identifier); + rethrow; + } + } + + /// Handle task updates (both status and progress) + void _handleTaskUpdate(TaskUpdate update) { + // Find the app ID for this task + String? appId; + for (final entry in state.entries) { + if (entry.value.taskId == update.task.taskId) { + appId = entry.key; + break; + } + } + + if (appId == null) { + return; + } + + final currentInfo = state[appId]!; + + // Update based on update type + if (update is TaskStatusUpdate) { + // Handle failure with detailed error + if (update.status == TaskStatus.failed) { + String errorDetails = 'Download failed'; + + if (update.exception != null) { + // Extract meaningful error message + final exception = update.exception!; + if (exception.runtimeType.toString().contains('TaskException')) { + // TaskException contains httpResponseCode and description + errorDetails = exception.toString(); + } else { + errorDetails = exception.toString(); + } + + // Trim long stack traces + if (errorDetails.length > 200) { + errorDetails = '${errorDetails.substring(0, 197)}...'; + } + } + + state = { + ...state, + appId: currentInfo.copyWith( + status: update.status, + errorDetails: errorDetails, + ), + }; + } else if (update.status == TaskStatus.paused) { + state = {...state, appId: currentInfo.copyWith(status: update.status)}; + } else if (update.status == TaskStatus.canceled) { + state = {...state, appId: currentInfo.copyWith(status: update.status)}; + } else { + state = {...state, appId: currentInfo.copyWith(status: update.status)}; + + // Handle completion + if (update.status == TaskStatus.complete) { + _handleDownloadComplete(appId); + } + } + } else if (update is TaskProgressUpdate) { + // Background_downloader sends bogus -5.0 progress when pausing + // We need to preserve the last valid progress instead of using 0.0 + double safeProgress = currentInfo.progress; + + if (update.progress.isFinite && + !update.progress.isNaN && + update.progress >= 0.0) { + safeProgress = update.progress.clamp(0.0, 1.0); + } + // Otherwise keep the previous valid progress + + state = {...state, appId: currentInfo.copyWith(progress: safeProgress)}; + } + } + + /// Handle download completion - add to installation queue + Future _handleDownloadComplete(String appId) async { + final downloadInfo = state[appId]; + // ignore: unnecessary_null_comparison + if (downloadInfo == null) return; // Early exit if already removed + + // Add to installation queue + if (!_installQueue.contains(appId)) { + _installQueue.add(appId); + } + + // Process queue if not already processing + _processInstallQueue(); + } + + /// Process installation queue sequentially to prevent concurrent installations + Future _processInstallQueue() async { + // Don't start processing if already processing + if (_isInstallingFromQueue) return; + + // Don't process if queue is empty + if (_installQueue.isEmpty) return; + + _isInstallingFromQueue = true; + + while (_installQueue.isNotEmpty) { + final appId = _installQueue.first; + final downloadInfo = state[appId]; + + // Skip if download info no longer exists + if (downloadInfo == null) { + _installQueue.removeAt(0); + continue; + } + + try { + // Get the downloaded file path + final filePath = await downloadInfo.task.filePath(); + + // Update state to show installing + state = {...state, appId: downloadInfo.copyWith(isInstalling: true)}; + + // Use the stored file metadata from the download info + final fileMetadata = downloadInfo.fileMetadata; + + // Trigger installation with verification data + final packageManager = ref.read(packageManagerProvider.notifier); + await packageManager.install( + appId, + filePath, + expectedHash: fileMetadata.hash, + expectedSize: fileMetadata.size ?? 0, + ); + + // Remove from queue after successful installation initiation + _installQueue.removeAt(0); + + // Add a small delay between installations to ensure Android processes them sequentially + if (_installQueue.isNotEmpty) { + await Future.delayed(const Duration(milliseconds: 500)); + } + } catch (e) { + // Remove from queue and state on error + _installQueue.removeAt(0); + state = Map.from(state)..remove(appId); + } + } + + _isInstallingFromQueue = false; + } + + /// Handle installation result from BroadcastReceiver + void _handleInstallResult(Map result) { + final packageName = result['packageName'] as String?; + final success = result['success'] as bool? ?? false; + + if (packageName == null) return; // Safety check + + // Find and remove the download + final downloadInfo = state[packageName]; + // ignore: unnecessary_null_comparison + if (downloadInfo != null) { + // Check if download exists + if (success) { + // Clean up downloaded file - ALWAYS delete after successful install + downloadInfo.task.filePath().then((filePath) async { + try { + final file = File(filePath); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + // Continue even if deletion fails + } + }); + + // Remove from state + state = Map.from(state)..remove(packageName); + + // Refresh package list to show installed app + ref.read(packageManagerProvider.notifier).syncInstalledPackages(); + } else { + // Keep download info but mark as ready to install (don't remove file) + state = Map.from(state) + ..[packageName] = downloadInfo.copyWith( + isInstalling: false, + isReadyToInstall: true, + ); + } + } + } + + /// Pause a download + /// + /// The background_downloader package handles pause state persistence, + /// so downloads can be resumed even after app restart. + Future pauseDownload(String appId) async { + final downloadInfo = state[appId]; + if (downloadInfo == null) { + return; + } + + try { + await _downloader.pause(downloadInfo.task); + // State update happens automatically via task callbacks + } catch (e) { + // Exception while pausing download + } + } + + /// Resume a paused download + /// + /// The background_downloader package automatically handles resumption + /// from the exact byte position where it was paused. + Future resumeDownload(String appId) async { + final downloadInfo = state[appId]; + if (downloadInfo == null) { + return; + } + + try { + await _downloader.resume(downloadInfo.task); + // State update happens automatically via task callbacks + } catch (e) { + // Exception while resuming download + } + } + + /// Cancel a download + Future cancelDownload(String appId) async { + final downloadInfo = state[appId]; + if (downloadInfo == null) return; + + try { + await _downloader.cancel(downloadInfo.task); + + // Remove from state + state = Map.from(state)..remove(appId); + + // Clean up any partial file + final filePath = await downloadInfo.task.filePath(); + // ignore: unnecessary_null_comparison + if (filePath != null) { + // File might not exist yet + await File(filePath).delete().catchError((_) => File(filePath)); + } + } catch (e) { + // Failed to cancel download + } + } + + /// Retry a failed download + Future retryDownload(String appId) async { + final downloadInfo = state[appId]; + if (downloadInfo == null) return; + + try { + // The package handles retry automatically with the retries parameter + // For manual retry, we need to create a new task + await _downloader.cancel(downloadInfo.task); + state = Map.from(state)..remove(appId); + + // Re-download using the same logic + // This requires us to have access to the app and release + // For now, just remove it and let user re-initiate + } catch (e) { + // Failed to retry download + } + } + + /// Get download info for an app + DownloadInfo? getDownloadInfo(String appId) => state[appId]; + + /// Check if app is currently downloading + bool isDownloading(String appId) { + final info = state[appId]; + return info != null && + (info.status == TaskStatus.running || + info.status == TaskStatus.enqueued); + } + + /// Mark download as installing (for reckless mode retry) + void markInstalling(String appId) { + final downloadInfo = state[appId]; + if (downloadInfo != null) { + state = { + ...state, + appId: downloadInfo.copyWith( + isInstalling: true, + isReadyToInstall: false, + errorDetails: null, + ), + }; + } + } + + /// Install from existing downloaded file + Future installFromDownloaded(String appId) async { + final downloadInfo = state[appId]; + if (downloadInfo == null || !downloadInfo.isReadyToInstall) return; + + try { + final filePath = await downloadInfo.task.filePath(); + + // Check if file still exists + if (!await File(filePath).exists()) { + // File was deleted, remove from state + state = Map.from(state)..remove(appId); + return; + } + + // Update state to mark as no longer ready to install (about to install) + state = { + ...state, + appId: downloadInfo.copyWith( + isReadyToInstall: false, + ), + }; + + // Add to installation queue instead of installing directly + if (!_installQueue.contains(appId)) { + _installQueue.add(appId); + } + + // Process queue if not already processing + _processInstallQueue(); + } catch (e) { + // Reset to ready-to-install state on error + state = Map.from(state) + ..[appId] = downloadInfo.copyWith( + isInstalling: false, + isReadyToInstall: true, + ); + } + } + + /// Clean up all downloads + Future cleanupDownloads() async { + try { + // Cancel all active downloads + for (final downloadInfo in state.values) { + await _downloader.cancel(downloadInfo.task); + } + + // Clear state + state = {}; + + // Clean up download directory + // background_downloader manages its own directory internally + // We rely on task.filePath() to get file locations + } catch (e) { + // Failed to cleanup downloads + } + } +} + +/// Provider for the download service +final downloadServiceProvider = + StateNotifierProvider>( + DownloadService.new, + ); + +/// Provider for getting download info for a specific app +final downloadInfoProvider = Provider.family(( + ref, + appId, +) { + final downloads = ref.watch(downloadServiceProvider); + return downloads[appId]; +}); diff --git a/lib/services/error_reporting_service.dart b/lib/services/error_reporting_service.dart new file mode 100644 index 0000000..5f11ff0 --- /dev/null +++ b/lib/services/error_reporting_service.dart @@ -0,0 +1,115 @@ +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. +/// +/// Uses [kAnonymousPrivateKey] for signing so error reports 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) { + debugPrint('Error (not reported in debug): $exception'); + debugPrint('$stackTrace'); + 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 anonymous signer for error reporting + final signer = Bip340PrivateKeySigner(kAnonymousPrivateKey, 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', stream: false), + ); + + // Update rate limiting + _reportedErrors[errorHash] = DateTime.now(); + _sessionReportCount++; + + debugPrint('Error report sent successfully'); + } catch (e) { + // Silently fail - we don't want error reporting to cause more errors + debugPrint('Failed to send error report: $e'); + } + } + + 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/notification_service.dart b/lib/services/notification_service.dart new file mode 100644 index 0000000..4ff3621 --- /dev/null +++ b/lib/services/notification_service.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:toastification/toastification.dart'; +import 'package:zapstore/theme.dart'; + +extension ContextX on BuildContext { + ThemeData get theme => Theme.of(this); + void showInfo(String message, {String? description, Icon? icon}) { + const actionColor = AppColors.darkActionPrimary; + + toastification.show( + context: this, + type: ToastificationType.info, + icon: icon ?? const Icon(Icons.info, color: Colors.white), + 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, + primaryColor: actionColor, + backgroundColor: actionColor, + foregroundColor: Colors.white, + ); + } + + 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 ?? const Icon(Icons.error, color: Colors.white), + title: _ToastTitleWidget(title), + autoCloseDuration: const Duration(seconds: 5), + showProgressBar: false, + closeOnClick: true, + foregroundColor: Colors.white, + description: description != null + ? _ToastDescriptionWidget(description: description, actions: actions) + : null, + ); + } +} + +class _ToastTitleWidget extends StatelessWidget { + final String text; + const _ToastTitleWidget(this.text); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + overflow: TextOverflow.ellipsis, + maxLines: 4, + ); + } +} + +class _ToastDescriptionWidget extends StatelessWidget { + final String? description; + final List<(String, Future Function())> actions; + + const _ToastDescriptionWidget({this.description, this.actions = const []}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + text: TextSpan( + text: description, + style: const TextStyle(fontSize: 16, color: Colors.white), + ), + ), + for (final (text, fn) in actions) + ElevatedButton( + onPressed: () async { + await fn.call(); + toastification.dismissAll(delayForAnimation: false); + }, + child: Text(text), + ), + ], + ); + } +} 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..32676d7 --- /dev/null +++ b/lib/services/package_manager/android_package_manager.dart @@ -0,0 +1,305 @@ +import 'dart:io'; +import 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; + +/// Android implementation of PackageManager with integrated installation +final class AndroidPackageManager extends PackageManager { + AndroidPackageManager(super.ref) { + // Set up method channel handler for callbacks from native side + // NOTE: Do not call any plugins in the constructor. Plugins may not yet be + // registered at this point, leading to MissingPluginException. + // However, setting the method call handler is safe. + _channel.setMethodCallHandler(_handleMethodCall); + } + + static const MethodChannel _channel = MethodChannel( + 'android_package_manager', + ); + bool _supportsSilentInstall = false; + + @override + void dispose() { + _channel.setMethodCallHandler(null); + super.dispose(); + } + + /// Handle method calls from native side + Future _handleMethodCall(MethodCall call) async { + if (call.method == 'onInstallResult') { + final result = Map.from(call.arguments as Map); + // Use the callback from the base class + onInstallResult?.call(result); + } + } + + @override + String get platform => 'android-arm64-v8a'; + + @override + String get packageExtension => '.apk'; + + @override + Future install( + String appId, + String filePath, { + required String expectedHash, + required int expectedSize, + bool skipVerification = false, + }) async { + await _ensureInstallPermission(); + + final file = File(filePath); + if (!file.existsSync()) { + throw Exception('APK file not found: $filePath'); + } + + try { + // Attempt installation via method channel with verification + final result = await _channel + .invokeMethod>('install', { + 'filePath': filePath, + 'packageName': appId, + 'expectedHash': expectedHash, + 'expectedSize': expectedSize, + 'skipVerification': skipVerification, + }) + .timeout( + Duration(seconds: 20), + onTimeout: () => { + 'isSuccess': false, + 'errorMessage': 'Installation timed out after 20 seconds', + }, + ); + + final resultMap = Map.from(result ?? {}); + + if (!(resultMap['isSuccess'] == true)) { + final error = resultMap['errorMessage'] ?? 'Installation failed'; + + // Provide more helpful error messages based on silent install capability + if (_supportsSilentInstall && error.contains('User action required')) { + throw Exception( + 'Silent installation failed: $error\n\n' + 'This may indicate:\n' + '• The app requires special permissions\n' + '• System policy prevents silent installation\n' + '• The APK signature is not trusted\n\n' + 'The installation dialog should appear automatically.', + ); + } + + throw Exception(error); + } + + // Refresh installed packages state + await syncInstalledPackages(); + } catch (e) { + throw Exception('Installation failed: $e'); + } + } + + @override + Future uninstall(String appId) async { + try { + // Call uninstall method via method channel + final result = await _channel + .invokeMethod>('uninstall', { + 'packageName': appId, + }) + .timeout( + const Duration(seconds: 15), + onTimeout: () => { + 'isSuccess': false, + 'errorMessage': 'Uninstallation timed out after 15 seconds', + }, + ); + + final resultMap = Map.from(result ?? {}); + + if (!(resultMap['isSuccess'] == true)) { + final error = resultMap['errorMessage'] ?? 'Uninstallation failed'; + throw Exception(error); + } + + // Refresh installed packages state after a brief delay + // to allow the system uninstaller to complete + await Future.delayed(const Duration(milliseconds: 500)); + await syncInstalledPackages(); + } catch (e) { + throw Exception('Uninstallation failed: $e'); + } + } + + @override + Future requestPermission() async { + try { + final result = await _channel.invokeMethod>( + 'requestInstallPermission', + ); + final resultMap = Map.from(result ?? {}); + + if (!(resultMap['success'] == true)) { + final error = resultMap['message'] ?? 'Failed to request permission'; + throw Exception(error); + } + } 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 { + final hasUnknownSources = + await _channel.invokeMethod('hasUnknownSourcesPermission') ?? + false; + return hasUnknownSources; + } catch (e) { + return false; + } + } + + @override + bool get supportsSilentInstall { + // Cached capability determined asynchronously via plugin + return _supportsSilentInstall; + } + + /// Check if automatic, unattended updates are possible + bool get supportsAutomaticUpdates { + return _supportsSilentInstall; + } + + /// Check if we can silently install/update a specific package + @override + Future canInstallSilently(String appId) async { + try { + final result = + await _channel.invokeMethod('canInstallSilently', { + 'packageName': appId, + }) ?? + false; + return result; + } catch (e) { + return false; + } + } + + /// Install an update silently if possible, otherwise fall back to user confirmation + Future installUpdate( + String appId, + String filePath, { + required String expectedHash, + required int expectedSize, + }) async { + // Install regardless - the system will handle showing confirmation if needed + await install( + appId, + filePath, + expectedHash: expectedHash, + expectedSize: expectedSize, + ); + } + + /// Launch an installed app by its package identifier + @override + Future launchApp(String appId) async { + try { + final result = await _channel + .invokeMethod>('launchApp', { + 'packageName': appId, + }) + .timeout( + const Duration(seconds: 10), + onTimeout: () => { + 'isSuccess': false, + 'errorMessage': 'App launch timed out after 10 seconds', + }, + ); + + final resultMap = Map.from(result ?? {}); + + if (!(resultMap['isSuccess'] == true)) { + final error = resultMap['errorMessage'] ?? 'Failed to launch app'; + throw Exception(error); + } + } catch (e) { + throw Exception('Failed to launch app: $e'); + } + } + + @override + Future syncInstalledPackages() async { + try { + // Check and cache silent install capability (general check) + final canInstallSilently = + await _channel.invokeMethod('canInstallSilently') ?? false; + _supportsSilentInstall = canInstallSilently; + + // Get installed apps via method channel, excluding system apps + final installedApps = + await _channel.invokeMethod>('getInstalledApps', { + 'includeSystemApps': false, + }) ?? + []; + + final packages = []; + + for (final appObj in installedApps) { + final app = Map.from(appObj as Map); + final appId = + app['bundleId'] as String? ?? app['packageName'] as String? ?? ''; + final version = app['versionName'] as String? ?? '0.0.0'; + final versionCode = app['versionCode'] as int?; + + if (appId.isNotEmpty) { + packages.add( + PackageInfo( + appId: appId, + version: version, + versionCode: versionCode, + installTime: null, // Method channel doesn't provide install time + signatureHash: + '', // Method channel doesn't provide signature hash + ), + ); + } + } + + state = packages; + } catch (e) { + // Fallback to empty state on error + state = []; + } + } + + Future _ensureInstallPermission() async { + if (!Platform.isAndroid) return; + + try { + final status = await Permission.requestInstallPackages.status; + if (status.isGranted) { + return; + } + + final newStatus = await Permission.requestInstallPackages.request(); + if (!newStatus.isGranted) { + throw Exception( + 'Install permission required. Please enable "Install unknown apps" ' + 'for Zapstore and try again.', + ); + } + } catch (e) { + throw Exception('Unable to request install permission: $e'); + } + } +} 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..164a390 --- /dev/null +++ b/lib/services/package_manager/dummy_package_manager.dart @@ -0,0 +1,100 @@ +import 'package:zapstore/services/package_manager/package_manager.dart'; + +/// Dummy implementation of PackageManager for testing +final class DummyPackageManager extends PackageManager { + DummyPackageManager(super.ref) { + // Initialize state with mock packages + state = [ + PackageInfo( + appId: 'com.example.test', + version: '1.0.0', + versionCode: 1, + signatureHash: 'dummy_signature_1', + installTime: DateTime.now().subtract(const Duration(days: 1)), + ), + PackageInfo( + appId: 'dev.zapstore.alpha', + version: '1.0.0', + versionCode: 1, + signatureHash: 'dummy_signature_2', + installTime: DateTime.now().subtract(const Duration(hours: 12)), + ), + PackageInfo( + appId: 'com.dummy.browser', + version: '2.1.0', + versionCode: 210, + signatureHash: 'dummy_signature_3', + installTime: DateTime.now().subtract(const Duration(days: 5)), + ), + ]; + } + + @override + String get platform => 'android-arm64-v8a'; + + @override + String get packageExtension => '.apk'; + + @override + Future install( + String appId, + String filePath, { + required String expectedHash, + required int expectedSize, + bool skipVerification = false, + }) async { + // Mock implementation - just add to list if not already present + if (!state.any((p) => p.appId == appId)) { + state = [ + ...state, + PackageInfo( + appId: appId, + version: '1.0.0', + versionCode: 1, + signatureHash: 'mock_signature', + installTime: DateTime.now(), + ), + ]; + } + } + + @override + Future uninstall(String appId) async { + state = state.where((p) => p.appId != appId).toList(); + } + + @override + Future launchApp(String appId) async { + // Mock implementation - just simulate launching + if (!state.any((p) => p.appId == appId)) { + throw Exception('App not installed: $appId'); + } + // In a real implementation, this would launch the app + await Future.delayed(const Duration(milliseconds: 500)); + } + + @override + Future requestPermission() async { + // Mock implementation - always succeeds + } + + @override + Future hasPermission() async { + // Mock implementation - always has permission + return true; + } + + @override + bool get supportsSilentInstall => false; + + @override + Future canInstallSilently(String appId) async { + // Mock implementation - always returns false for dummy manager + return false; + } + + @override + Future syncInstalledPackages() async { + // No-op for dummy implementation + } +} diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart new file mode 100644 index 0000000..f0a8228 --- /dev/null +++ b/lib/services/package_manager/package_manager.dart @@ -0,0 +1,151 @@ +import 'dart:async'; + +import 'package:equatable/equatable.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/utils/version_utils.dart'; + +/// Information about an installed package +class PackageInfo extends Equatable { + const PackageInfo({ + required this.appId, + required this.version, + required this.versionCode, + required this.signatureHash, + this.installTime, + }); + + /// The application identifier (bundle ID) + final String appId; + + /// Version string (e.g., "1.2.3") + final String version; + + /// Numeric version code (Android) or null for other platforms + final int? versionCode; + + /// APK signature hash or certificate hash + final String signatureHash; + + /// When the app was installed + final DateTime? installTime; + + @override + List get props => [ + appId, + version, + versionCode, + signatureHash, + installTime, + ]; +} + +/// Callback type for installation results +typedef InstallResultCallback = void Function(Map result); + +/// Package management interface +abstract class PackageManager extends StateNotifier> { + PackageManager(this.ref) : super([]); + + /// Riverpod ref for accessing storage and other dependencies. + final Ref ref; + + /// Optional callback for installation results + InstallResultCallback? onInstallResult; + + /// Install an APK or package from the given file path + Future install( + String appId, + String filePath, { + required String expectedHash, + required int expectedSize, + bool skipVerification = false, + }); + + /// Uninstall a package by its app identifier + Future uninstall(String appId); + + /// Launch an installed app by its app identifier + Future launchApp(String appId); + + /// Check if an app can be installed (version compatibility, etc.) + /// + /// Compares by versionCode first when available. If version codes are equal + /// or unavailable, falls back to semantic version comparison. + /// + /// Uses the current state for installed packages check. + bool canInstall(FileMetadata m, String version, {int? versionCode}) { + final installed = state + .where((p) => p.appId == m.appIdentifier) + .firstOrNull; + if (installed == null) return true; + + return canUpgrade( + installed.versionCode?.toString() ?? installed.version, + versionCode?.toString() ?? version, + ); + } + + bool canUpdate(FileMetadata m) { + final installed = state + .where((p) => p.appId == m.appIdentifier) + .firstOrNull; + if (installed == null) return false; + return canUpgrade( + installed.versionCode?.toString() ?? installed.version, + m.versionCode?.toString() ?? m.version, + ); + } + + /// Request installation permissions from the user/system + Future requestPermission(); + + /// Check if the app has installation permissions + Future hasPermission(); + + /// Whether the platform supports silent installation + bool get supportsSilentInstall; + + /// Check if a specific package can be silently installed/updated + /// Returns false if not supported or if the package requires user confirmation + Future canInstallSilently(String appId); + + /// Target platform identifier used to filter file metadata (e.g., '#f' tag) + /// Example: 'android-arm64-v8a' for Android arm64 builds + String get platform; + + /// File extension for packages on this platform + /// Example: '.apk' for Android, '.ipa' for iOS, '.dmg' for macOS + String get packageExtension; + + /// Refresh the internal state of installed packages + Future syncInstalledPackages(); + + /// Get all installed packages (returns current state) + List getInstalledPackages() => state; + + /// Check if a package is installed + bool isInstalled(String appId) { + return state.any((p) => p.appId == appId); + } + + /// Get package info for a specific app + PackageInfo? getInfo(String appId) { + try { + return state.firstWhere((p) => p.appId == appId); + } catch (e) { + return null; + } + } +} + +final packageManagerProvider = + StateNotifierProvider>( + DummyPackageManager.new, + ); + +extension FileMetadataExt on FileMetadata { + bool get canUpdate => + ref.read(packageManagerProvider.notifier).canUpdate(this); +} diff --git a/lib/services/preferences_service.dart b/lib/services/preferences_service.dart new file mode 100644 index 0000000..01420a4 --- /dev/null +++ b/lib/services/preferences_service.dart @@ -0,0 +1,15 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +/// Service to manage user preferences +/// Note: Theme preferences removed as app is dark mode only +class PreferencesService { + const PreferencesService(this.ref); + + final Ref ref; + + // Future preference methods can be added here +} + +final preferencesServiceProvider = Provider( + (ref) => PreferencesService(ref), +); diff --git a/lib/services/profile_provider.dart b/lib/services/profile_provider.dart new file mode 100644 index 0000000..cc811c2 --- /dev/null +++ b/lib/services/profile_provider.dart @@ -0,0 +1,167 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:models/models.dart'; + +/// Cache duration before fetching from remote relays +const kProfileCacheDuration = Duration(hours: 2); + +/// Tracks last fetch times for profiles by pubkey +final _profileFetchTimesProvider = StateProvider>( + (ref) => {}, +); + +/// Tracks profile update timestamps to trigger reactivity +final _profileUpdateTriggerProvider = StateProvider>( + (ref) => {}, +); + +final _profileRemoteFetchProvider = FutureProvider.autoDispose + .family((ref, pubkey) async { + final storage = ref.read(storageNotifierProvider.notifier); + final request = RequestFilter(authors: {pubkey}).toRequest(); + + try { + final profiles = await storage.query( + request, + source: const RemoteSource( + relays: 'social', + stream: false, + background: false, + ), + ); + + // Save to local storage + if (profiles.isNotEmpty) { + await storage.save(profiles.toSet()); + } + + ref.read(_profileFetchTimesProvider.notifier).update((state) { + return {...state, pubkey: DateTime.now()}; + }); + } catch (error, stackTrace) { + debugPrint('Failed to fetch profile $pubkey: $error\n$stackTrace'); + } + }); + +/// Service for managing profile fetches with cache awareness +class ProfileService { + ProfileService(this.ref); + + final Ref ref; + + /// Fetches profiles from remote, respecting cache duration. + /// Pass a Set of pubkeys - use Set of 1 for single profile. + Future fetchProfiles(Set pubkeys) async { + if (pubkeys.isEmpty) return; + + final fetchTimes = ref.read(_profileFetchTimesProvider); + final now = DateTime.now(); + + // Filter out recently fetched profiles + final staleProfiles = pubkeys.where((pubkey) { + final lastFetch = fetchTimes[pubkey]; + return lastFetch == null || + now.difference(lastFetch) > kProfileCacheDuration; + }).toSet(); + + if (staleProfiles.isEmpty) return; + + try { + final profiles = await ref.read(storageNotifierProvider.notifier).query( + RequestFilter(authors: staleProfiles).toRequest(), + source: const RemoteSource( + relays: 'social', + stream: false, + background: false, + ), + ); + + // Save to local storage so they're available for LocalSource queries + if (profiles.isNotEmpty) { + await ref.read(storageNotifierProvider.notifier).save(profiles.toSet()); + + // Trigger reactivity by updating timestamp for each profile + ref.read(_profileUpdateTriggerProvider.notifier).update((state) { + final updated = {...state}; + for (final profile in profiles) { + updated[profile.event.pubkey] = DateTime.now(); + } + return updated; + }); + } + + // Update fetch times for all profiles + ref.read(_profileFetchTimesProvider.notifier).update((state) { + final updated = {...state}; + for (final pubkey in staleProfiles) { + updated[pubkey] = now; + } + return updated; + }); + } catch (error, stackTrace) { + debugPrint('Failed to fetch profiles: $error\n$stackTrace'); + } + } + + /// Invalidate cache for a specific profile, forcing a remote fetch on next access. + void invalidateProfile(String pubkey) { + ref.read(_profileFetchTimesProvider.notifier).update((state) { + final updated = {...state}; + updated.remove(pubkey); + return updated; + }); + } + + /// Invalidate cache for all profiles, forcing remote fetches on next access. + /// Useful for pull-to-refresh scenarios. + void invalidateAllProfiles() { + ref.read(_profileFetchTimesProvider.notifier).state = {}; + } +} + +/// Provider for the profile service +final profileServiceProvider = Provider((ref) => ProfileService(ref)); + +/// Provider family for accessing profiles with smart caching +/// +/// Automatically determines whether to trigger a remote refresh: +/// - If profile was fetched from remote < 2 hours ago: keep cached data +/// - If profile was never fetched or > 2 hours ago: run a background remote fetch +/// - Reacts to profile updates via _profileUpdateTriggerProvider +final profileProvider = + AutoDisposeProvider.family, String>((ref, pubkey) { + final lastFetchTime = ref.watch( + _profileFetchTimesProvider.select((state) => state[pubkey]), + ); + + // Watch update trigger to react when profiles are saved + ref.watch( + _profileUpdateTriggerProvider.select((state) => state[pubkey]), + ); + + final now = DateTime.now(); + final shouldFetchRemote = + lastFetchTime == null || + now.difference(lastFetchTime) > kProfileCacheDuration; + + // Trigger background fetch if cache is stale + if (shouldFetchRemote) { + ref.watch(_profileRemoteFetchProvider(pubkey)); + } + + // Read from local storage (will re-run when update trigger changes) + final profileState = ref.watch( + query(authors: {pubkey}, source: const LocalSource()), + ); + + return switch (profileState) { + StorageLoading() => const AsyncValue.loading(), + StorageError(:final exception) => AsyncValue.error( + exception, + StackTrace.current, + ), + StorageData(:final models) => AsyncValue.data( + models.isNotEmpty ? models.first : null, + ), + }; + }); diff --git a/lib/services/profile_service.dart b/lib/services/profile_service.dart new file mode 100644 index 0000000..cc811c2 --- /dev/null +++ b/lib/services/profile_service.dart @@ -0,0 +1,167 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:models/models.dart'; + +/// Cache duration before fetching from remote relays +const kProfileCacheDuration = Duration(hours: 2); + +/// Tracks last fetch times for profiles by pubkey +final _profileFetchTimesProvider = StateProvider>( + (ref) => {}, +); + +/// Tracks profile update timestamps to trigger reactivity +final _profileUpdateTriggerProvider = StateProvider>( + (ref) => {}, +); + +final _profileRemoteFetchProvider = FutureProvider.autoDispose + .family((ref, pubkey) async { + final storage = ref.read(storageNotifierProvider.notifier); + final request = RequestFilter(authors: {pubkey}).toRequest(); + + try { + final profiles = await storage.query( + request, + source: const RemoteSource( + relays: 'social', + stream: false, + background: false, + ), + ); + + // Save to local storage + if (profiles.isNotEmpty) { + await storage.save(profiles.toSet()); + } + + ref.read(_profileFetchTimesProvider.notifier).update((state) { + return {...state, pubkey: DateTime.now()}; + }); + } catch (error, stackTrace) { + debugPrint('Failed to fetch profile $pubkey: $error\n$stackTrace'); + } + }); + +/// Service for managing profile fetches with cache awareness +class ProfileService { + ProfileService(this.ref); + + final Ref ref; + + /// Fetches profiles from remote, respecting cache duration. + /// Pass a Set of pubkeys - use Set of 1 for single profile. + Future fetchProfiles(Set pubkeys) async { + if (pubkeys.isEmpty) return; + + final fetchTimes = ref.read(_profileFetchTimesProvider); + final now = DateTime.now(); + + // Filter out recently fetched profiles + final staleProfiles = pubkeys.where((pubkey) { + final lastFetch = fetchTimes[pubkey]; + return lastFetch == null || + now.difference(lastFetch) > kProfileCacheDuration; + }).toSet(); + + if (staleProfiles.isEmpty) return; + + try { + final profiles = await ref.read(storageNotifierProvider.notifier).query( + RequestFilter(authors: staleProfiles).toRequest(), + source: const RemoteSource( + relays: 'social', + stream: false, + background: false, + ), + ); + + // Save to local storage so they're available for LocalSource queries + if (profiles.isNotEmpty) { + await ref.read(storageNotifierProvider.notifier).save(profiles.toSet()); + + // Trigger reactivity by updating timestamp for each profile + ref.read(_profileUpdateTriggerProvider.notifier).update((state) { + final updated = {...state}; + for (final profile in profiles) { + updated[profile.event.pubkey] = DateTime.now(); + } + return updated; + }); + } + + // Update fetch times for all profiles + ref.read(_profileFetchTimesProvider.notifier).update((state) { + final updated = {...state}; + for (final pubkey in staleProfiles) { + updated[pubkey] = now; + } + return updated; + }); + } catch (error, stackTrace) { + debugPrint('Failed to fetch profiles: $error\n$stackTrace'); + } + } + + /// Invalidate cache for a specific profile, forcing a remote fetch on next access. + void invalidateProfile(String pubkey) { + ref.read(_profileFetchTimesProvider.notifier).update((state) { + final updated = {...state}; + updated.remove(pubkey); + return updated; + }); + } + + /// Invalidate cache for all profiles, forcing remote fetches on next access. + /// Useful for pull-to-refresh scenarios. + void invalidateAllProfiles() { + ref.read(_profileFetchTimesProvider.notifier).state = {}; + } +} + +/// Provider for the profile service +final profileServiceProvider = Provider((ref) => ProfileService(ref)); + +/// Provider family for accessing profiles with smart caching +/// +/// Automatically determines whether to trigger a remote refresh: +/// - If profile was fetched from remote < 2 hours ago: keep cached data +/// - If profile was never fetched or > 2 hours ago: run a background remote fetch +/// - Reacts to profile updates via _profileUpdateTriggerProvider +final profileProvider = + AutoDisposeProvider.family, String>((ref, pubkey) { + final lastFetchTime = ref.watch( + _profileFetchTimesProvider.select((state) => state[pubkey]), + ); + + // Watch update trigger to react when profiles are saved + ref.watch( + _profileUpdateTriggerProvider.select((state) => state[pubkey]), + ); + + final now = DateTime.now(); + final shouldFetchRemote = + lastFetchTime == null || + now.difference(lastFetchTime) > kProfileCacheDuration; + + // Trigger background fetch if cache is stale + if (shouldFetchRemote) { + ref.watch(_profileRemoteFetchProvider(pubkey)); + } + + // Read from local storage (will re-run when update trigger changes) + final profileState = ref.watch( + query(authors: {pubkey}, source: const LocalSource()), + ); + + return switch (profileState) { + StorageLoading() => const AsyncValue.loading(), + StorageError(:final exception) => AsyncValue.error( + exception, + StackTrace.current, + ), + StorageData(:final models) => AsyncValue.data( + models.isNotEmpty ? models.first : null, + ), + }; + }); diff --git a/lib/services/trust_service.dart b/lib/services/trust_service.dart new file mode 100644 index 0000000..5ad68a6 --- /dev/null +++ b/lib/services/trust_service.dart @@ -0,0 +1,79 @@ +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 TrustService { + const TrustService(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) => TrustService(ref)); diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart new file mode 100644 index 0000000..446752f --- /dev/null +++ b/lib/services/updates_service.dart @@ -0,0 +1,216 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/utils/extensions.dart'; + +/// Categorized apps state +class CategorizedApps { + const CategorizedApps({ + required this.automaticUpdates, + required this.manualUpdates, + required this.upToDateApps, + this.isLoading = false, + }); + + final List automaticUpdates; + final List manualUpdates; + final List upToDateApps; + final bool isLoading; + + static const empty = CategorizedApps( + automaticUpdates: [], + manualUpdates: [], + upToDateApps: [], + isLoading: true, + ); + + CategorizedApps copyWith({ + List? automaticUpdates, + List? manualUpdates, + List? upToDateApps, + bool? isLoading, + }) { + return CategorizedApps( + automaticUpdates: automaticUpdates ?? this.automaticUpdates, + manualUpdates: manualUpdates ?? this.manualUpdates, + upToDateApps: upToDateApps ?? this.upToDateApps, + isLoading: isLoading ?? this.isLoading, + ); + } +} + +/// Provider that maintains streaming subscription and categorizes apps +class CategorizedAppsNotifier extends StateNotifier { + CategorizedAppsNotifier(this._ref) : super(CategorizedApps.empty) { + // Listen to package manager changes and recreate query subscription + _ref.listen(packageManagerProvider, (_, packages) { + final installedIds = packages.map((p) => p.appId).toSet(); + final platform = _ref.read(packageManagerProvider.notifier).platform; + _recreateQuerySubscription(installedIds, platform); + }, fireImmediately: true); + } + + final Ref _ref; + ProviderSubscription>? _querySub; + + void _recreateQuerySubscription(Set installedIds, String platform) { + // Close old subscription + _querySub?.close(); + + if (installedIds.isEmpty) { + state = const CategorizedApps( + automaticUpdates: [], + manualUpdates: [], + upToDateApps: [], + ); + return; + } + + // Create new streaming subscription + _querySub = _ref.listen( + query( + tags: { + '#d': installedIds, + '#f': {platform}, + }, + and: (app) => { + app.latestRelease, + if (app.latestRelease.value != null) + app.latestRelease.value!.latestMetadata, + }, + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + background: true, + stream: true, + ), + andSource: const LocalAndRemoteSource( + relays: 'AppCatalog', + background: true, + stream: false, + ), + subscriptionPrefix: 'updates', + ), + (_, appsState) { + // Fetch authors in background + _fetchAuthors(appsState.models); + + // Categorize + _categorizeApps(appsState.models); + }, + fireImmediately: true, + ); + } + + @override + void dispose() { + _querySub?.close(); + super.dispose(); + } + + void _fetchAuthors(List apps) async { + final authorPubkeys = apps.map((a) => a.event.pubkey).toSet(); + if (authorPubkeys.isNotEmpty) { + await _ref.storage.query( + RequestFilter(authors: authorPubkeys).toRequest(), + source: const RemoteSource( + relays: 'social', + background: false, + stream: false, + ), + ); + } + } + + void _categorizeApps(List apps) async { + final packageManager = _ref.read(packageManagerProvider.notifier); + final automaticUpdates = []; + final manualUpdates = []; + final upToDateApps = []; + + for (final app in apps) { + if (app.hasUpdate) { + final canSilentInstall = await packageManager.canInstallSilently( + app.identifier, + ); + if (canSilentInstall) { + automaticUpdates.add(app); + } else { + manualUpdates.add(app); + } + } else if (app.isUpdated) { + upToDateApps.add(app); + } + } + + // Sort alphabetically + automaticUpdates.sort( + (a, b) => (a.name ?? a.identifier).toLowerCase().compareTo( + (b.name ?? b.identifier).toLowerCase(), + ), + ); + manualUpdates.sort( + (a, b) => (a.name ?? a.identifier).toLowerCase().compareTo( + (b.name ?? b.identifier).toLowerCase(), + ), + ); + upToDateApps.sort( + (a, b) => (a.name ?? a.identifier).toLowerCase().compareTo( + (b.name ?? b.identifier).toLowerCase(), + ), + ); + + if (mounted) { + state = CategorizedApps( + automaticUpdates: automaticUpdates, + manualUpdates: manualUpdates, + upToDateApps: upToDateApps, + ); + } + } +} + +final categorizedAppsProvider = + StateNotifierProvider( + CategorizedAppsNotifier.new, + ); + +/// Provider that calculates the total number of apps with available updates +/// Uses LocalSource since categorizedAppsProvider already fetches from relays +final updateCountProvider = Provider((ref) { + // Watch package manager for installed state changes + final packages = ref.watch(packageManagerProvider); + final installedIds = packages.map((p) => p.appId).toSet(); + final platform = ref.read(packageManagerProvider.notifier).platform; + + if (installedIds.isEmpty) { + return 0; + } + + // Query apps from local storage only - data is already fetched by categorizedAppsProvider + final state = ref.watch( + query( + tags: { + '#d': installedIds, + '#f': {platform}, + }, + and: (app) => { + app.latestRelease, + // Load nested FileMetadata - critical for hasUpdate to work + if (app.latestRelease.value != null) + app.latestRelease.value!.latestMetadata, + }, + source: const LocalSource(), + subscriptionPrefix: 'update-count', + ), + ); + + int updateCount = 0; + + for (final app in state.models) { + if (app.hasUpdate) { + updateCount++; + } + } + + return updateCount; +}); diff --git a/lib/theme.dart b/lib/theme.dart new file mode 100644 index 0000000..d0b7df5 --- /dev/null +++ b/lib/theme.dart @@ -0,0 +1,380 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +/// Font family used throughout the app +const kFontFamily = 'Inter'; + +// Theme provider - always returns dark theme +final themeProvider = Provider((ref) => darkTheme); + +// 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: kFontFamily, + fontSize: 56, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + color: baseColor, + height: 1.1, + ), + displayMedium: const TextStyle( + fontFamily: kFontFamily, + fontSize: 44, + fontWeight: FontWeight.w600, + letterSpacing: -0.25, + color: baseColor, + height: 1.15, + ), + displaySmall: const TextStyle( + fontFamily: kFontFamily, + fontSize: 36, + fontWeight: FontWeight.w600, + letterSpacing: 0, + color: baseColor, + height: 1.2, + ), + + // Headline styles - for section titles + headlineLarge: const TextStyle( + fontFamily: kFontFamily, + fontSize: 32, + fontWeight: FontWeight.w600, + letterSpacing: 0, + color: baseColor, + height: 1.25, + ), + headlineMedium: const TextStyle( + fontFamily: kFontFamily, + fontSize: 28, + fontWeight: FontWeight.w600, + letterSpacing: 0.15, + color: baseColor, + height: 1.3, + ), + headlineSmall: const TextStyle( + fontFamily: kFontFamily, + fontSize: 24, + fontWeight: FontWeight.w600, + letterSpacing: 0.15, + color: baseColor, + height: 1.35, + ), + + // Title styles - for card titles and prominent text + titleLarge: const TextStyle( + fontFamily: kFontFamily, + fontSize: 22, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + color: baseColor, + height: 1.4, + ), + titleMedium: const TextStyle( + fontFamily: kFontFamily, + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: 0.15, + color: baseColor, + height: 1.45, + ), + titleSmall: const TextStyle( + fontFamily: kFontFamily, + fontSize: 16, + fontWeight: FontWeight.w500, + 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: kFontFamily, + fontSize: 20, + fontWeight: FontWeight.w600, + 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..46a0fee --- /dev/null +++ b/lib/utils/debug_utils.dart @@ -0,0 +1,31 @@ +/// Debug utilities for development and testing +library; + +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; +} diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index b3e2ca6..019bc56 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -1,172 +1,117 @@ -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'; +import 'package:collection/collection.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, - ); - } +// Re-export constants for backwards compatibility +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 { + bool get isRelaySigned => + author.value?.pubkey == kZapstorePubkey && + identifier != kZapstoreAppIdentifier; - _ToastDescriptionWidget({this.description, this.actions = const []}); + /// Returns PackageInfo if installed, otherwise null + PackageInfo? get installedPackage => + ref.read(packageManagerProvider.notifier).getInfo(identifier); - @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 the app is installed on the device + bool get isInstalled => + ref.read(packageManagerProvider.notifier).isInstalled(identifier); -BelongsTo belongsTo>(Map? map) { - return map != null ? BelongsTo.fromJson(map) : BelongsTo(); -} + /// Latest file metadata associated to the latest release + /// Note: assumes latest metadata has been loaded for the current platform + FileMetadata? get latestFileMetadata => + latestRelease.value?.latestMetadata.value; -HasMany hasMany>(Map? map) { - return map != null ? HasMany.fromJson(map) : HasMany(); -} + /// 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; -final emojiParser = EmojiParser(); - -extension StringWidget on String { - String parseEmojis() { - return replaceAllMapped(RegExp(':([a-z]*):'), (m) { - return emojiParser.hasName(m[1]!) ? emojiParser.get(m[1]!).code : m[0]!; - }); - } - - 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 AppsExt on Iterable { + Future loadMetadata({bool withAuthors = true}) async { + if (isEmpty) return; + final ref = first.ref; + + final releases = await ref.storage.query( + Request( + map( + (app) => app.latestRelease.req?.filters.firstOrNull, + ).nonNulls.toList(), + ), + ); + + if (releases.isEmpty) return; + + await ref.storage.query( + Request( + releases + .map((r) => r.latestMetadata.req?.filters.firstOrNull) + .nonNulls + .toList(), + ), + source: const LocalAndRemoteSource(stream: false, background: true), + ); + + if (withAuthors) { + await ref.storage.query( + Request( + map( + (a) => a.author.req?.filters.firstOrNull, + ).nonNulls.toSet().toList(), + ), + source: const LocalAndRemoteSource( + relays: 'vertex', + stream: false, + background: true, + ), + ); + } + } +} + +extension WidgetRefExt on WidgetRef { + Ref get ref => read(Provider((ref) => ref)); } 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/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..d9c590f 100644 --- a/lib/widgets/app_card.dart +++ b/lib/widgets/app_card.dart @@ -1,253 +1,457 @@ -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:skeletonizer/skeletonizer.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/app.dart'; +import 'package:background_downloader/background_downloader.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/services/download_service.dart'; +import 'package:zapstore/services/profile_service.dart'; +import 'package:zapstore/widgets/zap_widgets.dart'; + +import 'author_container.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 Profile? author; + final bool isLoading; + final bool showUpdateArrow; + final bool showSignedBy; + final bool showUpdateButton; + final bool showZapEncouragement; - 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.author, + this.isLoading = false, + this.showUpdateArrow = false, + this.showSignedBy = true, + this.showUpdateButton = false, + this.showZapEncouragement = false, }); @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); + } + + // Use provided author, or try relationship, or fallback query + final relationshipAuthor = app!.author.value; + + // Fallback: Query author profile directly if no author provided and relationship doesn't work + final authorPubkey = app!.event.pubkey; + final needsFallbackQuery = author == null && relationshipAuthor == null; + final authorProfileAsync = needsFallbackQuery + ? ref.watch(profileProvider(authorPubkey)) + : null; + final fallbackAuthor = authorProfileAsync?.value; + + // Use the working author (provided, relationship, or fallback) + final actualAuthor = author ?? relationshipAuthor ?? fallbackAuthor; + + return GestureDetector( + onTap: () { + final segments = GoRouterState.of(context).uri.pathSegments; + final first = segments.isNotEmpty ? segments.first : 'search'; + context.push('/$first/app/${app!.id}', extra: app!); + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + 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: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // App Icon (64px, radius 15 to match old design) + _buildAppIcon(context), + + const SizedBox(width: 18), + + // App Details with professional typography + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // App Name and Version Row + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + app!.name ?? app!.identifier, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + letterSpacing: 0.1, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + const SizedBox(width: 12), + VersionPillWidget( + app: app!, + showUpdateArrow: showUpdateArrow, + ), + ], + ), + + const SizedBox(height: 12), + + // App Description with improved readability + Text( + app!.description.isNotEmpty + ? app!.description + : 'No description available', + style: context.textTheme.bodyMedium?.copyWith( + height: 1.5, + color: AppColors.darkOnSurfaceSecondary, + ), + overflow: TextOverflow.ellipsis, + maxLines: 3, + softWrap: true, + ), + + // Author signature (only if app has publisher) + if (showSignedBy && actualAuthor != null) ...[ + Gap(14), + Theme( + data: Theme.of(context).copyWith( + textTheme: context.textTheme.copyWith( + bodySmall: context.textTheme.bodySmall?.copyWith( + color: AppColors.darkOnSurfaceSecondary, + ), + ), + ), + child: AuthorContainer( + profile: actualAuthor, + beforeText: 'Published by', + size: context.textTheme.bodyMedium!.fontSize!, + oneLine: true, + app: app, + ), + ), + ], + + // Update button (for apps with updates or currently downloading/installing) + if (showUpdateButton) ...[ + Builder( + builder: (context) { + final downloadInfo = ref.watch( + downloadInfoProvider(app!.identifier), + ); + final hasDownload = downloadInfo != null; + final shouldShow = app!.hasUpdate || hasDownload; + + if (!shouldShow) return const SizedBox.shrink(); + + return Column( + children: [ + Gap(12), + SizedBox( + height: 38, + child: _CompactInstallButton( + app: app!, + release: app!.latestRelease.value, + ), + ), + ], + ); + }, + ), + ], + + // Zap encouragement (only for downloading/installing developer-signed apps) + if (showZapEncouragement) ...[ + Builder( + builder: (context) { + final downloadInfo = ref.watch( + downloadInfoProvider(app!.identifier), + ); + + // Show during download OR installation (including paused) + final isActive = + downloadInfo != null && + (downloadInfo.isInstalling || + downloadInfo.status == TaskStatus.running || + downloadInfo.status == TaskStatus.enqueued || + downloadInfo.status == TaskStatus.paused || + downloadInfo.status == + TaskStatus.waitingToRetry); + + final actualAuthor = author ?? app!.author.value; + final hasLud16 = + actualAuthor?.lud16?.trim().isNotEmpty ?? false; + final canZap = actualAuthor != null && hasLud16; + final shouldShow = + isActive && !app!.isRelaySigned && canZap; + + if (!shouldShow) return const SizedBox.shrink(); + + return Column( + children: [ + Gap(12), + _ZapEncouragementInCard( + app: app!, + author: actualAuthor, + ), + ], + ); + }, + ), + ], + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildAppIcon(BuildContext context) { + final iconUrl = app!.icons.isNotEmpty ? app!.icons.first : null; + + return Container( + width: 58, + height: 58, + 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 _buildSkeleton(BuildContext context) { + return SkeletonizerConfig( + data: AppColors.getSkeletonizerConfig(Theme.of(context).brightness), + child: Skeletonizer( + enabled: true, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + 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: Row( + children: [ + // App icon skeleton + Container( + width: 58, + height: 58, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(14), + ), + ), + const SizedBox(width: 18), + // App details skeleton + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // App name skeleton + Row( + children: [ + Container( + height: 24, + width: 150, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + const Spacer(), + Container( + height: 20, + width: 60, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(10), + ), + ), + ], + ), + const SizedBox(height: 12), + // Description skeleton + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 16, + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 4), + Container( + height: 16, + width: 200, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + // Author skeleton (only if showSignedBy is true) + if (showSignedBy) ...[ + const SizedBox(height: 12), + Row( + children: [ + CircleAvatar( + radius: 12, + backgroundColor: AppColors.darkSkeletonBase, + ), + const SizedBox(width: 8), + Container( + height: 16, + width: 120, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +/// 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), + SizedBox( + height: 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: const Text( + 'Zap', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + ), + ), + ], + ), + ), ); } } 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_curation_pickers.dart b/lib/widgets/app_curation_pickers.dart new file mode 100644 index 0000000..95b5008 --- /dev/null +++ b/lib/widgets/app_curation_pickers.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/widgets/profiles_rich_text.dart'; + +class AppPickersContainer extends StatelessWidget { + const AppPickersContainer({super.key, required this.app, this.author}); + + final App app; + final Profile? author; + + @override + Widget build(BuildContext context) { + final appPacks = app.appPacks.toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (appPacks.isNotEmpty) ...[ + _AppPackPickers(appPacks: appPacks), + const SizedBox(height: 16), + ], + ], + ); + } +} + +class _AppPackPickers extends StatelessWidget { + const _AppPackPickers({required this.appPacks}); + + final List appPacks; + + @override + Widget build(BuildContext context) { + final seenPubkeys = {}; + final curatorProfiles = appPacks + .map((pack) => pack.author.value) + .where((profile) => profile != null && seenPubkeys.add(profile.pubkey)) + .cast() + .toList(); + + if (curatorProfiles.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(8), + ), + child: ProfilesRichText( + profiles: curatorProfiles, + trailingText: ' picked this app', + maxProfilesToDisplay: 3, + avatarRadius: 12, + textStyle: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.grey[600]), + ), + ); + } +} diff --git a/lib/widgets/app_detail_widgets.dart b/lib/widgets/app_detail_widgets.dart new file mode 100644 index 0000000..2c1c718 --- /dev/null +++ b/lib/widgets/app_detail_widgets.dart @@ -0,0 +1,892 @@ +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:share_plus/share_plus.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:zapstore/services/bookmarks_service.dart'; +import 'package:zapstore/services/download_service.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/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)), + ); + } +} + +/// Row of social action buttons (zap, bookmark, app pack, 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 bookmarks from centralized provider (handles decryption) + final bookmarksAsync = ref.watch(bookmarksProvider); + final bookmarkedIds = bookmarksAsync.when( + data: (ids) => ids, + loading: () => {}, + error: (_, __) => {}, + ); + + // Check if this app is bookmarked + final appAddressableId = + '${app.event.kind}:${app.pubkey}:${app.identifier}'; + final isPrivatelySaved = bookmarkedIds.contains(appAddressableId); + + // Query public packs for the dialog + // TODO: Why are app packs queried remotely here too? + final publicPacksState = isSignedIn + ? ref.watch( + query( + authors: {signedInPubkey}, + and: (pack) => {pack.apps}, + source: const LocalAndRemoteSource( + relays: 'social', + stream: true, + ), + andSource: const LocalSource(), + subscriptionPrefix: 'user-packs', + ), + ) + : null; + + final allPacks = publicPacksState?.models ?? []; + + // Split packs for dialog (exclude the bookmark pack) + final publicPacks = allPacks + .where((pack) => pack.identifier != kAppBookmarksIdentifier) + .toList(); + + // Query zaps for zappers list + final latestMetadata = app.latestFileMetadata; + final zapsState = latestMetadata != null + ? ref.watch( + query( + tags: app.event.addressableIdTagMap, + source: LocalAndRemoteSource(relays: 'social'), + and: (zap) => {zap.author}, + andSource: LocalAndRemoteSource(relays: 'social', stream: false), + subscriptionPrefix: 'app-zaps', + ), + ) + : null; + + final zapsOnMetadataState = latestMetadata != null + ? ref.watch( + query( + tags: { + '#e': {latestMetadata.id}, + }, + and: (zap) => {zap.author}, + source: LocalAndRemoteSource(relays: 'social'), + andSource: LocalAndRemoteSource(relays: 'social', stream: false), + subscriptionPrefix: 'metadata-zaps', + ), + ) + : null; + + final zaps = zapsState != null && zapsOnMetadataState != null + ? {...zapsState.models, ...zapsOnMetadataState.models} + : {}; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Zap + Bookmark + App Pack + Share button row (50% / 16% / 16% / 16% split) + SizedBox( + height: 36, + child: Row( + children: [ + // Zap button (50%) + Expanded( + flex: 50, + child: ZapButton(app: app, author: author), + ), + const SizedBox(width: 8), + // Bookmark button (16%) + Expanded( + flex: 16, + child: FilledButton( + onPressed: () { + if (isSignedIn) { + _showBookmarkDialog(context, ref, app, isPrivatelySaved); + } else { + _showSignInPrompt(context, ref); + } + }, + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + backgroundColor: isPrivatelySaved && isSignedIn + ? (Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF4A6BA0) // Deep blue + : const Color(0xFF1E4D8B)) // Darker blue + : Theme.of(context).colorScheme.surfaceContainerHighest, + 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 Pack button (16%) + Expanded( + flex: 16, + child: FilledButton( + onPressed: () { + if (isSignedIn) { + _showAddToPackDialog(context, ref, app, publicPacks); + } else { + _showSignInPrompt(context, ref); + } + }, + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + backgroundColor: + Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF3A6FCC) // Theme dark primary + : const Color(0xFF2563A8), // Muted blue + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.apps, size: 20), + ), + ), + const SizedBox(width: 8), + // Share button (16%) + Expanded( + flex: 16, + child: FilledButton( + onPressed: () => _shareApp(app), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + backgroundColor: + Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF2D5A8F) // Navy blue + : const Color(0xFF1A4673), // Darker navy + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.share, size: 20), + ), + ), + ], + ), + ), + + // Zappers list - only show for apps with zaps + if (zaps.isNotEmpty && !app.isRelaySigned) + Padding( + padding: const EdgeInsets.only(top: 8), + child: ZappersHorizontalList(zaps: zaps.toList()), + ), + ], + ); + } + + Future _showBookmarkDialog( + BuildContext context, + WidgetRef ref, + App app, + bool isPrivatelySaved, + ) async { + await showBaseDialog( + context: context, + dialog: BookmarkDialog(app: app, isPrivatelySaved: isPrivatelySaved), + ); + } + + Future _showAddToPackDialog( + BuildContext context, + WidgetRef ref, + App app, + List publicPacks, + ) async { + await showBaseDialog( + context: context, + dialog: AddToPackDialog(app: app, publicPacks: publicPacks), + ); + } + + void _shareApp(App app) { + try { + // Generate naddr for the app + final naddr = Utils.encodeShareableIdentifier( + AddressInput( + identifier: app.identifier, + author: app.pubkey, + kind: app.event.kind, + relays: [], + ), + ); + final shareUrl = 'https://zapstore.dev/apps/$naddr'; + + // Share using Android's share sheet + SharePlus.instance.share(ShareParams(text: shareUrl)); + } catch (e) { + // Failed to share app + } + } + + Future _showSignInPrompt(BuildContext context, WidgetRef ref) async { + await showBaseDialog(context: context, dialog: const SignInPromptDialog()); + } +} + +/// 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}, + 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 the download service with specific metadata + final downloadService = ref.read(downloadServiceProvider.notifier); + await downloadService.downloadAppWithMetadata( + app.identifier, + app.name ?? app.identifier, + metadata, + ); + } +} diff --git a/lib/widgets/app_header.dart b/lib/widgets/app_header.dart new file mode 100644 index 0000000..674da55 --- /dev/null +++ b/lib/widgets/app_header.dart @@ -0,0 +1,90 @@ +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'; + +class AppHeader extends StatelessWidget { + const AppHeader({super.key, required this.app}); + + final App app; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + ), + child: app.icons.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(16), + child: CachedNetworkImage( + imageUrl: app.icons.first, + 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( + fontWeight: FontWeight.w900, + letterSpacing: 0.1, + fontSize: + (Theme.of( + context, + ).textTheme.headlineSmall?.fontSize ?? + 24) * + 1.08, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + minFontSize: 16, + ), + const SizedBox(height: 16), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + 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..c0b5a11 --- /dev/null +++ b/lib/widgets/app_info_table.dart @@ -0,0 +1,315 @@ +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: _abbr(full), copyValue: full), + ); + } + + if (fileMetadata?.apkSignatureHash != null) { + final full = fileMetadata!.apkSignatureHash!; + rows.add( + _InfoRow( + label: 'Certificate hash', + value: _abbr(full), + 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; + } + + 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 _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_pack_container.dart b/lib/widgets/app_pack_container.dart new file mode 100644 index 0000000..eb97522 --- /dev/null +++ b/lib/widgets/app_pack_container.dart @@ -0,0 +1,499 @@ +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 'pill_widget.dart'; +import 'rounded_image.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:auto_size_text/auto_size_text.dart'; +import '../utils/extensions.dart'; +import '../services/profile_service.dart'; +import '../theme.dart'; + +/// App Pack Container +/// Shows horizontally scrollable pills for different app packs +/// Each pill represents a curated collection of apps (e.g. "Nostr by franzap", "Utilities by franzap") +/// When a pill is tapped, it shows the apps from that pack below +/// Design: Horizontal scroll with colored pills, selected state changes pill color +/// Usage: Featured at top of search screen, allows users to browse curated app collections +class AppPackContainer extends HookConsumerWidget { + const AppPackContainer({super.key, this.showSkeleton = false}); + + final bool showSkeleton; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scrollController = useScrollController(); + + // If showing skeleton, return skeleton state immediately + if (showSkeleton) { + return _buildLoadingState(context); + } + + final appPacksState = ref.watch( + query( + limit: 20, + and: (pack) => {pack.apps}, + source: const LocalAndRemoteSource( + stream: true, + background: true, + relays: 'social', // Load from social relay group + ), + // App relationships should come from default/zapstore relay + andSource: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + background: true, + ), + subscriptionPrefix: 'app-pack', + ), + ); + + return switch (appPacksState) { + StorageLoading() => _buildLoadingState(context), + StorageError(:final exception) => _buildErrorState(context, exception), + StorageData(:final models) when models.isEmpty => const SizedBox.shrink(), + StorageData(:final models) => _buildLoadedState( + context, + ref, + models, + scrollController, + ), + }; + } + + static Widget _buildLoadingState(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Skeleton pills + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: SkeletonizerConfig( + data: AppColors.getSkeletonizerConfig( + Theme.of(context).brightness, + ), + child: Skeletonizer( + enabled: true, + child: Row( + children: List.generate( + 3, + (index) => Container( + width: 120, + height: 32, + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ), + ), + ), + ), + + // Skeleton grid + const SizedBox(height: 24), + _buildSelectedAppsGrid(context, []), // Empty list shows skeletons + ], + ); + } + + Widget _buildErrorState(BuildContext context, Object exception) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: PillWidget( + const TextSpan(text: 'Error loading collections'), + color: Colors.red[700]!, + ), + ); + } + + Widget _buildLoadedState( + BuildContext context, + WidgetRef ref, + List appPacks, + ScrollController scrollController, + ) { + // Filter out empty app packs, packs where apps don't have basic data, + // and the private bookmarks pack + final nonEmptyPacks = appPacks.where((pack) { + // Exclude private bookmarks pack + if (pack.identifier == kAppBookmarksIdentifier) { + return false; + } + // Check if pack has at least one app with name or identifier + final hasValidApps = pack.apps.toList().any( + (app) => app.name != null || app.identifier.isNotEmpty, + ); + return hasValidApps; + }).toList(); + + // If no packs with apps, show loading state + if (nonEmptyPacks.isEmpty) { + return _buildLoadingState(context); + } + + // Default to Nostr pack, or first available pack + final defaultSelection = () { + final nostrPack = nonEmptyPacks + .where((pack) => pack.id == kNostrCurationSetShareableId) + .firstOrNull; + return nostrPack?.id ?? nonEmptyPacks.first.id; + }(); + + final selectedAppPack = useState(defaultSelection); + + // Find the selected app pack + final selectedPack = nonEmptyPacks + .where((pack) => pack.id == selectedAppPack.value) + .firstOrNull; + + // Controller for the horizontal apps grid + final gridController = useScrollController(); + // Rebuild when the grid scrolls to adjust left padding dynamically + useListenable(gridController); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: scrollController, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Row( + children: [ + _buildPackPills( + context, + ref, + nonEmptyPacks, + selectedAppPack.value, + (id) => selectedAppPack.value = id, + ), + ], + ), + ), + ), + + // Show apps from selected pack with professional spacing + if (selectedPack != null) ...[ + const SizedBox(height: 16), + _buildSelectedAppsGrid( + context, + // Filter apps that have basic data (name or identifier) + selectedPack.apps + .toList() + .where((app) => app.name != null || app.identifier.isNotEmpty) + .toList(), + controller: gridController, + padding: EdgeInsets.only( + left: gridController.hasClients && gridController.offset > 0 + ? 0 + : 12, + right: 12, + ), + ), + ], + ], + ); + } + + Widget _buildPackPills( + BuildContext context, + WidgetRef ref, + List appPacks, + String? selectedId, + Function(String?) onSelect, + ) { + // Sort app packs: Nostr pack first, then others + final sortedPacks = [...appPacks]; + sortedPacks.sort((a, b) { + // Nostr pack goes first + if (a.id == kNostrCurationSetShareableId) return -1; + if (b.id == kNostrCurationSetShareableId) return 1; + // Others by creation date (newest first) + return b.event.createdAt.compareTo(a.event.createdAt); + }); + + return Row( + children: sortedPacks.map((appPack) { + final isSelected = selectedId == appPack.id; + // Load author via profileProvider with caching + final authorAsync = ref.watch(profileProvider(appPack.event.pubkey)); + final author = authorAsync.value; + + return Padding( + padding: const EdgeInsets.only(right: 16), + child: GestureDetector( + onTap: () => onSelect(appPack.id), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: isSelected + ? Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.15) + : Theme.of(context).colorScheme.surfaceContainerHighest + .withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(18), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + appPack.name ?? appPack.identifier, + style: context.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (author != null) ...[ + Text( + ' by ', + style: context.textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + _InlineAuthorWidget(author: author, isSelected: isSelected), + ], + ], + ), + ), + ), + ); + }).toList(), + ); + } + + static Widget _buildSelectedAppsGrid( + BuildContext context, + List apps, { + ScrollController? controller, + EdgeInsets? padding, + }) { + return SizedBox( + height: 220, // Slightly reduced from 240 to remove extra space + child: GridView.builder( + controller: controller, + padding: padding ?? const EdgeInsets.symmetric(horizontal: 12.0), + scrollDirection: Axis.horizontal, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.8, // Made smaller (less tall) + crossAxisSpacing: 2, + mainAxisSpacing: 2, + ), + itemCount: apps.isEmpty ? 6 : apps.length, + itemBuilder: (context, index) { + if (apps.isEmpty) { + return const _AppGridCard(isLoading: true); + } + return _AppGridCard(app: apps[index]); + }, + ), + ); + } +} + +class _AppGridCard extends ConsumerWidget { + const _AppGridCard({this.app, this.isLoading = false}); + + final App? app; + final bool isLoading; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (isLoading || app == null) { + return _buildSkeleton(context); + } + + return GestureDetector( + onTap: () { + // Trigger loading of latestRelease relationship if not available (async, no wait) + if (app!.latestRelease.value == null) { + final request = app!.latestRelease.req; + if (request != null) { + ref.storage + .query( + request, + source: const LocalAndRemoteSource(stream: false), + ) + .catchError((e) { + // Failed to load latest release + return []; + }); + } + } + + // Navigate immediately with the app (detail screen will handle loading states) + final segments = GoRouterState.of(context).uri.pathSegments; + final first = segments.isNotEmpty ? segments.first : 'search'; + context.push('/$first/app/${app!.id}', extra: app!); + }, + child: Container( + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.8), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Smaller app icon to make room for text + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: app!.icons.isNotEmpty + ? CachedNetworkImage( + imageUrl: app!.icons.first, + fit: BoxFit.cover, + fadeInDuration: const Duration(milliseconds: 500), + fadeOutDuration: const Duration(milliseconds: 200), + placeholder: (_, url) => const SizedBox.shrink(), + errorWidget: (_, error, stackTrace) => Center( + child: Icon( + Icons.broken_image_outlined, + size: 16, + color: Colors.grey[400], + ), + ), + ) + : Center( + child: Icon( + Icons.apps_outlined, + size: 16, + color: Colors.grey[400], + ), + ), + ), + ), + const SizedBox(height: 12), + + // Auto-sizing app name to prevent cutoff + AutoSizeText( + app!.name ?? app!.identifier, + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + height: 1.2, + ), + maxLines: 1, + minFontSize: 10, + maxFontSize: 12, + textAlign: TextAlign.center, + overflow: TextOverflow.fade, + softWrap: false, + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildSkeleton(BuildContext context) { + return Padding( + padding: const EdgeInsets.all( + 14, + ), // Slightly more padding to center within grid cell + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // App icon skeleton - simple rounded square + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(8), + ), + ), + const SizedBox(height: 8), + + // App name skeleton - single centered bar + Container( + height: 10, + width: 40, + decoration: BoxDecoration( + color: AppColors.darkSkeletonBase, + borderRadius: BorderRadius.circular(5), + ), + ), + ], + ), + ); + } +} + +/// Inline author widget for showing profile in pills +class _InlineAuthorWidget extends StatelessWidget { + const _InlineAuthorWidget({required this.author, this.isSelected = false}); + + final Profile author; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.3), + width: 1, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: RoundedImage(url: author.pictureUrl, size: 18), + ), + ), + const SizedBox(width: 6), + Text( + author.nameOrNpub, + style: context.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); + } +} diff --git a/lib/widgets/auth_widgets.dart b/lib/widgets/auth_widgets.dart new file mode 100644 index 0000000..c9ce3b7 --- /dev/null +++ b/lib/widgets/auth_widgets.dart @@ -0,0 +1,70 @@ +import 'package:async_button_builder/async_button_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:zapstore/main.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/widgets/common/base_dialog.dart'; + +/// Dialog prompting user to sign in for bookmark features +class SignInPromptDialog extends HookConsumerWidget { + const SignInPromptDialog({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseDialog( + titleIcon: const Icon(Icons.bookmark), + title: Text( + 'Sign in to bookmark', + style: Theme.of(context).textTheme.headlineSmall, + ), + content: BaseDialogContent( + children: [ + const SizedBox(height: 8), + Text( + 'Sign in to bookmark apps and create app packs.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + actions: [ + BaseDialogAction( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + AsyncButtonBuilder( + onPressed: () async { + try { + await ref.read(amberSignerProvider).signIn(); + onSignInSuccess(ref); + if (context.mounted) { + Navigator.pop(context); + } + } catch (e) { + if (context.mounted) { + context.showError('Sign-in failed: $e'); + } + } + }, + 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.login, size: 18), + ), + label: const Text('Sign in'), + ); + }, + child: const SizedBox.shrink(), + ), + ], + ); + } +} 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..ee6789d --- /dev/null +++ b/lib/widgets/bookmark_widgets.dart @@ -0,0 +1,556 @@ +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/common/base_dialog.dart'; + +/// Dialog for bookmarking an app privately (encrypted) +class BookmarkDialog extends HookConsumerWidget { + const BookmarkDialog({ + super.key, + required this.app, + required this.isPrivatelySaved, + }); + + final App app; + final bool isPrivatelySaved; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseDialog( + titleIcon: const Icon(Icons.bookmark), + title: Text('Bookmark', style: Theme.of(context).textTheme.headlineSmall), + content: BaseDialogContent( + children: [ + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.lock, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Private bookmark', + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + ], + ), + 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), + ), + ), + ], + ), + 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: 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 bookmark' : 'Bookmark'), + ); + }, + 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('You must be signed in to save apps'); + } + return; + } + + // Query for existing pack + final existingPackState = await ref.storage.query( + RequestFilter( + authors: {signedInPubkey}, + tags: { + '#d': {kAppBookmarksIdentifier}, + }, + ).toRequest(), + source: const LocalSource(), + ); + final existingPack = existingPackState.firstOrNull; + + // Get existing app IDs by decrypting if pack exists + List existingAppIds = []; + if (existingPack != null) { + try { + final decryptedContent = await signer.nip44Decrypt( + existingPack.content, + signedInPubkey, + ); + existingAppIds = (jsonDecode(decryptedContent) as List) + .cast(); + } catch (e) { + // Failed to decrypt existing pack + } + } + + // 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 pack with updated list + final partialPack = PartialAppPack.withEncryptedApps( + name: 'Bookmarks', + identifier: kAppBookmarksIdentifier, + apps: existingAppIds, + ); + + // Sign (encrypts the content) + final signedPack = await partialPack.signWith(signer); + + // Save to local storage and publish to relays + await ref.storage.save({signedPack}); + ref.storage.publish({signedPack}, source: RemoteSource(relays: 'social')); + + if (context.mounted) { + Navigator.pop(context); + context.showInfo(isCurrentlySaved ? 'Bookmark removed' : 'Bookmarked'); + } + } catch (e) { + if (context.mounted) { + context.showError('Failed to update bookmark: $e'); + } + } + } +} + +/// Dialog for managing app packs (public collections) +class AddToPackDialog extends HookConsumerWidget { + const AddToPackDialog({ + super.key, + required this.app, + required this.publicPacks, + }); + + final App app; + final List publicPacks; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final newPackNameController = useTextEditingController(); + + // Check which packs contain this app + final selectedCollections = useState>({}); + // Track new packs with their names (identifier -> name) + final newPackNames = useState>({}); + + useEffect(() { + final selected = {}; + for (final pack in publicPacks) { + if (pack.apps.toList().any((appModel) => appModel.id == app.id)) { + selected.add(pack.identifier); + } + } + selectedCollections.value = selected; + return null; + }, [app.id, publicPacks.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( + 'Manage App Packs', + style: Theme.of(context).textTheme.headlineSmall, + ), + content: BaseDialogContent( + children: [ + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.public, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Add or remove ${app.name} from public app packs', + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Show existing packs and newly created packs as selectable chips + if (publicPacks.isNotEmpty || newPackNames.value.isNotEmpty) ...[ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + // Existing packs + ...publicPacks.map((pack) { + final isSelected = selectedCollections.value.contains( + pack.identifier, + ); + final displayName = pack.name ?? pack.identifier; + return FilterChip( + label: Text(displayName), + selected: isSelected, + onSelected: (selected) { + if (selected) { + selectedCollections.value = { + ...selectedCollections.value, + pack.identifier, + }; + } else { + selectedCollections.value = { + ...selectedCollections.value, + }..remove(pack.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 packs not yet in publicPacks + ...newPackNames.value.entries + .where( + (entry) => !publicPacks.any( + (pack) => pack.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 newPackNames if deselected + final updatedNames = Map.from( + newPackNames.value, + )..remove(identifier); + newPackNames.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 pack + TextField( + controller: newPackNameController, + maxLength: 20, + decoration: const InputDecoration( + labelText: 'Add to new app pack', + 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, + }; + newPackNames.value = {...newPackNames.value, identifier: name}; + newPackNameController.clear(); + } + }, + ), + ], + ), + actions: [ + BaseDialogAction( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + AsyncButtonBuilder( + onPressed: () async { + // Check if there's a pending new pack name in the text field + final pendingPackName = newPackNameController.text.trim(); + if (pendingPackName.isNotEmpty) { + final identifier = slugify(pendingPackName); + if (identifier.isNotEmpty && + !selectedCollections.value.contains(identifier)) { + // Add the pending pack to the collections + selectedCollections.value = { + ...selectedCollections.value, + identifier, + }; + newPackNames.value = { + ...newPackNames.value, + identifier: pendingPackName, + }; + } + } + + // Now save with the updated collections + await _savePublicCollections( + context, + ref, + app, + publicPacks, + selectedCollections.value, + newPackNames.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 existingPacks, + Set selectedCollectionIds, + Map newPackNames, + ) async { + try { + final signer = ref.read(Signer.activeSignerProvider); + if (signer == null) { + if (context.mounted) { + context.showError('You must be signed in'); + } + return; + } + + // Get current state of which collections contain this app + final currentCollectionsWithApp = {}; + for (final pack in existingPacks) { + // Get raw app IDs from event tags + final appIds = pack.event.getTagSetValues('a'); + if (appIds.contains(app.id)) { + currentCollectionsWithApp.add(pack.identifier); + } + } + + // Collections to remove from + final collectionsToRemoveFrom = currentCollectionsWithApp.difference( + selectedCollectionIds, + ); + + // Update or create collections + for (final collectionId in selectedCollectionIds) { + final existingPack = existingPacks + .where((pack) => pack.identifier == collectionId) + .firstOrNull; + + // Use the provided name from newPackNames, or existing pack name, or identifier + final packName = + newPackNames[collectionId] ?? existingPack?.name ?? collectionId; + + // Get existing app IDs as a list to preserve order + final existingAppIds = + existingPack?.event.getTagSetValues('a').toList() ?? []; + + // Check if app already exists in the pack + final appAlreadyExists = existingAppIds.contains(app.id); + + // Only update if the app is not already in the pack + if (!appAlreadyExists) { + final partialPack = PartialAppPack( + name: packName, + identifier: collectionId, + ); + + // Add existing apps in order + for (final appId in existingAppIds) { + partialPack.addApp(appId); + } + + // Add this app at the end + partialPack.addApp(app.id); + + final signedPack = await partialPack.signWith(signer); + await ref.storage.save({signedPack}); + await ref.storage.publish({ + signedPack, + }, source: RemoteSource(relays: 'social')); + } + } + + // Remove from deselected collections + for (final collectionId in collectionsToRemoveFrom) { + final existingPack = existingPacks + .where((pack) => pack.identifier == collectionId) + .firstOrNull; + if (existingPack != null) { + final partialPack = PartialAppPack( + name: existingPack.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 = existingPack.event + .getTagSetValues('a') + .toList(); + for (final appId in existingAppIds) { + if (appId != app.id) { + partialPack.addApp(appId); + } + } + + final signedPack = await partialPack.signWith(signer); + await ref.storage.save({signedPack}); + await ref.storage.publish({ + signedPack, + }, source: RemoteSource(relays: 'social')); + } + } + + if (context.mounted) { + if (selectedCollectionIds.isEmpty) { + context.showInfo('Removed from all app packs'); + } else { + context.showInfo('App packs updated'); + } + Navigator.pop(context); + } + } catch (e) { + if (context.mounted) { + context.showError('Failed to save: $e'); + } + } + } +} + diff --git a/lib/widgets/comments_section.dart b/lib/widgets/comments_section.dart new file mode 100644 index 0000000..7e303e2 --- /dev/null +++ b/lib/widgets/comments_section.dart @@ -0,0 +1,449 @@ +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:async_button_builder/async_button_builder.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/common/profile_avatar.dart'; +import 'package:intl/intl.dart'; +import 'package:zapstore/widgets/sign_in_button.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/widgets/pill_widget.dart'; +import 'package:zapstore/theme.dart'; + +class CommentsSection extends ConsumerWidget { + const CommentsSection({super.key, this.fileMetadata}); + + final FileMetadata? fileMetadata; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (fileMetadata == null) { + return const SizedBox.shrink(); + } + + final app = fileMetadata!.release.value?.app.value; + if (app == null) { + return const SizedBox.shrink(); + } + + final commentsState = ref.watch( + query( + tags: { + '#A': {app.id}, + }, + source: LocalAndRemoteSource( + stream: true, + background: true, + relays: 'social', + ), + and: (comment) => {comment.author}, + subscriptionPrefix: 'app-comments', + ), + ); + + return switch (commentsState) { + StorageData(:final models) => Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Comments', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + if (models.isNotEmpty) + _CommentCountBadge( + count: models.length, + ), + ], + ), + const SizedBox(height: 12), + _CommentsSection( + comments: models, + fileMetadata: fileMetadata!, + ), + ], + ), + ), + StorageError(:final exception) => Padding( + padding: const EdgeInsets.all(16), + child: _buildCommentsError( + context, + exception, + ), + ), + _ => const SizedBox.shrink(), + }; + } + + 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 _CommentsSection extends HookConsumerWidget { + const _CommentsSection({required this.comments, required this.fileMetadata}); + + final List comments; + final FileMetadata fileMetadata; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Sort comments by creation date (newest first) + final sortedComments = comments.toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + final hasComments = sortedComments.isNotEmpty; + + return Column( + children: [ + _AddCommentButton(fileMetadata: fileMetadata), + if (hasComments) ...[ + const SizedBox(height: 16), + ...sortedComments.map( + (comment) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _CommentCard(comment: comment), + ), + ), + ], + ], + ); + } +} + +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) { + final author = comment.author.value; + // Extract version from comment tags + final version = comment.event.getFirstTagValue('version'); + + 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( + fontWeight: FontWeight.w600, + ), + ), + 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) { + final signer = ref.watch(Signer.activeSignerProvider); + + if (signer == null) { + return SizedBox( + width: double.infinity, + child: Theme( + data: Theme.of(context).copyWith( + filledButtonTheme: FilledButtonThemeData( + 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), + ), + ), + ), + ), + child: SignInButton(label: 'Sign in to comment', minimal: false), + ), + ); + } + + return SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () => _showCommentComposer(context), + icon: const Icon(Icons.add_comment), + label: 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 _CommentComposer extends HookConsumerWidget { + const _CommentComposer({required this.fileMetadata}); + + final FileMetadata fileMetadata; + + @override + Widget build(BuildContext context, WidgetRef ref) { + 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?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 16), + 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: Text('Post Comment'), + onPressed: () => + _publishComment(ref, textController.text, context), + builder: (context, child, callback, buttonState) { + return FilledButton( + onPressed: 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('Please sign in to comment'); + } + return; + } + + final app = fileMetadata.release.value?.app.value; + if (app == null) { + if (context.mounted) { + context.showError('App information not available'); + } + 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, + parentModel: app, + ); + + // Add version tag with the installed or latest version + comment.event.addTagValue('version', versionToComment); + + final signedComment = await comment.signWith(signer); + + await signedComment.save(); + await signedComment.publish(source: RemoteSource(relays: 'social')); + + if (context.mounted) { + Navigator.pop(context); + context.showInfo('Comment posted!'); + } + } catch (e) { + if (context.mounted) { + context.showError('Failed to post comment: $e'); + } + } + } +} 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..a857864 --- /dev/null +++ b/lib/widgets/common/note_parser.dart @@ -0,0 +1,1228 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:zapstore/utils/extensions.dart'; +import '../../../theme.dart'; +import 'package:zapstore/services/profile_service.dart'; +import 'package:zapstore/widgets/common/time_utils.dart'; +import 'package:zapstore/widgets/common/profile_avatar.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'; + +/// 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 RichText(text: 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, + onProfileTap: onProfileTap, + onHashtagTap: onHashtagTap, + ), + 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 profileAsync = ref.watch(profileProvider(profileData.pubkey)); + + // Show animated npub while profile is being loaded + if (profileAsync.isLoading || (!profileAsync.hasValue)) { + return GestureDetector( + onTap: onProfileTap != null + ? () => onProfileTap!(profileData.pubkey) + : null, + child: _AnimatedLoadingChip( + text: 'npub1${profileData.pubkey.substring(0, 8)}...', + colorPair: colorPair, + ), + ); + } + + final profile = profileAsync.value; + final displayName = + profile?.nameOrNpub ?? '${profileData.pubkey.substring(0, 8)}...'; + + 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 ConsumerWidget { + final EventData eventData; + final List colorPair; + final void Function(String pubkey)? onProfileTap; + final void Function(String hashtag)? onHashtagTap; + + const EventEntityWidget({ + super.key, + required this.eventData, + required this.colorPair, + this.onProfileTap, + this.onHashtagTap, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final noteState = ref.watch( + query( + ids: {eventData.eventId}, + limit: 1, + and: (note) => {note.author}, + // Profile relationships should come from social relays + andSource: const LocalAndRemoteSource(relays: 'social'), + subscriptionPrefix: 'entity-note', + ), + ); + + final note = noteState.models.firstOrNull; + + if (note == null) { + 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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Skeleton for author info + SkeletonizerConfig( + data: AppColors.getSkeletonizerConfig( + Theme.of(context).brightness, + ), + child: Skeletonizer( + enabled: true, + child: Row( + children: [ + Container( + width: 24, + height: 24, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8.0), + Expanded( + child: Container( + width: 100, + height: 13, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + Container( + width: 40, + height: 11, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8.0), + + // Skeleton for note content + SkeletonizerConfig( + data: AppColors.getSkeletonizerConfig( + Theme.of(context).brightness, + ), + child: Skeletonizer( + enabled: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + height: 14, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 4), + Container( + width: MediaQuery.of(context).size.width * 0.7, + height: 14, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + 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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: onProfileTap != null + ? () => onProfileTap!(note.event.pubkey) + : null, + child: Row( + children: [ + ProfileAvatar( + profile: note.author.value, + radius: 12, + borderColors: colorPair, + ), + const SizedBox(width: 8.0), + Expanded( + child: note.author.value?.nameOrNpub != null + ? Text( + note.author.value!.nameOrNpub, + style: context.textTheme.bodyMedium!.copyWith( + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ) + : _AnimatedLoadingChip( + text: + 'npub1${note.event.pubkey.substring(0, 8)}...', + colorPair: colorPair, + ), + ), + TimeAgoText( + note.createdAt, + style: Theme.of( + context, + ).textTheme.bodySmall!.copyWith(fontSize: 11), + ), + ], + ), + ), + const SizedBox(height: 8.0), + ParsedContentWidget( + note: note, + colorPair: colorPair, + onProfileTap: onProfileTap, + onHashtagTap: onHashtagTap, + ), + ], + ), + ), + ); + } +} + +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 ParsedContentWidget extends ConsumerWidget { + final Note note; + final List colorPair; + final void Function(String pubkey)? onProfileTap; + final void Function(String hashtag)? onHashtagTap; + + const ParsedContentWidget({ + super.key, + required this.note, + required this.colorPair, + this.onProfileTap, + this.onHashtagTap, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Reply context - show if this note is a reply + if (note.replyTo.value != null) ...[ + ReplyContextWidget( + replyingNote: note, + colorPair: colorPair, + onProfileTap: onProfileTap, + onHashtagTap: onHashtagTap, + ), + const SizedBox(height: 8), + ], + + // Original note content + _buildNoteContent(context), + ], + ); + } + + Widget _buildNoteContent(BuildContext context) { + if (note.content.trim().isEmpty) { + return Text( + 'No content', + style: context.textTheme.bodyMedium!.copyWith( + fontStyle: FontStyle.italic, + color: Colors.grey[500], + ), + ); + } + + return NoteParser.parse( + context, + note.content, + textStyle: context.textTheme.bodyMedium, + linkStyle: context.textTheme.bodyMedium!.copyWith( + color: colorPair[0], + decoration: TextDecoration.underline, + ), + onNostrEntity: (entity) => NostrEntityWidget( + entity: entity, + colorPair: colorPair, + onProfileTap: onProfileTap, + onHashtagTap: onHashtagTap, + ), + onHttpUrl: (url) => UrlChipWidget(url: url, colorPair: colorPair), + onMediaUrl: (url) => MediaWidget(url: url, colorPair: colorPair), + onHashtagTap: onHashtagTap, + onProfileTap: onProfileTap, + ); + } +} + +/// Internal widget for reply context - now part of the note parser +class ReplyContextWidget extends ConsumerWidget { + final Note replyingNote; + final List colorPair; + final void Function(String pubkey)? onProfileTap; + final void Function(String hashtag)? onHashtagTap; + + const ReplyContextWidget({ + super.key, + required this.replyingNote, + required this.colorPair, + this.onProfileTap, + this.onHashtagTap, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final replyToNote = replyingNote.replyTo.value; + + if (replyToNote == null) { + return const SizedBox.shrink(); + } + + // Load the author for the replyTo note + final authorAsync = ref.watch(profileProvider(replyToNote.event.pubkey)); + final author = authorAsync.value; + final isAuthorLoading = authorAsync.isLoading || !authorAsync.hasValue; + + // Check if the reply-to note itself is still being loaded + final replyToNoteState = ref.watch( + query( + ids: {replyToNote.event.id}, + limit: 1, + subscriptionPrefix: 'reply-note', + ), + ); + + final isNoteLoading = replyToNoteState is StorageLoading; + + return Container( + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8.0), + border: Border.all( + color: colorPair[0].withValues(alpha: 0.2), + width: 1.0, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // "Replying to" indicator + Row( + children: [ + Icon( + Icons.reply, + size: 14, + color: colorPair[0].withValues(alpha: 0.7), + ), + const SizedBox(width: 4), + Text( + 'Replying to', + style: context.textTheme.bodySmall?.copyWith( + color: colorPair[0].withValues(alpha: 0.7), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 8), + + // Parent note preview + Container( + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(6.0), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Parent note author + GestureDetector( + onTap: onProfileTap != null + ? () => onProfileTap!(replyToNote.event.pubkey) + : null, + child: Row( + children: [ + ProfileAvatar( + profile: author, + radius: 10, + borderColors: colorPair, + ), + const SizedBox(width: 6), + Expanded( + child: isAuthorLoading || isNoteLoading + ? _AnimatedLoadingChip( + text: + 'npub1${replyToNote.event.pubkey.substring(0, 8)}...', + colorPair: colorPair, + ) + : Text( + author?.nameOrNpub ?? + '${replyToNote.event.pubkey.substring(0, 8)}...', + style: context.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 12, + ), + ), + ), + TimeAgoText( + replyToNote.createdAt, + style: context.textTheme.bodySmall?.copyWith( + fontSize: 11, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + const SizedBox(height: 6), + + // Parent note content with proper loading state handling + isNoteLoading || isAuthorLoading + ? Skeletonizer( + enabled: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + height: 12, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 4), + Container( + width: MediaQuery.of(context).size.width * 0.6, + height: 12, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ) + : replyToNote.content.trim().isEmpty + ? Text( + 'No content', + style: context.textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), + fontSize: 12, + ), + ) + : _buildFullContent(context, replyToNote.content), + ], + ), + ), + ], + ), + ); + } + + Widget _buildFullContent(BuildContext context, String content) { + return NoteParser.parse( + context, + content, + textStyle: context.textTheme.bodySmall?.copyWith(fontSize: 12), + linkStyle: context.textTheme.bodySmall?.copyWith( + color: colorPair[0], + decoration: TextDecoration.underline, + fontSize: 12, + ), + onNostrEntity: (entity) => NostrEntityWidget( + entity: entity, + colorPair: colorPair, + onProfileTap: onProfileTap, + onHashtagTap: onHashtagTap, + ), + onHttpUrl: (url) => UrlChipWidget(url: url, colorPair: colorPair), + onMediaUrl: (url) => MediaWidget(url: url, colorPair: colorPair), + onHashtagTap: onHashtagTap, + onProfileTap: onProfileTap, + ); + } +} + +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) { + return CachedNetworkImage( + imageUrl: url, + 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..0777d30 --- /dev/null +++ b/lib/widgets/common/profile_avatar.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:models/models.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 = profile?.pictureUrl; + + Widget avatar = ClipOval( + child: pictureUrl != null && pictureUrl.isNotEmpty + ? 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) => const SizedBox.shrink(), + errorWidget: (context, url, error) => Center( + child: Icon( + Icons.person_outline, + color: AppColors.darkSkeletonHighlight, + size: radius * 1.3, + ), + ), + ) + : SizedBox( + width: radius * 2, + height: radius * 2, + child: Center( + child: Icon( + Icons.person_outline, + color: AppColors.darkSkeletonHighlight, + size: radius * 1.3, + ), + ), + ), + ); + + 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/date_pill_widget.dart b/lib/widgets/date_pill_widget.dart new file mode 100644 index 0000000..68a5646 --- /dev/null +++ b/lib/widgets/date_pill_widget.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:zapstore/utils/extensions.dart'; + +/// Date pill widget showing a formatted date in pill format +class DatePillWidget extends StatelessWidget { + final DateTime date; + + const DatePillWidget({super.key, required this.date}); + + @override + Widget build(BuildContext context) { + final formattedDate = DateFormat('MMM d, y').format(date); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + formattedDate, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ), + ), + ); + } +} 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..42d3aee 100644 --- a/lib/widgets/install_alert_dialog.dart +++ b/lib/widgets/install_alert_dialog.dart @@ -2,59 +2,77 @@ 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 publisher = app.author.value; + if (publisher == null) { + return const SizedBox.shrink(); + } final trustedSignerNotifier = useState(false); - final signedInUser = ref.watch(signedInUserProvider); + final profile = ref.watch(Signer.activeProfileProvider(LocalSource())); + final theme = Theme.of(context); + final baseTextSize = theme.textTheme.bodyMedium?.fontSize ?? 14.0; - return AlertDialog( - elevation: 10, - title: Text( - 'Are you sure you want to install ${app.name}?', - style: TextStyle(fontWeight: FontWeight.bold), - ), - 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), + 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. ', + 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 ...[ + AuthorContainer( + profile: publisher, + beforeText: '${app.name ?? app.identifier} is published by', + afterText: '.', + oneLine: false, + size: baseTextSize, + ), + Gap(14), + profile != null + ? RelevantWhoFollowContainer( + toNpub: publisher.npub, + trailingText: + ' and others follow ${publisher.nameOrNpub} on Nostr.\n\nThis information helps you determine ${publisher.nameOrNpub}\'s reputation. They are not endorsements of the ${app.name} app.', + size: baseTextSize, + ) + : SignInButton( + label: + 'Sign in to view the publisher\'s reputable followers', + minimal: true, + requireNip55: true, + ), + Gap(14), Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Switch( value: trustedSignerNotifier.value, @@ -64,37 +82,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..0f3dc39 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -1,154 +1,723 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.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:zapstore/utils/extensions.dart'; -import 'package:zapstore/utils/system_info.dart'; -import 'package:zapstore/widgets/install_alert_dialog.dart'; -import 'package:zapstore/widgets/spinning_logo.dart'; +import 'dart:async'; -class InstallButton extends HookConsumerWidget { - InstallButton({ +import 'package:async_button_builder/async_button_builder.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/services/download_service.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/services/trust_service.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/common/base_dialog.dart'; +import 'package:zapstore/widgets/install_alert_dialog.dart'; +import 'package:zapstore/widgets/install_button_state.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/theme.dart'; + +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 downloadInfo = ref.watch(downloadInfoProvider(app.identifier)); + final installedPackage = ref + .watch(packageManagerProvider) + .where((p) => p.appId == app.identifier) + .firstOrNull; - 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]); + // Determine current state from inputs using the extracted function + final state = determineInstallButtonState( + app: app, + installedPackage: installedPackage, + downloadInfo: downloadInfo, + release: release, + formatTotalSizeMb: _formatTotalSizeMb, + ); - 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), + // Compact mode - just the button without positioning or extra actions + if (compact) { + return _buildButtonForState(context, ref, state); + } + + // Regular mode - positioned at bottom with extra 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: _buildButtonForState(context, ref, state), ), - 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, - ), - ), - } - }, + ), + // Show action buttons only for installed apps + if (state is InstalledUpToDate || + state is UpdateAvailable || + state is DowngradeBlocked) ...[ + if (state is UpdateAvailable) ...[ + const SizedBox(width: 8), + _buildOpenIconButton(context, ref), + ], + const SizedBox(width: 8), + _buildUninstallIconButton(context, ref), + ], + ], + ), + ), ), ); } -} -final kUpdateColor = Colors.blue[700]!; + /// Builds the appropriate button widget based on the current state + /// Uses pattern matching on sealed class for exhaustive handling + Widget _buildButtonForState( + BuildContext context, + WidgetRef ref, + InstallButtonState state, + ) { + final fontSize = compact ? 13.0 : 16.0; + + return switch (state) { + // Not installed, ready to install + ReadyToInstall(:final hasRelease) => _buildAsyncButton( + context, + ref, + text: 'Install', + onPressed: hasRelease ? () => _startDownload(ref) : null, + isPrimary: true, + fontSize: fontSize, + needsTrustCheck: true, + ), + + // Installed and up to date + InstalledUpToDate() => _buildAsyncButton( + context, + ref, + text: 'Open', + onPressed: () => _openApp(context, ref), + isPrimary: true, + fontSize: fontSize, + needsTrustCheck: false, + ), + + // Update available + UpdateAvailable(:final hasRelease) => _buildAsyncButton( + context, + ref, + text: 'Update', + onPressed: hasRelease ? () => _startDownload(ref) : null, + isPrimary: true, + fontSize: fontSize, + needsTrustCheck: false, + ), + + // Downgrade blocked + DowngradeBlocked() => _buildSimpleButton( + context, + "Can't downgrade", + null, + isPrimary: false, + showSpinner: false, + fontSize: fontSize, + isDowngrade: true, + ), + + // Download in progress + Downloading(:final progress, :final totalSizeMb) => _buildProgressButton( + context, + ref, + progress: progress, + text: _formatDownloadProgress(progress, totalSizeMb), + fontSize: fontSize, + onTap: () => _pauseDownload(ref), + ), + + // Download paused + DownloadPaused(:final progress, :final totalSizeMb) => + _buildProgressButton( + context, + ref, + progress: progress, + text: _formatDownloadProgress(progress, totalSizeMb, paused: true), + fontSize: fontSize, + onTap: () => _resumeDownload(ref), + ), + + // Download enqueued + DownloadEnqueued(:final isUpdate) => _buildSimpleButton( + context, + isUpdate ? 'Update' : 'Install', + () => _cancelAndRestart(ref), + isPrimary: true, + fontSize: fontSize, + ), + + // Downloaded, ready to install + DownloadedReadyToInstall(:final isUpdate) => AsyncButtonBuilder( + onPressed: () => _installFromDownloaded(ref), + builder: (context, child, callback, buttonState) { + return _buildSimpleButton( + context, + buttonState.maybeWhen( + loading: () => 'Installing...', + orElse: () => isUpdate ? 'Update' : 'Install', + ), + buttonState.maybeWhen( + loading: () => null, + orElse: () => callback, + ), + isPrimary: true, + showSpinner: buttonState.maybeWhen( + loading: () => true, + orElse: () => false, + ), + fontSize: fontSize, + ); + }, + child: Text( + isUpdate ? 'Update' : 'Install', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + onError: () { + if (context.mounted) { + context.showError('Installation failed. Please try again.'); + } + }, + ), + + // Installing + Installing() => _buildSimpleButton( + context, + 'Requesting installation', + null, + isPrimary: true, + showSpinner: true, + fontSize: fontSize, + ), + + // Failed + Failed(:final canRetryReckless, :final downloadInfo) => + _buildSimpleButton( + context, + 'Error (tap for details)', + () => _handleErrorTap(ref, context, downloadInfo, canRetryReckless), + isPrimary: false, + isError: true, + fontSize: fontSize, + ), + }; + } + + /// Helper to format download progress text + String _formatDownloadProgress( + double progress, + String? totalSizeMb, { + bool paused = false, + }) { + final percent = (progress * 100).round(); + final pausedSuffix = paused ? ' (paused)' : ''; + return totalSizeMb != null + ? '$percent% of $totalSizeMb$pausedSuffix' + : '$percent%$pausedSuffix'; + } + + /// Builds async button with trust check for fresh installs + Widget _buildAsyncButton( + BuildContext context, + WidgetRef ref, { + required String text, + required Future Function()? onPressed, + required bool isPrimary, + required double fontSize, + required bool needsTrustCheck, + }) { + // Store error message for display + String? lastError; + + return AsyncButtonBuilder( + onPressed: onPressed == null + ? null + : () async { + // Trust check only for fresh installs + if (needsTrustCheck) { + 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; + final result = await showBaseDialog<({bool trustPermanently})>( + context: context, + dialog: InstallAlertDialog(app: app), + ); + if (result == null) return; + if (result.trustPermanently && signerPubkey != null) { + try { + await ref + .read(trustServiceProvider) + .addTrustedSigner(signerPubkey); + } catch (_) { + // ignore persistence errors + } + } + } + } + try { + await onPressed(); + } catch (e) { + // Store the error message for display + lastError = e.toString(); + rethrow; + } + }, + builder: (context, child, callback, buttonState) { + return _buildSimpleButton( + context, + buttonState.maybeWhen( + loading: () => text == 'Open' ? 'Launching...' : 'Starting...', + orElse: () => text, + ), + buttonState.maybeWhen(loading: () => null, orElse: () => callback), + isPrimary: isPrimary, + showSpinner: false, + fontSize: fontSize, + ); + }, + child: Text( + text, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + onError: () { + if (context.mounted) { + // Show the actual error message if available + final message = lastError != null + ? lastError!.replaceFirst('Exception: ', '') + : 'Operation failed. Please try again.'; + context.showError(message); + } + }, + ); + } + + 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', + ); + } + + // Removed: update logic moved to AppExt + + 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', + ); + } + + 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 FilledButton( + onPressed: onTap ?? () => _cancelDownload(ref), + style: FilledButton.styleFrom( + backgroundColor: Colors.transparent, + disabledBackgroundColor: Colors.transparent, + disabledForegroundColor: Colors.white, + foregroundColor: Colors.white, + elevation: 0, + shadowColor: Colors.transparent, + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + children: [ + Positioned.fill(child: Container(color: actionColor)), + Positioned.fill( + child: Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: progress, + heightFactor: 1.0, + child: Container(color: darkerAction), + ), + ), + ), + Center( + child: Text( + text, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildSimpleButton( + BuildContext context, + String text, + VoidCallback? onPressed, { + bool isPrimary = true, + bool isError = false, + bool showSpinner = false, + double fontSize = 16.0, + bool isDowngrade = false, + }) { + final theme = Theme.of(context); + + Widget child = Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: Text( + text, + style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + if (showSpinner) ...[ + const SizedBox(width: 8), + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ], + ], + ); + + if (isError) { + return FilledButton( + onPressed: onPressed, + style: FilledButton.styleFrom( + backgroundColor: theme.colorScheme.error, + disabledBackgroundColor: theme.colorScheme.error, + disabledForegroundColor: Colors.white, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: child, + ); + } else if (isDowngrade) { + // Greyed out disabled button for downgrades (opaque) + final greyColor = theme.colorScheme.outline; + return FilledButton( + onPressed: null, // disabled + style: FilledButton.styleFrom( + backgroundColor: greyColor, + disabledBackgroundColor: greyColor, + disabledForegroundColor: theme.colorScheme.onSurface.withValues( + alpha: 0.5, + ), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: child, + ); + } else { + const actionColor = AppColors.darkActionPrimary; + final backgroundColor = isPrimary + ? actionColor + : actionColor.withValues(alpha: 0.8); + + return FilledButton( + onPressed: onPressed, + style: FilledButton.styleFrom( + backgroundColor: backgroundColor, + disabledBackgroundColor: backgroundColor, + disabledForegroundColor: Colors.white, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: child, + ); + } + } + + Future _startDownload(WidgetRef ref) async { + if (release == null) return; + + final downloadService = ref.read(downloadServiceProvider.notifier); + await downloadService.downloadApp(app, release!); + } + + Future _installFromDownloaded(WidgetRef ref) async { + try { + final downloadService = ref.read(downloadServiceProvider.notifier); + await downloadService.installFromDownloaded(app.identifier); + } catch (_) { + rethrow; + } + } + + Future _resumeDownload(WidgetRef ref) async { + final downloadService = ref.read(downloadServiceProvider.notifier); + await downloadService.resumeDownload(app.identifier); + } + + Future _pauseDownload(WidgetRef ref) async { + try { + final downloadService = ref.read(downloadServiceProvider.notifier); + await downloadService.pauseDownload(app.identifier); + } catch (e) { + // Failed to pause download + } + } + + Future _cancelDownload(WidgetRef ref) async { + try { + final downloadService = ref.read(downloadServiceProvider.notifier); + await downloadService.cancelDownload(app.identifier); + } catch (e) { + // Failed to cancel download + } + } + + Future _openApp(BuildContext context, WidgetRef ref) async { + try { + final packageManager = ref.read(packageManagerProvider.notifier); + context.showInfo('Launching ${app.name ?? app.identifier}...'); + await packageManager.launchApp(app.identifier); + } catch (e) { + if (!context.mounted) return; + context.showError('Failed to launch ${app.name ?? app.identifier}: $e'); + } + } + + Future _cancelAndRestart(WidgetRef ref) async { + final downloadService = ref.read(downloadServiceProvider.notifier); + await downloadService.cancelDownload(app.identifier); + } + + String? _formatTotalSizeMb(Release? release) { + try { + final sizeBytes = app.latestFileMetadata?.size; + if (sizeBytes == null || sizeBytes <= 0) return null; + final mb = sizeBytes / (1024 * 1024); + return '${mb.toStringAsFixed(1)} MB'; + } catch (_) { + return null; + } + } + + void _handleErrorTap( + WidgetRef ref, + BuildContext context, + DownloadInfo downloadInfo, + bool canRetryReckless, + ) { + final errorMessage = + downloadInfo.errorDetails ?? 'Download failed. Please try again.'; + + context.showError( + errorMessage, + actions: canRetryReckless + ? [ + ( + '⚠️ Proceed anyway (RECKLESS)', + () async { + // Retry installation with skipVerification=true + final filePath = await downloadInfo.task.filePath(); + final packageManager = ref.read( + packageManagerProvider.notifier, + ); + + // Update download state to show installing + final downloadService = ref.read( + downloadServiceProvider.notifier, + ); + downloadService.markInstalling(app.identifier); + + try { + await packageManager.install( + app.identifier, + filePath, + expectedHash: downloadInfo.fileMetadata.hash, + expectedSize: downloadInfo.fileMetadata.size ?? 0, + skipVerification: true, // SKIP VERIFICATION + ); + } catch (e) { + if (context.mounted) { + context.showError('Installation failed: $e'); + } + } + }, + ), + ] + : [], + ); + + // Only cancel if user doesn't have the option to proceed + // For hash errors, keep the download so user can proceed + if (!canRetryReckless) { + final downloadService = ref.read(downloadServiceProvider.notifier); + downloadService.cancelDownload(app.identifier); + } + } + + Future _uninstallApp(WidgetRef ref, BuildContext context) async { + // Special handling for Zapstore app itself + if (app.identifier == 'dev.zapstore.alpha') { + final shouldProceed = await _showZapstoreUninstallDialog(context); + if (!shouldProceed) return; + } + + try { + final packageManager = ref.read(packageManagerProvider.notifier); + await packageManager.uninstall(app.identifier); + if (context.mounted) { + _monitorUninstallCompletion(ref, context); + } + } catch (e) { + if (context.mounted) { + context.showError('Uninstall failed: $e'); + } + } + } + + Future _showZapstoreUninstallDialog(BuildContext context) async { + final result = await showBaseDialog( + context: context, + dialog: Builder( + builder: (dialogContext) => BaseDialog( + title: const BaseDialogTitle('Uninstall Zapstore'), + content: const BaseDialogContent( + children: [ + Text( + 'This will uninstall Zapstore from your device.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 12), + Text( + 'To download the previous stable Zapstore version, visit:', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 8), + Text( + 'zapstore.dev/download', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ], + ), + 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, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + child: const Text('Uninstall'), + ), + ], + ), + ), + ); + + return result ?? false; + } + + void _monitorUninstallCompletion(WidgetRef ref, BuildContext context) { + bool notificationShown = false; + late final ProviderSubscription> subscription; + + // Listen to package manager changes instead of polling + subscription = ref.listenManual(packageManagerProvider, (previous, next) { + final isStillInstalled = next.any((p) => p.appId == app.identifier); + + // Only show notification once when app becomes uninstalled + if (!isStillInstalled && !notificationShown && context.mounted) { + notificationShown = true; + context.showInfo('${app.name ?? app.identifier} has been uninstalled'); + // Close subscription after showing notification + subscription.close(); + } + }); + + // Timeout after 30 seconds + Future.delayed(const Duration(seconds: 30), () { + if (!notificationShown) { + subscription.close(); + } + }); + } +} diff --git a/lib/widgets/install_button_state.dart b/lib/widgets/install_button_state.dart new file mode 100644 index 0000000..9e3c2d8 --- /dev/null +++ b/lib/widgets/install_button_state.dart @@ -0,0 +1,162 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/services/download_service.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/utils/extensions.dart'; + +/// Represents all possible button states in the install flow. +/// This state machine ensures UI consistency and prevents invalid states. +sealed class InstallButtonState { + const InstallButtonState(); +} + +/// App is not installed, ready to install +class ReadyToInstall extends InstallButtonState { + final bool hasRelease; + const ReadyToInstall({required this.hasRelease}); +} + +/// App is installed and up to date, can be opened +class InstalledUpToDate extends InstallButtonState { + const InstalledUpToDate(); +} + +/// App is installed, update available +class UpdateAvailable extends InstallButtonState { + final bool hasRelease; + const UpdateAvailable({required this.hasRelease}); +} + +/// App is installed, but relay has older version (downgrade not allowed) +class DowngradeBlocked extends InstallButtonState { + const DowngradeBlocked(); +} + +/// Download is in progress +class Downloading extends InstallButtonState { + final double progress; + final String? totalSizeMb; + const Downloading({required this.progress, this.totalSizeMb}); +} + +/// Download is paused +class DownloadPaused extends InstallButtonState { + final double progress; + final String? totalSizeMb; + const DownloadPaused({required this.progress, this.totalSizeMb}); +} + +/// Download is enqueued or waiting to retry +class DownloadEnqueued extends InstallButtonState { + final bool isUpdate; + const DownloadEnqueued({required this.isUpdate}); +} + +/// Download completed, ready to install from file +class DownloadedReadyToInstall extends InstallButtonState { + final bool isUpdate; + const DownloadedReadyToInstall({required this.isUpdate}); +} + +/// Installation is in progress +class Installing extends InstallButtonState { + const Installing(); +} + +/// Download or installation failed +class Failed extends InstallButtonState { + final String errorMessage; + final bool canRetryReckless; + final DownloadInfo downloadInfo; + const Failed({ + required this.errorMessage, + required this.canRetryReckless, + required this.downloadInfo, + }); +} + +/// Determines the current button state from app, download info, and release data. +/// This is the single source of truth for button state logic. +InstallButtonState determineInstallButtonState({ + required App app, + required PackageInfo? installedPackage, + required DownloadInfo? downloadInfo, + required Release? release, + required String? Function(Release?) formatTotalSizeMb, +}) { + final isInstalled = installedPackage != null; + final hasUpdate = app.hasUpdate; + final hasDowngrade = app.hasDowngrade; + final hasRelease = release != null; + + // PRIORITY 1: Check active download states first + if (downloadInfo != null) { + // Ready to install from downloaded file + if (downloadInfo.isReadyToInstall) { + return DownloadedReadyToInstall(isUpdate: isInstalled && hasUpdate); + } + + // Installation in progress + if (downloadInfo.isInstalling) { + return const Installing(); + } + + // Check task status + switch (downloadInfo.status) { + case TaskStatus.running: + return Downloading( + progress: downloadInfo.progress, + totalSizeMb: formatTotalSizeMb(release), + ); + + case TaskStatus.paused: + return DownloadPaused( + progress: downloadInfo.progress, + totalSizeMb: formatTotalSizeMb(release), + ); + + case TaskStatus.enqueued: + case TaskStatus.waitingToRetry: + return DownloadEnqueued(isUpdate: isInstalled && hasUpdate); + + case TaskStatus.complete: + return DownloadedReadyToInstall(isUpdate: isInstalled && hasUpdate); + + case TaskStatus.failed: + final errorMessage = + downloadInfo.errorDetails ?? 'Download failed. Please try again.'; + final isHashError = + errorMessage.contains('Hash verification failed') && + !errorMessage.contains('Invalid APK file'); + return Failed( + errorMessage: errorMessage, + canRetryReckless: isHashError, + downloadInfo: downloadInfo, + ); + + case TaskStatus.canceled: + case TaskStatus.notFound: + break; + } + } + + // PRIORITY 2: Check installation state (no active download) + if (isInstalled) { + // Downgrade case + if (hasDowngrade) { + return const DowngradeBlocked(); + } + + // Update available + if (hasUpdate) { + return UpdateAvailable(hasRelease: hasRelease); + } + + // Up to date + return const InstalledUpToDate(); + } + + // PRIORITY 3: Not installed, no active download + return ReadyToInstall(hasRelease: hasRelease); +} + diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index 2cc9083..85db97e 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -1,144 +1,435 @@ -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: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:async_button_builder/async_button_builder.dart'; +import 'package:zapstore/services/profile_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); + } + + final state = ref.watch(latestReleasesProvider); + final storage = state.storage; + + // Combine live storage models (newest) with paged older apps + final combinedApps = [...storage.models, ...state.olderApps]; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Latest Releases', style: context.textTheme.headlineSmall), + if (storage.models.isNotEmpty) + TextButton( + onPressed: () { + scrollController.animateTo( + scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, + child: const Text('See more'), + ), + ], + ), + ), + + const SizedBox(height: 12), + + 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, + ref, + combinedApps, + state.isLoadingMore, + state.hasMore, + ref.read(latestReleasesProvider.notifier).loadMore, + ), + ], + ); + } + + 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, + ), + ); + } + + Widget _buildAppsList( + BuildContext context, + WidgetRef ref, + List apps, + bool isLoadingMore, + bool hasMoreApps, + Future Function() onLoadMore, + ) { + return Column( + children: [ + ...apps.map((app) { + final releaseAuthor = app.latestRelease.value?.author.value; + return AppCard( + app: app, + author: releaseAuthor, + showUpdateArrow: app.hasUpdate, + ); + }), + + const SizedBox(height: 10), + + if (hasMoreApps) + AsyncButtonBuilder( + loadingWidget: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 3, + color: Theme.of(context).colorScheme.onSurface, + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + ), + onPressed: isLoadingMore ? null : onLoadMore, + builder: (context, child, callback, state) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: callback, + 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), ), ), - ); - }, - child: Text('Load more'), + child: child, + ), + ), + ); + }, + child: Text(isLoadingMore ? 'Loading...' : 'Load more'), + ) + else + 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), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Latest Releases', style: context.textTheme.headlineMedium), + TextButton( + onPressed: null, // Disabled during loading + child: const Text('See more'), + ), + ], + ), + ), + const SizedBox(height: 12), + // 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, + // Load nested FileMetadata from latestRelease (same relay group) + if (app.latestRelease.value != null) + app.latestRelease.value!.latestMetadata, + }, + // NOTE: It must stream=true + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: true, + background: 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); + + final newApps = next.models; + + // For new apps, one-off fetch their relationships + // TODO: Not needed - should be dealt with by and/andSource + if (newApps.isNotEmpty) { + await _fetchRelationshipsForNewApps(newApps); + } + } else { + state = state.copyWith(storage: next); + } + + if (next is StorageError) { + state = state.copyWith(isLoadingMore: false); + } + }, + fireImmediately: true, + ); + } + + /// One-off fetch of Release and FileMetadata for newly received apps + Future _fetchRelationshipsForNewApps(List newApps) async { + if (newApps.isEmpty) return; + + // One-off fetch: Get releases for these apps from remote + final releases = await ref.storage.query( + Request( + newApps + .map((app) => app.latestRelease.req?.filters.firstOrNull) + .nonNulls + .toList(), + ), + source: const RemoteSource(stream: false), + ); + + // One-off fetch: Get file metadata for the releases from remote + if (releases.isNotEmpty) { + await ref.storage.query( + Request( + releases + .map((r) => r.latestMetadata.req?.filters.firstOrNull) + .nonNulls + .toList(), + ), + source: const RemoteSource(stream: false), + ); + } + + // One-off fetch: Get release author profiles from 'social' relay group + final authorPubkeys = releases.map((r) => r.event.pubkey).toSet(); + if (authorPubkeys.isNotEmpty) { + await ref.read(profileServiceProvider).fetchProfiles(authorPubkeys); + } + } + + /// Fetch authors for a page of apps (used during pagination) + Future _loadRelationshipsFor(List appsPage) async { + final releases = appsPage + .map((a) => a.latestRelease.value) + .whereType() + .toList(); + + if (releases.isEmpty) return; + + // Only need to query release authors from 'social' group (different relay group) + final authorPubkeys = releases.map((r) => r.event.pubkey).toSet(); + if (authorPubkeys.isNotEmpty) { + await ref.read(profileServiceProvider).fetchProfiles(authorPubkeys); + } + } + + 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, background: 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 + if (releases.isNotEmpty) { + await ref.storage.query( + Request( + releases + .map((r) => r.latestMetadata.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 (_) { + state = state.copyWith(isLoadingMore: false); + } + } + + @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..6fca570 --- /dev/null +++ b/lib/widgets/nwc_widgets.dart @@ -0,0 +1,385 @@ +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/theme.dart'; +import 'package:zapstore/utils/extensions.dart'; + +/// NWC connection status states +enum NWCStatus { notSignedIn, checking, connected, disconnected, error } + +/// Card showing NWC connection status and management +class NWCConnectionCard extends HookConsumerWidget { + const NWCConnectionCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final signer = ref.watch(Signer.activeSignerProvider); + final pubkey = ref.watch(Signer.activePubkeyProvider); + final isSignedIn = pubkey != null; + + // Reactive NWC state via CustomData keyed by Signer.kNwcConnectionString + final nwcState = pubkey == null + ? null + : ref.watch( + query( + authors: {pubkey}, + tags: { + '#d': {Signer.kNwcConnectionString}, + }, + limit: 1, + source: const LocalSource(), + subscriptionPrefix: 'nwc-data', + ), + ); + + final NWCStatus nwcStatus = () { + if (signer == null || pubkey == null) return NWCStatus.notSignedIn; + final state = nwcState; + if (state == null) return NWCStatus.checking; + return switch (state) { + StorageLoading() => NWCStatus.checking, + StorageError() => NWCStatus.error, + StorageData(:final models) => + (models.isNotEmpty && models.first.content.trim().isNotEmpty) + ? NWCStatus.connected + : NWCStatus.disconnected, + }; + }(); + + 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?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 12), + + Text( + isSignedIn + ? 'Use Nostr Wallet Connect (NWC) to zap the developers.' + : 'Connect a Lightning wallet to send zaps. Sign in required.', + style: context.textTheme.bodySmall, + ), + + const SizedBox(height: 12), + + // Connection Status + switch (nwcStatus) { + NWCStatus.notSignedIn => 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('Sign in to connect wallet')), + ], + ), + NWCStatus.checking => 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...'), + ], + ), + + NWCStatus.connected => 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')), + ], + ), + NWCStatus.disconnected => 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')), + ], + ), + NWCStatus.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 + if (isSignedIn) + Row( + children: [ + Expanded( + child: TextButton.icon( + onPressed: nwcStatus != NWCStatus.notSignedIn + ? () => _showNWCDialog(context, ref, signer) + : null, + icon: Icon( + Icons.flash_on, + size: 18, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Theme.of(context).colorScheme.primary, + ), + label: Text( + nwcStatus == NWCStatus.connected + ? 'Update Connection' + : 'Connect Wallet', + ), + style: TextButton.styleFrom( + backgroundColor: AppColors.darkPillBackground, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + ), + ), + ), + if (nwcStatus == NWCStatus.connected) ...[ + const SizedBox(width: 8), + FilledButton( + onPressed: () => + _removeNWCConnection(context, ref, signer), + 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, Signer? signer) { + if (signer == null) return; + + showDialog( + context: context, + builder: (context) => NWCConnectionDialog(signer: signer), + ); + } + + void _removeNWCConnection( + BuildContext context, + WidgetRef ref, + Signer? signer, + ) async { + if (signer == null) return; + + 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 { + await signer.setNWCString(''); + if (context.mounted) { + context.showInfo('Wallet connection removed'); + } + } catch (e) { + if (context.mounted) { + context.showError('Failed to remove connection: $e'); + } + } + } + } +} + +/// Dialog for connecting/updating NWC connection +class NWCConnectionDialog extends HookWidget { + final Signer signer; + + const NWCConnectionDialog({super.key, required this.signer}); + + @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('Please enter a valid NWC connection string'); + return; + } + + if (!nwcString.trim().startsWith('nostr+walletconnect://')) { + context.showError( + 'Invalid NWC format. Should start with nostr+walletconnect://', + ); + return; + } + + isLoading.value = true; + + try { + await signer.setNWCString(nwcString.trim()); + + if (context.mounted) { + Navigator.pop(context); + context.showInfo('⚡ Lightning wallet connected successfully!'); + } + } catch (e) { + if (context.mounted) { + context.showError('Connection failed: $e'); + } + } finally { + isLoading.value = false; + } + } +} + diff --git a/lib/widgets/pill_widget.dart b/lib/widgets/pill_widget.dart index f686cac..ef5eba5 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) { @@ -19,7 +26,7 @@ class PillWidget extends StatelessWidget { text: 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..6bbebcc --- /dev/null +++ b/lib/widgets/profile_header.dart @@ -0,0 +1,125 @@ +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?.copyWith(fontWeight: FontWeight.bold), + 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..6405d59 100644 --- a/lib/widgets/relevant_who_follow_container.dart +++ b/lib/widgets/relevant_who_follow_container.dart @@ -1,72 +1,203 @@ +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/profiles_rich_text.dart'; class RelevantWhoFollowContainer extends HookConsumerWidget { const RelevantWhoFollowContainer({ super.key, required this.toNpub, this.loadingText = 'Checking reputation...', + this.trailingText = '', + this.size, }); final String loadingText; + final String trailingText; final String toNpub; + final double? size; @override Widget build(BuildContext context, WidgetRef ref) { - final signedInUser = ref.watch(signedInUserProvider); + 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), + )); + + // Get signed-in user's pubkey and contact list + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + final contactListState = signedInPubkey != null + ? ref.watch( + query( + authors: {signedInPubkey}, + limit: 1, + source: LocalAndRemoteSource(relays: 'social', stream: false), + subscriptionPrefix: 'user-contacts', + ), + ) + : null; + final data = ref.watch(relevantWhoFollowProvider((to: toNpub))); - if (signedInUser == null) { - return Container(); - } - 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) { - return Text( - 'No reputable profiles follow the signer. Make sure you know the signer.'); - } + AsyncData>(value: final users) => Builder( + builder: (context) { + // Find relevant who follow, remove the target that comes included + final relevantWhoFollow = users + .where((u) => u.pubkey.encodeShareable(type: 'npub') != toNpub) + .toList(); - return UsersRichText( - users: relevantWhoFollow, - signedInUser: signedInUser, - onlyUseCommaSeparator: true, - trailingText: ' and others follow this signer on nostr.', + // Check if signed-in user follows the target + final targetPubkey = toNpub.decodeShareable(); + final contactList = contactListState?.models.firstOrNull; + final userFollowsTarget = + contactList?.followingPubkeys.contains(targetPubkey) ?? false; + + if (relevantWhoFollow.isEmpty && !userFollowsTarget) { + return Text( + 'No reputation information found for this publisher.', + style: baseStyle, ); - }, + } + + // If user follows but no Vertex followers, show a simple message + if (relevantWhoFollow.isEmpty && userFollowsTarget) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + text: TextSpan( + text: 'You follow this publisher. ', + style: baseStyle, + children: [TextSpan(text: trailingText)], + ), + ), + const Gap(8), + _buildViewProfileLink(context, toNpub, baseStyle), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!userFollowsTarget) ...[ + Text('You are not following them.', style: baseStyle), + const Gap(8), + ], + ProfilesRichText( + profiles: relevantWhoFollow, + leadingText: userFollowsTarget ? 'You, ' : null, + commasOnly: true, + trailingText: trailingText, + textStyle: baseStyle, + avatarRadius: 10, + ), + const Gap(8), + _buildViewProfileLink(context, toNpub, baseStyle), + ], + ); + }, + ), + AsyncError() => Center( + child: Text( + 'Reputation service is unreachable. Try again later.', + style: baseStyle, ), - AsyncError(:final error) => Center(child: Text(error.toString())), + ), // Loading state _ => Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - Text(loadingText), - Gap(10), - SmallCircularProgressIndicator(), - ], - ), + 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), + ), + ], ), - ) + ), + ), }; } + + Widget _buildViewProfileLink( + BuildContext context, + String npub, + TextStyle? baseStyle, + ) { + return RichText( + text: 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/$npub'); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } + }, + ), + ); + } } 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') + .timeout(Duration(seconds: 6)); + + 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, + background: false, + ), + ); + return profiles; + } + + throw Exception('Bad response from service'); + }); diff --git a/lib/widgets/rounded_image.dart b/lib/widgets/rounded_image.dart index 8aae573..e646622 100644 --- a/lib/widgets/rounded_image.dart +++ b/lib/widgets/rounded_image.dart @@ -1,6 +1,8 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.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, diff --git a/lib/widgets/screenshots_gallery.dart b/lib/widgets/screenshots_gallery.dart new file mode 100644 index 0000000..67a5d53 --- /dev/null +++ b/lib/widgets/screenshots_gallery.dart @@ -0,0 +1,135 @@ +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'; +// 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) { + if (app.images.isEmpty) return const SizedBox.shrink(); + + final imageUrls = app.images.toList(); + 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: true, + 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..d74cc6d --- /dev/null +++ b/lib/widgets/sign_in_button.dart @@ -0,0 +1,88 @@ +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/main.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; + +const _amberPackageId = 'com.greenart7c3.nostrsigner'; +const _amberNaddr = + 'naddr1qqdkxmmd9enhyet9deshyaphvvejumn0wd68yumfvahx2uszyp6hjpmdntls5n8aa7n7ypzlyjrv0ewch33ml3452wtjx0smhl93jqcyqqq8uzcgpp6ky'; + +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 packageManager = ref.watch(packageManagerProvider); + final isAmberInstalled = packageManager.any( + (p) => p.appId == _amberPackageId, + ); + + return AsyncButtonBuilder( + onPressed: () async { + if (!isAmberInstalled) { + context.showInfo('Install Amber to sign in with your Nostr identity'); + context.push('/profile/apps/$_amberNaddr'); + } else { + try { + await ref.read(amberSignerProvider).signIn(); + onSignInSuccess(ref); + } 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 index 860de33..4e8e019 100644 --- a/lib/widgets/spinning_logo.dart +++ b/lib/widgets/spinning_logo.dart @@ -1,49 +1,32 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; -class SpinningLogo extends StatefulWidget { +/// An animated spinning logo widget used for loading states +/// Continuously rotates the Zapstore logo image +class SpinningLogo extends HookWidget { 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) { + final controller = useAnimationController( + duration: const Duration(seconds: 2), + )..repeat(); + return Center( child: AnimatedBuilder( - animation: _controller, + animation: controller, builder: (context, child) { return Transform.rotate( - angle: - _controller.value * 2.0 * 3.14159, // Full rotation in radians + angle: controller.value * 2.0 * 3.14159, child: child, ); }, child: Image.asset( 'assets/images/logo-fg.png', - height: widget.size, - width: widget.size, - ), // Adjust size as needed + height: size, + width: size, + ), ), ); } 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..2c46b54 100644 --- a/lib/widgets/version_pill_widget.dart +++ b/lib/widgets/version_pill_widget.dart @@ -1,82 +1,196 @@ 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 + final installedPackages = ref.watch(packageManagerProvider); + + // 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, + ref, + installedPackages, + 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, + ref, + installedPackages, + version, + AppColors.darkPillBackground, + Colors.white, + ); + } + + Widget _buildDualVersionPills( + BuildContext context, + WidgetRef ref, + List installedPackages, + String installedVersion, + String availableVersion, { + bool isDowngrade = false, + }) { 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]!, - ), - 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, - }, - ), + // Current version pill (muted colors for installed version) + _buildVersionPill( + context, + ref, + installedPackages, + installedVersion, + Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), + Theme.of(context).colorScheme.onSurface, + isInstalledVersion: true, + ), + + // Arrow icon (always arrow, forbidden icon is in the pill itself) + Icon( + Icons.arrow_right, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + + // Available version pill (highlighted for update, greyed for downgrade) + _buildVersionPill( + context, + ref, + installedPackages, + 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, + ), ], ); } + + Widget _buildVersionPill( + BuildContext context, + WidgetRef ref, + List installedPackages, + String version, + Color backgroundColor, + Color textColor, { + bool isInstalledVersion = false, + bool isDowngrade = false, + }) { + // Trim version text to max 8 characters to make room for icon + String displayVersion = _displayVersion(version); + + // 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); + } + } + } + + // Use grey colors for "do not disturb" states (downgrade, certificate mismatch) + final finalBackgroundColor = backgroundColor; + final finalTextColor = textColor; + + final double verticalPadding = isInstalledVersion ? 5.0 * 1.05 : 5.0; + + return Container( + padding: EdgeInsets.symmetric(horizontal: 9, vertical: verticalPadding), + decoration: BoxDecoration( + color: finalBackgroundColor, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + displayVersion, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + color: finalTextColor, + fontSize: 11.5, + ), + ), + if (statusIcon != null) ...[ + const SizedBox(width: 4), + IconTheme.merge( + data: const IconThemeData(size: 12), + child: statusIcon, + ), + ], + ], + ), + ); + } + + String _displayVersion(String version) { + return version.length > 10 ? '${version.substring(0, 9)}...' : version; + } + + // No width enforcement; both pills share the same text style } 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..6d281aa --- /dev/null +++ b/lib/widgets/zap_widgets.dart @@ -0,0 +1,722 @@ +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:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.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/extensions.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}); + + final List zaps; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = theme.textTheme.bodySmall?.color?.withValues(alpha: 0.8); + + // Group sats per zapper (prefer zapRequest.author over wallet author) + final Map satsPerPubkey = {}; + final Map profileByPubkey = {}; + + for (final zap in zaps) { + final requestAuthor = zap.zapRequest.value?.author.value; + final walletAuthor = zap.author.value; + final chosenAuthor = requestAuthor ?? walletAuthor; + final pubkey = chosenAuthor?.pubkey; + if (pubkey == null || chosenAuthor == null) continue; + + satsPerPubkey[pubkey] = (satsPerPubkey[pubkey] ?? 0) + zap.amount; + profileByPubkey[pubkey] = chosenAuthor; + } + + 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( + children: [ + Text( + '⚡️${formatSatsCompact(totalSats)}', + style: theme.textTheme.titleLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 16), + ], + ), + 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(210); + final commentController = useTextEditingController(); + final customAmount = useState(null); + final quickAmounts = useMemoized( + () => [ + (label: '🤙 210', value: 210), + (label: '💜 2100', value: 2100), + (label: '🤩 4200', value: 4200), + (label: '🚀 21k', value: 21000), + (label: '💯 100k', value: 100000), + ], + ); + final pubkey = ref.watch(Signer.activePubkeyProvider); + final signer = ref.watch(Signer.activeSignerProvider); + final hasWalletConnection = useState(false); + useEffect(() { + if (signer == null) { + hasWalletConnection.value = false; + return null; + } + signer + .getNWCString() + .then( + (value) => hasWalletConnection.value = (value?.isNotEmpty == true), + ) + .catchError((_) => hasWalletConnection.value = false); + return null; + }, [signer]); + + 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) ...[ + InkWell( + onTap: () async { + final packageManager = ref.read(packageManagerProvider); + const amberPackageId = 'com.greenart7c3.nostrsigner'; + final isAmberInstalled = packageManager.any( + (p) => p.appId == amberPackageId, + ); + + if (!isAmberInstalled) { + // Close the zap dialog first + if (context.mounted) { + Navigator.of(context).pop(); + } + // Navigate to Amber app page + if (context.mounted) { + context.showInfo( + 'Install Amber to sign in and unlock zapping features', + ); + context.push( + '/profile/apps/naddr1qqdkxmmd9enhyet9deshyaphvvejumn0wd68yumfvahx2uszyp6hjpmdntls5n8aa7n7ypzlyjrv0ewch33ml3452wtjx0smhl93jqcyqqq8uzcgpp6ky', + ); + } + } else { + try { + await ref.read(amberSignerProvider).signIn(); + // After successful sign-in, the dialog will update automatically + // because pubkey will no longer be null + } catch (e) { + if (context.mounted) { + context.showError('Sign-in failed: $e'); + } + } + } + }, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(12), + 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( + 'Tap to sign in. You can zap anonymously or sign in for attribution.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + ], + + if (pubkey != null && !hasWalletConnection.value) ...[ + InkWell( + onTap: signer == null + ? null + : () async { + final connected = await showBaseDialog( + context: context, + dialog: NWCConnectionDialogInline(signer: signer), + ); + if (connected == true) { + hasWalletConnection.value = true; + } + }, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(12), + 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( + 'Tap to connect NWC for zapping, otherwise an invoice will be copied to the clipboard.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ), + ), + 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 { + // Prepare signer (anonymous if needed) + var signer = ref.read(Signer.activeSignerProvider); + if (signer == null) { + signer = Bip340PrivateKeySigner( + kAnonymousPrivateKey, + ref.ref, + ); + await signer.signIn(registerSigner: false); + } + + // Read NWC state (may be empty) + final nwcString = await signer.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) { + if (context.mounted) Navigator.of(context).pop(true); + // Continue payment in background (no await) + // ignore: unawaited_futures + signedZapRequest + .pay() + .then((_) { + if (context.mounted) { + context.showInfo( + '⚡ Zap successful! ${selectedAmount.value} sats sent', + ); + } + }) + .catchError((e) { + if (context.mounted) { + context.showError('Zap failed: $e'); + } + }); + } else { + final invoice = await signedZapRequest.getInvoice(); + if (context.mounted) Navigator.of(context).pop(true); + await Clipboard.setData(ClipboardData(text: invoice)); + if (context.mounted) { + context.showInfo('Invoice copied to clipboard'); + } + } + } catch (e) { + if (context.mounted) { + context.showError('Zap failed: $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(), + ), + ], + ); + } +} + +/// 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 HookWidget { + final Signer signer; + + const NWCConnectionDialogInline({super.key, required this.signer}); + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(); + final isLoading = useState(false); + + 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( + 'Please enter a valid NWC connection string', + ); + return; + } + if (!nwcString.trim().startsWith('nostr+walletconnect://')) { + context.showError( + 'Invalid NWC format. Should start with nostr+walletconnect://', + ); + return; + } + isLoading.value = true; + try { + await signer.setNWCString(nwcString.trim()); + if (context.mounted) { + Navigator.pop(context, true); + context.showInfo( + '⚡ Lightning wallet connected successfully!', + ); + } + } catch (e) { + if (context.mounted) { + context.showError('Connection failed: $e'); + } + } finally { + isLoading.value = false; + } + }, + child: isLoading.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Connect'), + ), + ], + ); + } +} 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/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 57efdf4..4c0025f 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,18 +6,10 @@ #include "generated_plugin_registrant.h" -#include -#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); - flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); - g_autoptr(FlPluginRegistrar) gtk_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); - gtk_plugin_register_with_registrar(gtk_registrar); g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 9137173..ad279a8 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,8 +3,6 @@ # list(APPEND FLUTTER_PLUGIN_LIST - flutter_secure_storage_linux - gtk sqlite3_flutter_libs url_launcher_linux ) 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..8cf035e 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,20 +5,18 @@ import FlutterMacOS import Foundation -import app_links -import device_info_plus -import flutter_secure_storage_macos +import connectivity_plus import path_provider_foundation +import share_plus import shared_preferences_foundation import sqflite_darwin import sqlite3_flutter_libs 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")) 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..7e919c5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,91 +1,47 @@ # 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" - url: "https://pub.dev" - source: hosted - version: "0.7.1" - app_links: + 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: app_links - sha256: "433df2e61b10519407475d7f69e470789d23d593f28224c38ba1068597be7950" + name: any_link_preview + sha256: "0617bd49a58dd0478cd5c4c83bcf7a2d1b7d301aad229817d13693fe86807ea1" url: "https://pub.dev" source: hosted - version: "6.3.3" - app_links_linux: - dependency: transitive - description: - name: app_links_linux - sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 - url: "https://pub.dev" - source: hosted - version: "1.0.3" - app_links_platform_interface: - dependency: transitive - description: - name: app_links_platform_interface - sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" - url: "https://pub.dev" - source: hosted - version: "2.0.2" - app_links_web: - dependency: transitive - description: - name: app_links_web - sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 - url: "https://pub.dev" - source: hosted - version: "1.0.4" + version: "3.0.3" 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 +62,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 +82,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 +98,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 +134,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 +154,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 +162,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 +210,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 +226,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 +251,7 @@ packages: source: hosted version: "0.3.11" equatable: - dependency: transitive + dependency: "direct main" description: name: equatable sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" @@ -362,18 +262,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 +311,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: @@ -440,10 +347,10 @@ packages: dependency: "direct main" description: name: flutter_markdown - sha256: "255b00afa1a7bad19727da6a7780cf3db6c3c12e68d302d85e0ff1fdf173db9e" + sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27" url: "https://pub.dev" source: hosted - version: "0.7.4+3" + version: "0.7.7+1" flutter_phoenix: dependency: "direct main" description: @@ -453,61 +360,21 @@ packages: source: hosted version: "1.1.1" flutter_riverpod: - dependency: transitive + dependency: "direct main" description: name: flutter_riverpod sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" url: "https://pub.dev" source: hosted version: "2.6.1" - flutter_secure_storage: - dependency: "direct main" - description: - name: flutter_secure_storage - sha256: "165164745e6afb5c0e3e3fcc72a012fb9e58496fb26ffb92cf22e16a821e85d0" - url: "https://pub.dev" - source: hosted - version: "9.2.2" - flutter_secure_storage_linux: + flutter_svg: dependency: transitive description: - name: flutter_secure_storage_linux - sha256: "4d91bfc23047422cbcd73ac684bc169859ee766482517c22172c86596bf1464b" + name: flutter_svg + sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 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" - flutter_secure_storage_platform_interface: - dependency: transitive - description: - name: flutter_secure_storage_platform_interface - sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 - url: "https://pub.dev" - source: hosted - version: "1.1.2" - flutter_secure_storage_web: - dependency: transitive - description: - name: flutter_secure_storage_web - sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - flutter_secure_storage_windows: - dependency: transitive - description: - name: flutter_secure_storage_windows - sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 - url: "https://pub.dev" - source: hosted - version: "3.1.2" + version: "2.2.0" flutter_test: dependency: "direct dev" description: flutter @@ -526,14 +393,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,54 +401,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" - gtk: - dependency: transitive - description: - name: gtk - sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c - 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" + version: "16.1.0" hooks_riverpod: dependency: "direct main" description: @@ -598,86 +417,54 @@ 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" + version: "4.1.2" iconsax_flutter: dependency: transitive description: name: iconsax_flutter - sha256: "95b65699da8ea98f87c5d232f06b0debaaf1ec1332b697e4d90969ec9a93037d" + sha256: d14b4cec8586025ac15276bdd40f6eea308cb85748135965bb6255f14beb2564 url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.1" 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 +474,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 +509,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 +529,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 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 +553,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 +577,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 +600,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 +608,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 +628,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: @@ -898,34 +676,34 @@ packages: 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 +716,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 +732,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 +760,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 +823,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 +883,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 +899,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 +924,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 +953,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 +993,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 +1014,7 @@ packages: source: hosted version: "1.12.1" state_notifier: - dependency: transitive + dependency: "direct main" description: name: state_notifier sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb @@ -1261,14 +1029,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 +1037,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 +1069,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.4" - timing: - dependency: transitive - description: - name: timing - sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.dev" - source: hosted - version: "1.0.2" toastification: dependency: "direct main" description: name: toastification - sha256: "4d97fbfa463dfe83691044cba9f37cb185a79bb9205cfecb655fa1f6be126a13" + sha256: "69db2bff425b484007409650d8bcd5ed1ce2e9666293ece74dcd917dacf23112" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "3.0.3" typed_data: dependency: transitive description: @@ -1333,38 +1085,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 +1121,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 +1137,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 +1157,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 +1193,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 +1217,18 @@ 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: - dependency: transitive - description: - name: win32_registry - sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" - url: "https://pub.dev" - source: hosted - version: "1.1.5" + version: "5.15.0" xdg_directories: dependency: transitive description: @@ -1510,5 +1254,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..6c7db46 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,111 +1,127 @@ 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 + flutter_phoenix: ^1.1.1 + qr_flutter: ^4.1.0 + toastification: ^3.0.3 + share_plus: ^12.0.0 + background_downloader: ^9.2.6 + connectivity_plus: ^7.0.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 -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