mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
704 lines
21 KiB
Dart
704 lines
21 KiB
Dart
import 'package:async_button_builder/async_button_builder.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:models/models.dart';
|
|
import 'package:purplebase/purplebase.dart';
|
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
|
import 'package:zapstore/services/updates_service.dart';
|
|
import 'package:zapstore/theme.dart';
|
|
import 'package:zapstore/utils/extensions.dart';
|
|
import 'package:zapstore/widgets/common/badges.dart';
|
|
import 'package:zapstore/widgets/app_card.dart';
|
|
|
|
/// Screen for managing app updates
|
|
class UpdatesScreen extends HookConsumerWidget {
|
|
const UpdatesScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final categorized = ref.watch(categorizedAppsProvider);
|
|
|
|
if (categorized.isLoading) {
|
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
|
}
|
|
|
|
return Scaffold(
|
|
body: Padding(
|
|
padding: const EdgeInsets.only(top: 16),
|
|
child: _UpdatesContent(categorized: categorized),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Shows connection status with a small indicator at the top
|
|
class _ConnectionStatusIndicator extends ConsumerWidget {
|
|
const _ConnectionStatusIndicator();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final poolState = ref.watch(poolStateProvider);
|
|
final subscriptions = poolState?.subscriptions ?? {};
|
|
|
|
// Filter to only "app-updates" subscription
|
|
final updatesSubs = subscriptions.entries
|
|
.where((e) => e.key.startsWith('app-updates'))
|
|
.map((e) => e.value);
|
|
|
|
// Check relay status for updates subscription only
|
|
bool hasActiveConnection = false;
|
|
|
|
for (final sub in updatesSubs) {
|
|
for (final relay in sub.relays.values) {
|
|
if (relay.phase == RelaySubPhase.streaming ||
|
|
relay.phase == RelaySubPhase.loading) {
|
|
hasActiveConnection = true;
|
|
break;
|
|
}
|
|
}
|
|
if (hasActiveConnection) break;
|
|
}
|
|
|
|
final statusColor = hasActiveConnection ? Colors.green : Colors.grey;
|
|
final statusText = hasActiveConnection
|
|
? 'Connected · Checking for updates'
|
|
: 'Offline';
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
// Status dot
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: statusColor,
|
|
boxShadow: hasActiveConnection
|
|
? [
|
|
BoxShadow(
|
|
color: statusColor.withValues(alpha: 0.4),
|
|
blurRadius: 4,
|
|
spreadRadius: 1,
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
statusText,
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Item types for the updates list
|
|
enum _UpdatesItemType {
|
|
connectionStatus,
|
|
installingHeader,
|
|
installingApp,
|
|
automaticHeader,
|
|
automaticApp,
|
|
manualHeader,
|
|
manualInfoBox,
|
|
manualApp,
|
|
upToDateHeader,
|
|
upToDateApp,
|
|
uncatalogedHeader,
|
|
uncatalogedApp,
|
|
}
|
|
|
|
class _UpdatesItem {
|
|
final _UpdatesItemType type;
|
|
final App? app;
|
|
final PackageInfo? packageInfo;
|
|
|
|
const _UpdatesItem(this.type, {this.app, this.packageInfo});
|
|
}
|
|
|
|
class _UpdatesContent extends HookConsumerWidget {
|
|
const _UpdatesContent({required this.categorized});
|
|
|
|
final CategorizedApps categorized;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final automaticUpdates = categorized.automaticUpdates;
|
|
final manualUpdates = categorized.manualUpdates;
|
|
final upToDateApps = categorized.upToDateApps;
|
|
final uncatalogedApps = categorized.uncatalogedApps;
|
|
|
|
// Watch operations from PackageManager
|
|
final operations = ref.watch(
|
|
packageManagerProvider.select((s) => s.operations),
|
|
);
|
|
final activeAppIds = operations.entries
|
|
.where((entry) => entry.value.isActive)
|
|
.map((entry) => entry.key)
|
|
.toSet();
|
|
|
|
if (activeAppIds.isEmpty) {
|
|
return _UpdatesListBody(
|
|
automaticUpdates: automaticUpdates,
|
|
manualUpdates: manualUpdates,
|
|
installingApps: const [],
|
|
upToDateApps: upToDateApps,
|
|
uncatalogedApps: uncatalogedApps,
|
|
);
|
|
}
|
|
|
|
return _UpdatesListBodyWithInstallingAppIds(
|
|
installingAppIds: activeAppIds,
|
|
automaticUpdates: automaticUpdates,
|
|
manualUpdates: manualUpdates,
|
|
upToDateApps: upToDateApps,
|
|
uncatalogedApps: uncatalogedApps,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _UpdatesListBodyWithInstallingAppIds extends ConsumerWidget {
|
|
const _UpdatesListBodyWithInstallingAppIds({
|
|
required this.installingAppIds,
|
|
required this.automaticUpdates,
|
|
required this.manualUpdates,
|
|
required this.upToDateApps,
|
|
required this.uncatalogedApps,
|
|
});
|
|
|
|
final Set<String> installingAppIds;
|
|
final List<App> automaticUpdates;
|
|
final List<App> manualUpdates;
|
|
final List<App> upToDateApps;
|
|
final List<PackageInfo> uncatalogedApps;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final installingAppsState = ref.watch(
|
|
query<App>(
|
|
tags: {'#d': installingAppIds},
|
|
and: (app) => {app.latestRelease.query()},
|
|
source: const LocalAndRemoteSource(relays: 'AppCatalog'),
|
|
subscriptionPrefix: 'app-installing-apps',
|
|
),
|
|
);
|
|
|
|
final updateAppIds = {
|
|
...automaticUpdates.map((a) => a.identifier),
|
|
...manualUpdates.map((a) => a.identifier),
|
|
};
|
|
|
|
final installingApps = installingAppsState.models
|
|
.where(
|
|
(app) =>
|
|
installingAppIds.contains(app.identifier) &&
|
|
!updateAppIds.contains(app.identifier),
|
|
)
|
|
.toList();
|
|
|
|
return _UpdatesListBody(
|
|
automaticUpdates: automaticUpdates,
|
|
manualUpdates: manualUpdates,
|
|
installingApps: installingApps,
|
|
upToDateApps: upToDateApps,
|
|
uncatalogedApps: uncatalogedApps,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _UpdatesListBody extends StatelessWidget {
|
|
const _UpdatesListBody({
|
|
required this.automaticUpdates,
|
|
required this.manualUpdates,
|
|
required this.installingApps,
|
|
required this.upToDateApps,
|
|
required this.uncatalogedApps,
|
|
});
|
|
|
|
final List<App> automaticUpdates;
|
|
final List<App> manualUpdates;
|
|
final List<App> installingApps;
|
|
final List<App> upToDateApps;
|
|
final List<PackageInfo> uncatalogedApps;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (automaticUpdates.isEmpty &&
|
|
manualUpdates.isEmpty &&
|
|
installingApps.isEmpty &&
|
|
upToDateApps.isEmpty &&
|
|
uncatalogedApps.isEmpty) {
|
|
final theme = Theme.of(context);
|
|
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ColorFiltered(
|
|
colorFilter: const ColorFilter.matrix(<double>[
|
|
0.2126,
|
|
0.7152,
|
|
0.0722,
|
|
0,
|
|
0,
|
|
0.2126,
|
|
0.7152,
|
|
0.0722,
|
|
0,
|
|
0,
|
|
0.2126,
|
|
0.7152,
|
|
0.0722,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
1,
|
|
0,
|
|
]),
|
|
child: const Text('🎉', style: TextStyle(fontSize: 48)),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'No apps installed yet',
|
|
style: theme.textTheme.titleMedium,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'Install some apps to get started!',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Build flat list of items for ListView.builder
|
|
final items = <_UpdatesItem>[];
|
|
|
|
// Always add connection status at the top
|
|
items.add(const _UpdatesItem(_UpdatesItemType.connectionStatus));
|
|
|
|
if (installingApps.isNotEmpty) {
|
|
items.add(const _UpdatesItem(_UpdatesItemType.installingHeader));
|
|
for (final app in installingApps) {
|
|
items.add(_UpdatesItem(_UpdatesItemType.installingApp, app: app));
|
|
}
|
|
}
|
|
|
|
if (automaticUpdates.isNotEmpty) {
|
|
items.add(const _UpdatesItem(_UpdatesItemType.automaticHeader));
|
|
for (final app in automaticUpdates) {
|
|
items.add(_UpdatesItem(_UpdatesItemType.automaticApp, app: app));
|
|
}
|
|
}
|
|
|
|
if (manualUpdates.isNotEmpty) {
|
|
items.add(const _UpdatesItem(_UpdatesItemType.manualHeader));
|
|
items.add(const _UpdatesItem(_UpdatesItemType.manualInfoBox));
|
|
for (final app in manualUpdates) {
|
|
items.add(_UpdatesItem(_UpdatesItemType.manualApp, app: app));
|
|
}
|
|
}
|
|
|
|
if (upToDateApps.isNotEmpty) {
|
|
items.add(const _UpdatesItem(_UpdatesItemType.upToDateHeader));
|
|
for (final app in upToDateApps) {
|
|
items.add(_UpdatesItem(_UpdatesItemType.upToDateApp, app: app));
|
|
}
|
|
}
|
|
|
|
if (uncatalogedApps.isNotEmpty) {
|
|
items.add(const _UpdatesItem(_UpdatesItemType.uncatalogedHeader));
|
|
for (final pkg in uncatalogedApps) {
|
|
items.add(
|
|
_UpdatesItem(_UpdatesItemType.uncatalogedApp, packageInfo: pkg),
|
|
);
|
|
}
|
|
}
|
|
|
|
return ListView.builder(
|
|
itemCount: items.length,
|
|
itemBuilder: (context, index) {
|
|
final item = items[index];
|
|
switch (item.type) {
|
|
case _UpdatesItemType.connectionStatus:
|
|
return const _ConnectionStatusIndicator();
|
|
case _UpdatesItemType.installingHeader:
|
|
return _InstallingHeader(count: installingApps.length);
|
|
case _UpdatesItemType.installingApp:
|
|
return AppCard(
|
|
key: ValueKey('installing_${item.app?.identifier}'),
|
|
app: item.app,
|
|
showUpdateArrow: false,
|
|
showUpdateButton: true,
|
|
showZapEncouragement: true,
|
|
showDescription: false,
|
|
);
|
|
case _UpdatesItemType.automaticHeader:
|
|
return _UpdatesSectionHeader(
|
|
icon: Icons.system_update,
|
|
title: 'Updates',
|
|
count: automaticUpdates.length,
|
|
apps: automaticUpdates,
|
|
);
|
|
case _UpdatesItemType.automaticApp:
|
|
return AppCard(
|
|
key: ValueKey('automatic_${item.app?.identifier}'),
|
|
app: item.app,
|
|
showUpdateArrow: true,
|
|
showUpdateButton: true,
|
|
showZapEncouragement: true,
|
|
showDescription: false,
|
|
);
|
|
case _UpdatesItemType.manualHeader:
|
|
return _UpdatesSectionHeader(
|
|
icon: Icons.touch_app,
|
|
title: 'Manual Updates',
|
|
count: manualUpdates.length,
|
|
apps: manualUpdates,
|
|
);
|
|
case _UpdatesItemType.manualInfoBox:
|
|
return _ManualUpdatesInfoBox();
|
|
case _UpdatesItemType.manualApp:
|
|
return AppCard(
|
|
key: ValueKey('manual_${item.app?.identifier}'),
|
|
app: item.app,
|
|
showUpdateArrow: true,
|
|
showUpdateButton: true,
|
|
showZapEncouragement: true,
|
|
showDescription: false,
|
|
);
|
|
case _UpdatesItemType.upToDateHeader:
|
|
return _UpToDateHeader(count: upToDateApps.length);
|
|
case _UpdatesItemType.upToDateApp:
|
|
return AppCard(
|
|
key: ValueKey('uptodate_${item.app?.identifier}'),
|
|
app: item.app,
|
|
showUpdateArrow: false,
|
|
showDescription: false,
|
|
);
|
|
case _UpdatesItemType.uncatalogedHeader:
|
|
return _UncatalogedHeader(count: uncatalogedApps.length);
|
|
case _UpdatesItemType.uncatalogedApp:
|
|
return _UncatalogedAppCard(
|
|
key: ValueKey('uncataloged_${item.packageInfo?.appId}'),
|
|
packageInfo: item.packageInfo!,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InstallingHeader extends StatelessWidget {
|
|
const _InstallingHeader({required this.count});
|
|
|
|
final int count;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.downloading, size: 20, color: AppColors.darkActionPrimary),
|
|
const SizedBox(width: 8),
|
|
Text('Installing', style: context.textTheme.titleMedium),
|
|
const SizedBox(width: 8),
|
|
CountBadge(count: count, color: AppColors.darkPillBackground),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _UpdatesSectionHeader extends ConsumerWidget {
|
|
const _UpdatesSectionHeader({
|
|
required this.icon,
|
|
required this.title,
|
|
required this.count,
|
|
required this.apps,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String title;
|
|
final int count;
|
|
final List<App> apps;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
alignment: WrapAlignment.spaceBetween,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 20, color: AppColors.darkActionPrimary),
|
|
const SizedBox(width: 8),
|
|
Flexible(
|
|
child: Text(title, style: context.textTheme.titleMedium),
|
|
),
|
|
const SizedBox(width: 8),
|
|
CountBadge(count: count, color: AppColors.darkPillBackground),
|
|
],
|
|
),
|
|
AsyncButtonBuilder(
|
|
onPressed: () async {
|
|
final pm = ref.read(packageManagerProvider.notifier);
|
|
final items = apps
|
|
.where((app) => app.latestFileMetadata != null)
|
|
.map(
|
|
(app) => (
|
|
appId: app.identifier,
|
|
target: app.latestFileMetadata!,
|
|
displayName: app.name,
|
|
),
|
|
)
|
|
.toList();
|
|
await pm.queueDownloads(items);
|
|
},
|
|
builder: (context, child, callback, buttonState) {
|
|
const pillBg = AppColors.darkPillBackground;
|
|
const pillText = Colors.white;
|
|
|
|
return TextButton.icon(
|
|
onPressed: buttonState.maybeWhen(
|
|
loading: () => null,
|
|
orElse: () => callback,
|
|
),
|
|
icon: buttonState.maybeWhen(
|
|
loading: () => const SizedBox(
|
|
width: 12,
|
|
height: 12,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: pillText,
|
|
),
|
|
),
|
|
orElse: () =>
|
|
const Icon(Icons.download, size: 14, color: pillText),
|
|
),
|
|
label: const Text(
|
|
'Update All',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
color: pillText,
|
|
),
|
|
),
|
|
style: TextButton.styleFrom(
|
|
backgroundColor: pillBg,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 6,
|
|
),
|
|
minimumSize: Size.zero,
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
);
|
|
},
|
|
child: const SizedBox.shrink(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ManualUpdatesInfoBox extends StatelessWidget {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 2),
|
|
child: Icon(
|
|
Icons.info_outline,
|
|
size: 18,
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
'Apps not installed or updated by the latest Zapstore will show here '
|
|
'and require manual confirmation of the Android system prompt once per app.',
|
|
style: context.textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _UpToDateHeader extends StatelessWidget {
|
|
const _UpToDateHeader({required this.count});
|
|
|
|
final int count;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.check_circle,
|
|
size: 20,
|
|
color: AppColors.darkActionPrimary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text('Up to date', style: context.textTheme.titleMedium),
|
|
const SizedBox(width: 8),
|
|
CountBadge(count: count, color: AppColors.darkPillBackground),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _UncatalogedHeader extends StatelessWidget {
|
|
const _UncatalogedHeader({required this.count});
|
|
|
|
final int count;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.help_outline,
|
|
size: 20,
|
|
color: AppColors.darkOnSurfaceSecondary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text('Other installed', style: context.textTheme.titleMedium),
|
|
const SizedBox(width: 8),
|
|
CountBadge(count: count, color: AppColors.darkPillBackground),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _UncatalogedAppCard extends StatelessWidget {
|
|
const _UncatalogedAppCard({super.key, required this.packageInfo});
|
|
|
|
final PackageInfo packageInfo;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(20),
|
|
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6),
|
|
border: Border.all(
|
|
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
// Generic app icon
|
|
Container(
|
|
width: 50,
|
|
height: 50,
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(14),
|
|
),
|
|
child: Icon(
|
|
Icons.android,
|
|
color: AppColors.darkOnSurfaceSecondary,
|
|
size: 28,
|
|
),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
packageInfo.name ?? packageInfo.appId,
|
|
style: context.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
packageInfo.appId,
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: AppColors.darkOnSurfaceSecondary,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 8),
|
|
// Version pill
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 9,
|
|
vertical: 5,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.darkPillBackground,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
packageInfo.version,
|
|
style: context.textTheme.labelSmall?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|