mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
Add unmanaged apps to updates
This commit is contained in:
+160
-20
@@ -2,8 +2,11 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/services/device_key_service.dart';
|
||||
import 'package:zapstore/services/ignored_apps_service.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/services/updates_service.dart';
|
||||
import 'package:zapstore/theme.dart';
|
||||
@@ -174,6 +177,7 @@ class _UpdatesList extends ConsumerWidget {
|
||||
final manualUpdates = categorized.manualUpdates;
|
||||
final upToDateApps = categorized.upToDateApps;
|
||||
final uncatalogedApps = categorized.uncatalogedApps;
|
||||
final unmanagedApps = categorized.unmanagedApps;
|
||||
|
||||
// Resolve installing apps (active operations not already in update lists)
|
||||
final operations = ref.watch(
|
||||
@@ -210,12 +214,13 @@ class _UpdatesList extends ConsumerWidget {
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Empty state
|
||||
// Empty state: only show if there are truly no apps at all (unmanaged ones don't count)
|
||||
if (automaticUpdates.isEmpty &&
|
||||
manualUpdates.isEmpty &&
|
||||
installingApps.isEmpty &&
|
||||
upToDateApps.isEmpty &&
|
||||
uncatalogedApps.isEmpty) {
|
||||
uncatalogedApps.isEmpty &&
|
||||
unmanagedApps.isEmpty) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Column(
|
||||
@@ -229,7 +234,7 @@ class _UpdatesList extends ConsumerWidget {
|
||||
0.2126, 0.7152, 0.0722, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
]),
|
||||
child: const Text('🎉', style: TextStyle(fontSize: 48)),
|
||||
child: const Text('\u{1F389}', style: TextStyle(fontSize: 48)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
@@ -307,26 +312,63 @@ class _UpdatesList extends ConsumerWidget {
|
||||
title: 'Other installed',
|
||||
count: uncatalogedApps.length,
|
||||
iconColor: AppColors.darkOnSurfaceSecondary,
|
||||
hint: 'Swipe left to stop managing these apps',
|
||||
),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _UncatalogedAppCard(
|
||||
(context, index) => _SlidablePackageCard(
|
||||
key: ValueKey('uncataloged_${uncatalogedApps[index].appId}'),
|
||||
packageInfo: uncatalogedApps[index],
|
||||
actionLabel: 'Unmanage',
|
||||
actionIcon: Icons.do_not_disturb_on_outlined,
|
||||
actionColor: Colors.orange.shade800,
|
||||
onAction: (ref) => toggleUnmanagedApp(
|
||||
ref,
|
||||
uncatalogedApps[index].appId,
|
||||
unmanage: true,
|
||||
),
|
||||
),
|
||||
childCount: uncatalogedApps.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (unmanagedApps.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: _SectionHeader(
|
||||
icon: Icons.visibility_off,
|
||||
title: 'Unmanaged Apps',
|
||||
count: unmanagedApps.length,
|
||||
iconColor: AppColors.darkOnSurfaceSecondary,
|
||||
hint: 'Swipe left to manage again',
|
||||
),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _SlidablePackageCard(
|
||||
key: ValueKey('unmanaged_${unmanagedApps[index].appId}'),
|
||||
packageInfo: unmanagedApps[index],
|
||||
actionLabel: 'Manage',
|
||||
actionIcon: Icons.visibility_outlined,
|
||||
actionColor: AppColors.darkActionPrimary,
|
||||
onAction: (ref) => toggleUnmanagedApp(
|
||||
ref,
|
||||
unmanagedApps[index].appId,
|
||||
unmanage: false,
|
||||
),
|
||||
),
|
||||
childCount: unmanagedApps.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A section: header + list of AppCards, rendered as a single sliver.
|
||||
class _AppSection extends StatelessWidget {
|
||||
/// A section: header + list of AppCards with swipe-to-ignore, rendered as a single sliver.
|
||||
class _AppSection extends ConsumerWidget {
|
||||
const _AppSection({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
@@ -348,7 +390,9 @@ class _AppSection extends StatelessWidget {
|
||||
final Widget? headerTrailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasDeviceKey = ref.watch(devicePubkeyProvider) != null;
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
@@ -361,7 +405,7 @@ class _AppSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
final app = apps[index - 1];
|
||||
return AppCard(
|
||||
final card = AppCard(
|
||||
key: ValueKey('${keyPrefix}_${app.identifier}'),
|
||||
app: app,
|
||||
showUpdateArrow: showUpdateArrow,
|
||||
@@ -369,6 +413,31 @@ class _AppSection extends StatelessWidget {
|
||||
showZapEncouragement: showZapEncouragement,
|
||||
showDescription: false,
|
||||
);
|
||||
|
||||
if (!hasDeviceKey) return card;
|
||||
|
||||
return Slidable(
|
||||
key: ValueKey('slidable_${keyPrefix}_${app.identifier}'),
|
||||
endActionPane: ActionPane(
|
||||
motion: const BehindMotion(),
|
||||
extentRatio: 0.22,
|
||||
children: [
|
||||
SlidableAction(
|
||||
onPressed: (_) => toggleUnmanagedApp(
|
||||
ref,
|
||||
app.identifier,
|
||||
unmanage: true,
|
||||
),
|
||||
backgroundColor: Colors.orange.shade800,
|
||||
foregroundColor: Colors.white,
|
||||
icon: Icons.do_not_disturb_on_outlined,
|
||||
label: 'Unmanage',
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: card,
|
||||
);
|
||||
},
|
||||
childCount: apps.length + 1,
|
||||
),
|
||||
@@ -383,6 +452,7 @@ class _SectionHeader extends StatelessWidget {
|
||||
required this.count,
|
||||
this.iconColor,
|
||||
this.trailing,
|
||||
this.hint,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
@@ -390,21 +460,44 @@ class _SectionHeader extends StatelessWidget {
|
||||
final int count;
|
||||
final Color? iconColor;
|
||||
final Widget? trailing;
|
||||
final String? hint;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, hint != null ? 4 : 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: iconColor ?? AppColors.darkActionPrimary),
|
||||
const SizedBox(width: 8),
|
||||
Text(title, style: context.textTheme.titleMedium),
|
||||
const SizedBox(width: 8),
|
||||
CountBadge(count: count, color: AppColors.darkPillBackground),
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
trailing!,
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: iconColor ?? AppColors.darkActionPrimary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(title, style: context.textTheme.titleMedium),
|
||||
const SizedBox(width: 8),
|
||||
CountBadge(count: count, color: AppColors.darkPillBackground),
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
trailing!,
|
||||
],
|
||||
],
|
||||
),
|
||||
if (hint != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
hint!,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.45),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -441,8 +534,55 @@ class _ManualUpdatesHelpIcon extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _UncatalogedAppCard extends StatelessWidget {
|
||||
const _UncatalogedAppCard({super.key, required this.packageInfo});
|
||||
/// A package card (uncataloged or unmanaged) with a swipe-left action pane.
|
||||
///
|
||||
/// When the device key is not ready, no swipe action is shown.
|
||||
class _SlidablePackageCard extends ConsumerWidget {
|
||||
const _SlidablePackageCard({
|
||||
super.key,
|
||||
required this.packageInfo,
|
||||
required this.actionLabel,
|
||||
required this.actionIcon,
|
||||
required this.actionColor,
|
||||
required this.onAction,
|
||||
});
|
||||
|
||||
final PackageInfo packageInfo;
|
||||
final String actionLabel;
|
||||
final IconData actionIcon;
|
||||
final Color actionColor;
|
||||
final Future<void> Function(WidgetRef ref) onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasDeviceKey = ref.watch(devicePubkeyProvider) != null;
|
||||
final card = _PackageCard(packageInfo: packageInfo);
|
||||
|
||||
if (!hasDeviceKey) return card;
|
||||
|
||||
return Slidable(
|
||||
key: ValueKey('slidable_pkg_${packageInfo.appId}'),
|
||||
endActionPane: ActionPane(
|
||||
motion: const BehindMotion(),
|
||||
extentRatio: 0.22,
|
||||
children: [
|
||||
SlidableAction(
|
||||
onPressed: (_) => onAction(ref),
|
||||
backgroundColor: actionColor,
|
||||
foregroundColor: Colors.white,
|
||||
icon: actionIcon,
|
||||
label: actionLabel,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PackageCard extends StatelessWidget {
|
||||
const _PackageCard({required this.packageInfo});
|
||||
|
||||
final PackageInfo packageInfo;
|
||||
|
||||
|
||||
@@ -479,6 +479,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_slidable:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_slidable
|
||||
sha256: ea369262929d3cc6ebf9d8a00c196127966f117fe433a5e5cb47fb08008ca203
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.3"
|
||||
flutter_svg:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -57,6 +57,7 @@ dependencies:
|
||||
flutter_local_notifications: ^19.5.0
|
||||
device_info_plus: ^12.0.0
|
||||
archive: ^4.0.7
|
||||
flutter_slidable: ^4.0.3
|
||||
|
||||
dependency_overrides:
|
||||
models:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# WORK-010: Ignored Apps
|
||||
|
||||
## Goal
|
||||
Allow users to mark installed apps as "Ignored" so they are excluded from the
|
||||
Updates screen and the "Update All" batch operation.
|
||||
|
||||
## Approach
|
||||
Store the ignored set as an encrypted AppStack (kind 30267) with identifier
|
||||
`zapstore-ignored-apps`, using the same pattern as the bookmarks stack. This
|
||||
gives reactivity (SQLite + query<AppStack>), NIP-44 encryption, and
|
||||
cross-device sync for free.
|
||||
|
||||
## Tasks
|
||||
- [x] Add kIgnoredAppsIdentifier constant
|
||||
- [x] Update appStackEventFilter to reject the ignored-apps stack by d tag
|
||||
- [x] Add flutter_slidable dependency
|
||||
- [x] Create ignoredAppsProvider (mirrors bookmarksProvider)
|
||||
- [x] Create ignored_apps_service.dart with toggleIgnoredApp write helper
|
||||
- [x] Filter ignored apps out of all lists in categorizedUpdatesProvider
|
||||
- [x] Add ignoredApps: List<PackageInfo> to CategorizedUpdates
|
||||
- [x] Wrap app cards in Slidable on updates screen (swipe-left -> Ignore)
|
||||
- [x] Add "Ignored" section at bottom with swipe-left -> "Manage again" action
|
||||
|
||||
## Decisions
|
||||
- Encrypted stack (not CustomData) reuses existing infrastructure and encryption
|
||||
- Ignored app IDs stored as bare package IDs since uncataloged apps have no kind:pubkey prefix
|
||||
- For cataloged apps the app.identifier (package ID) is used consistently
|
||||
- Graceful degradation: unsigned-out users see no swipe actions
|
||||
- Ignored apps excluded from all lists: automaticUpdates, manualUpdates, upToDateApps, uncatalogedApps
|
||||
Reference in New Issue
Block a user