mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Reorganize the sign-out process to ensure the toast notification is displayed after a successful sign-out.
375 lines
13 KiB
Dart
375 lines
13 KiB
Dart
import 'dart:async';
|
|
import 'dart:io' show Platform;
|
|
|
|
import 'package:connectivity_plus/connectivity_plus.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:path/path.dart' as path;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:purplebase/purplebase.dart';
|
|
import 'package:amber_signer/amber_signer.dart';
|
|
import 'package:zapstore/services/app_restart_service.dart';
|
|
import 'package:zapstore/services/background_update_service.dart';
|
|
import 'package:zapstore/services/notification_service.dart';
|
|
import 'package:zapstore/services/secure_storage_service.dart';
|
|
import 'package:zapstore/router.dart';
|
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
|
import 'package:zapstore/theme.dart';
|
|
import 'package:zapstore/services/package_manager/android_package_manager.dart';
|
|
import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
|
|
import 'package:zapstore/services/market_intent_service.dart';
|
|
import 'package:zapstore/utils/extensions.dart';
|
|
import 'package:zapstore/widgets/breathing_logo.dart';
|
|
|
|
/// Global provider container for error reporting (accessible outside widget tree)
|
|
late final ProviderContainer _providerContainer;
|
|
|
|
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(
|
|
UncontrolledProviderScope(
|
|
container: _providerContainer,
|
|
child: const ZapstoreApp(),
|
|
),
|
|
);
|
|
}, _errorHandler);
|
|
|
|
FlutterError.onError = (details) {
|
|
// Prevents debugger stopping multiple times
|
|
FlutterError.dumpErrorToConsole(details);
|
|
_errorHandler(details.exception, details.stack);
|
|
};
|
|
}
|
|
|
|
/// Global error handler that reports errors via NIP-44 encrypted DMs
|
|
void _errorHandler(Object exception, StackTrace? stack) {
|
|
// Report error asynchronously (fire and forget)
|
|
// TODO: Disabled until careful review
|
|
// unawaited(
|
|
// _providerContainer
|
|
// .read(errorReportingServiceProvider)
|
|
// .reportError(exception, stack),
|
|
// );
|
|
}
|
|
|
|
class ZapstoreApp extends HookConsumerWidget {
|
|
const ZapstoreApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final notifier =
|
|
ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier;
|
|
final title = 'Zapstore';
|
|
|
|
// Watch initialization state for error overlay display
|
|
final initState = ref.watch(appInitializationProvider);
|
|
|
|
// Listen to app lifecycle and check for updates when app regains focus
|
|
useEffect(() {
|
|
final observer = _AppLifecycleObserver(ref);
|
|
WidgetsBinding.instance.addObserver(observer);
|
|
return () => WidgetsBinding.instance.removeObserver(observer);
|
|
}, []);
|
|
|
|
// Listen to connectivity changes and trigger ensureConnected when going online
|
|
useEffect(() {
|
|
final connectivity = Connectivity();
|
|
StreamSubscription<List<ConnectivityResult>>? subscription;
|
|
|
|
// Check initial connectivity state
|
|
connectivity.checkConnectivity().then((results) {
|
|
notifier.connect();
|
|
});
|
|
|
|
// Listen to connectivity changes
|
|
subscription = connectivity.onConnectivityChanged.listen((results) {
|
|
notifier.connect();
|
|
});
|
|
|
|
return () => subscription?.cancel();
|
|
}, []);
|
|
|
|
// Automatically sign out when Amber is uninstalled while signed in.
|
|
ref.listen<bool>(
|
|
packageManagerProvider.select(
|
|
(state) => state.installed.containsKey(kAmberPackageId),
|
|
),
|
|
(previous, isAmberInstalled) {
|
|
if (previous != true || isAmberInstalled) return;
|
|
if (ref.read(Signer.activePubkeyProvider) == null) return;
|
|
|
|
unawaited(() async {
|
|
try {
|
|
await ref.read(amberSignerProvider).signOut();
|
|
|
|
final toastContext = rootNavigatorKey.currentState?.overlay?.context;
|
|
if (toastContext != null && toastContext.mounted) {
|
|
toastContext.showInfo('Amber was removed, you were signed out');
|
|
}
|
|
} catch (error) {
|
|
debugPrint('Auto sign-out after Amber uninstall failed: $error');
|
|
}
|
|
}());
|
|
},
|
|
);
|
|
|
|
// Always show the main app UI, even during initialization
|
|
return MaterialApp.router(
|
|
title: title,
|
|
theme: darkTheme,
|
|
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.2);
|
|
final constrainedTextScaler = TextScaler.linear(constrainedTextScale);
|
|
|
|
// Show error overlay if initialization failed (do not block UI during loading)
|
|
if (initState is AsyncError) {
|
|
return MediaQuery(
|
|
data: mediaQuery.copyWith(textScaler: constrainedTextScaler),
|
|
child: Stack(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
|
child: child!,
|
|
),
|
|
// Error overlay
|
|
Container(
|
|
color: Colors.black54,
|
|
child: Center(
|
|
child: Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.error_outline,
|
|
size: 64,
|
|
color: Colors.red,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Initialization Error',
|
|
style: context.textTheme.headlineSmall,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
initState.error.toString(),
|
|
textAlign: TextAlign.center,
|
|
style: context.textTheme.bodyMedium,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return MediaQuery(
|
|
data: mediaQuery.copyWith(textScaler: constrainedTextScaler),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
|
child: child!,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class ZapstoreHome extends StatelessWidget {
|
|
const ZapstoreHome({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const BreathingLogo(size: 120),
|
|
const SizedBox(height: 24),
|
|
Text('Zapstore', style: context.textTheme.headlineMedium),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Permissionless app store for Nostr',
|
|
style: context.textTheme.bodyLarge,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
const _kDefaultAppCatalogRelay = 'wss://relay.zapstore.dev';
|
|
|
|
final appInitializationProvider = FutureProvider<void>((ref) async {
|
|
final dir = await getApplicationSupportDirectory();
|
|
final dbPath = path.join(dir.path, 'zapstore.db');
|
|
|
|
// Clear storage if requested from a clear all operation
|
|
await maybeClearStorage(dbPath);
|
|
|
|
// Load local relay config BEFORE storage init
|
|
// This ensures custom relays work even when signed out
|
|
final secureStorage = ref.read(secureStorageServiceProvider);
|
|
final localRelays = await secureStorage.getAppCatalogRelays();
|
|
final appCatalogRelays = localRelays ?? {_kDefaultAppCatalogRelay};
|
|
|
|
// Initialize storage with local relay config
|
|
await ref.read(
|
|
initializationProvider(
|
|
StorageConfiguration(
|
|
databasePath: dbPath,
|
|
defaultQuerySource: LocalAndRemoteSource(
|
|
relays: 'AppCatalog',
|
|
stream: false,
|
|
),
|
|
defaultRelays: {
|
|
'default': {_kDefaultAppCatalogRelay},
|
|
'bootstrap': {_kDefaultAppCatalogRelay},
|
|
// TODO: add 'wss://purplepag.es' back when it's fixed
|
|
'AppCatalog': appCatalogRelays,
|
|
'social': {
|
|
'wss://relay.damus.io',
|
|
'wss://relay.primal.net',
|
|
'wss://nos.lol',
|
|
},
|
|
'vertex': {'wss://relay.vertexlab.io'},
|
|
},
|
|
responseTimeout: Duration(seconds: 6),
|
|
),
|
|
).future,
|
|
);
|
|
|
|
// Initialize device capabilities (used for dynamic download concurrency)
|
|
await DeviceCapabilitiesCache.initialize();
|
|
|
|
// Record app open time for background notification throttling
|
|
await secureStorage.setLastAppOpenedTime(DateTime.now());
|
|
|
|
// These run in background - don't block UI
|
|
final packageManager = ref.read(packageManagerProvider.notifier);
|
|
unawaited(packageManager.syncInstalledPackages());
|
|
|
|
final backgroundService = ref.read(backgroundUpdateServiceProvider);
|
|
unawaited(backgroundService.initialize());
|
|
|
|
unawaited(ref.read(marketIntentServiceProvider).initialize());
|
|
|
|
await _attemptAutoSignIn(ref);
|
|
});
|
|
|
|
// AmberSigner provider for Nostr authentication
|
|
// Uses SecureStoragePubkeyPersistence to survive database clears
|
|
final amberSignerProvider = Provider<AmberSigner>(
|
|
(ref) => AmberSigner(ref, persistence: SecureStoragePubkeyPersistence()),
|
|
);
|
|
|
|
Future<void> _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 ContactList after successful sign-in
|
|
Future<void> onSignInSuccess(Ref ref) async {
|
|
final pubkey = ref.read(Signer.activePubkeyProvider);
|
|
if (pubkey == null) return;
|
|
|
|
final storage =
|
|
ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier;
|
|
|
|
// Fetch contact list for stack sorting (await to ensure it's cached)
|
|
await storage.query(
|
|
RequestFilter<ContactList>(authors: {pubkey}).toRequest(),
|
|
source: const RemoteSource(relays: 'social', stream: false),
|
|
);
|
|
}
|
|
|
|
/// Observes app lifecycle events and manages package/storage state
|
|
class _AppLifecycleObserver with WidgetsBindingObserver {
|
|
_AppLifecycleObserver(this._ref);
|
|
|
|
final WidgetRef _ref;
|
|
|
|
/// Handle permission grants that happened while app was backgrounded
|
|
Future<void> _checkPermissionGrants() async {
|
|
final packageManager = _ref.read(packageManagerProvider.notifier);
|
|
if (packageManager is! AndroidPackageManager) return;
|
|
|
|
final hasPermission = await packageManager.hasPermission();
|
|
if (!hasPermission) return;
|
|
|
|
// Advance any operations waiting for permission
|
|
final state = _ref.read(packageManagerProvider);
|
|
final awaitingPermission = state.operations.entries
|
|
.where((e) => e.value is AwaitingPermission)
|
|
.map((e) => e.key)
|
|
.toList(growable: false);
|
|
|
|
for (final appId in awaitingPermission) {
|
|
await packageManager.onPermissionGranted(appId);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
final notifier =
|
|
_ref.read(storageNotifierProvider.notifier)
|
|
as PurplebaseStorageNotifier;
|
|
final packageManager = _ref.read(packageManagerProvider.notifier);
|
|
|
|
if (state == AppLifecycleState.resumed) {
|
|
// Record app open time for background notification throttling
|
|
unawaited(_recordAppOpened());
|
|
|
|
// Sync installed packages to detect installs that completed while backgrounded
|
|
unawaited(packageManager.syncInstalledPackages());
|
|
|
|
// Check for permission grants that happened in settings
|
|
unawaited(_checkPermissionGrants());
|
|
|
|
// Reconnect storage/relay connections
|
|
notifier.connect();
|
|
} else if (state == AppLifecycleState.paused) {
|
|
notifier.disconnect();
|
|
}
|
|
}
|
|
|
|
/// Record that the user opened the app.
|
|
/// This is used to check inactivity for background notifications.
|
|
Future<void> _recordAppOpened() async {
|
|
final secureStorage = SecureStorageService();
|
|
await secureStorage.setLastAppOpenedTime(DateTime.now());
|
|
}
|
|
}
|