sync initial
This commit is contained in:
@@ -179,6 +179,19 @@
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize|stateAlwaysHidden" />
|
||||
|
||||
<activity
|
||||
android:name="eu.weblibre.flutter_mozilla_components.activities.AuthCustomTabActivity"
|
||||
android:exported="false"
|
||||
android:taskAffinity=""
|
||||
android:autoRemoveFromRecents="false"
|
||||
android:theme="@style/ExternalAppBrowserTheme"
|
||||
android:configChanges="keyboard|keyboardHidden|mcc|mnc|orientation|screenSize|layoutDirection|smallestScreenSize|screenLayout"
|
||||
android:windowSoftInputMode="adjustResize|stateAlwaysHidden" />
|
||||
|
||||
<activity
|
||||
android:name="eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:theme="@style/AddonsActivityTheme"
|
||||
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
|
||||
|
||||
@@ -20,9 +20,26 @@
|
||||
package eu.weblibre.gecko
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import eu.weblibre.flutter_mozilla_components.ActiveProfile
|
||||
import eu.weblibre.flutter_mozilla_components.MegazordSetup
|
||||
|
||||
class MyApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
MegazordSetup.setupEarlyMainProcess()
|
||||
|
||||
// Resolve active profile EARLY so cold-start WorkManager workers
|
||||
// get profile-prefixed SharedPreferences
|
||||
ActiveProfile.resolveFromDisk(this)
|
||||
}
|
||||
|
||||
override fun getSharedPreferences(name: String, mode: Int): SharedPreferences {
|
||||
val pfx = ActiveProfile.prefix
|
||||
if (pfx != null && name in ActiveProfile.FXA_SHARED_PREFERENCE_NAMES) {
|
||||
return super.getSharedPreferences("${pfx}_$name", mode)
|
||||
}
|
||||
return super.getSharedPreferences(name, mode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ part 'format.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class Format extends _$Format {
|
||||
String fullDateTimeWithTimezone(DateTime date) {
|
||||
String fullDateTime(DateTime date) {
|
||||
final pattern = DateFormat('yMMMMd').addPattern('Hm');
|
||||
|
||||
return pattern.format(date);
|
||||
|
||||
@@ -32,7 +32,7 @@ final class FormatProvider extends $AsyncNotifierProvider<Format, void> {
|
||||
Format create() => Format();
|
||||
}
|
||||
|
||||
String _$formatHash() => r'fe7fcdd19b512784a36f3838c57701030475e429';
|
||||
String _$formatHash() => r'f132aabb20bf4df77d771d6c866bc413c00bd9ff';
|
||||
|
||||
abstract class _$Format extends $AsyncNotifier<void> {
|
||||
FutureOr<void> build();
|
||||
|
||||
@@ -70,6 +70,7 @@ import 'package:weblibre/features/settings/presentation/screens/tabs_behavior_se
|
||||
import 'package:weblibre/features/settings/presentation/screens/tracking_protection_exceptions.dart';
|
||||
import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening.dart';
|
||||
import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening_group.dart';
|
||||
import 'package:weblibre/features/sync/presentation/screens/sync_settings.dart';
|
||||
import 'package:weblibre/features/tor/presentation/screens/country_picker.dart';
|
||||
import 'package:weblibre/features/tor/presentation/screens/tor_proxy.dart';
|
||||
import 'package:weblibre/features/user/domain/presentation/dialogs/select_profile.dart';
|
||||
|
||||
@@ -1427,6 +1427,11 @@ RouteBase get $settingsRoute => GoRouteData.$route(
|
||||
name: 'ErrorLogsRoute',
|
||||
factory: $ErrorLogsRoute._fromState,
|
||||
),
|
||||
GoRouteData.$route(
|
||||
path: 'sync',
|
||||
name: 'SyncSettingsRoute',
|
||||
factory: $SyncSettingsRoute._fromState,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1791,6 +1796,27 @@ mixin $ErrorLogsRoute on GoRouteData {
|
||||
void replace(BuildContext context) => context.replace(location);
|
||||
}
|
||||
|
||||
mixin $SyncSettingsRoute on GoRouteData {
|
||||
static SyncSettingsRoute _fromState(GoRouterState state) =>
|
||||
SyncSettingsRoute();
|
||||
|
||||
@override
|
||||
String get location => GoRouteData.$location('/settings/sync');
|
||||
|
||||
@override
|
||||
void go(BuildContext context) => context.go(location);
|
||||
|
||||
@override
|
||||
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
|
||||
|
||||
@override
|
||||
void pushReplacement(BuildContext context) =>
|
||||
context.pushReplacement(location);
|
||||
|
||||
@override
|
||||
void replace(BuildContext context) => context.replace(location);
|
||||
}
|
||||
|
||||
RouteBase get $torProxyRoute => GoRouteData.$route(
|
||||
path: '/tor',
|
||||
name: 'TorProxyRoute',
|
||||
|
||||
@@ -80,6 +80,7 @@ part of 'routes.dart';
|
||||
path: 'custom_tracking_protection',
|
||||
),
|
||||
TypedGoRoute<ErrorLogsRoute>(name: 'ErrorLogsRoute', path: 'error_logs'),
|
||||
TypedGoRoute<SyncSettingsRoute>(name: 'SyncSettingsRoute', path: 'sync'),
|
||||
],
|
||||
)
|
||||
class SettingsRoute extends GoRouteData with $SettingsRoute {
|
||||
@@ -214,3 +215,10 @@ class CustomTrackingProtectionRoute extends GoRouteData
|
||||
return const CustomTrackingProtectionScreen();
|
||||
}
|
||||
}
|
||||
|
||||
class SyncSettingsRoute extends GoRouteData with $SyncSettingsRoute {
|
||||
@override
|
||||
Widget build(BuildContext context, GoRouterState state) {
|
||||
return const SyncSettingsScreen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/keep_tab_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart';
|
||||
@@ -55,6 +55,7 @@ import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_
|
||||
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
|
||||
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart';
|
||||
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
|
||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/utils/move_to_background.dart';
|
||||
@@ -303,6 +304,44 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
},
|
||||
);
|
||||
|
||||
useOnAppLifecycleStateChange((previous, current) {
|
||||
switch (current) {
|
||||
case AppLifecycleState.resumed:
|
||||
if (current != AppLifecycleState.resumed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ref.read(syncIsAuthenticatedProvider)) {
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(() async {
|
||||
try {
|
||||
final openedTabs = await ref
|
||||
.read(syncRepositoryProvider.notifier)
|
||||
.pollIncomingTabsAndOpen();
|
||||
|
||||
if (openedTabs > 0 && context.mounted) {
|
||||
ui_helper.showOpenedTabsFromAnotherDeviceMessage(
|
||||
context,
|
||||
openedTabs,
|
||||
);
|
||||
}
|
||||
} catch (e, s) {
|
||||
logger.e(
|
||||
'Failed polling incoming sync tabs on resume',
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
}
|
||||
}());
|
||||
case AppLifecycleState.detached:
|
||||
case AppLifecycleState.inactive:
|
||||
case AppLifecycleState.hidden:
|
||||
case AppLifecycleState.paused:
|
||||
}
|
||||
});
|
||||
|
||||
final pointerMoveEventsController = useStreamController<Offset>();
|
||||
|
||||
// Watch sheet state for rendering in Stack
|
||||
@@ -317,8 +356,7 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
|
||||
// Toolbar is visible when: sheet is shown OR (not fullscreen AND controller says visible)
|
||||
// The controller handles loading-start show internally via ref.listen on isLoading
|
||||
final effectiveAppBarVisible =
|
||||
toolbarState == ToolbarVisibility.visible;
|
||||
final effectiveAppBarVisible = toolbarState == ToolbarVisibility.visible;
|
||||
final topToolbarVisible =
|
||||
sheetDisplayed || (!tabInFullScreen && effectiveAppBarVisible);
|
||||
final bottomToolbarVisible =
|
||||
@@ -1020,6 +1058,16 @@ class _ViewTabsSheet extends HookConsumerWidget {
|
||||
final tabsViewMode = ref.watch(tabsViewModeControllerProvider);
|
||||
final tabsReorderable = ref.watch(tabsReorderableControllerProvider);
|
||||
|
||||
final isSyncedScope = ref.watch(
|
||||
effectiveTabsTrayScopeProvider.select(
|
||||
(scope) => scope == TabsTrayScope.synced,
|
||||
),
|
||||
);
|
||||
|
||||
final effectiveTabsViewMode = isSyncedScope
|
||||
? TabsViewMode.list
|
||||
: tabsViewMode;
|
||||
|
||||
final draggableScrollableController = useDraggableScrollableController(
|
||||
keys: [tabsReorderable],
|
||||
);
|
||||
@@ -1037,7 +1085,7 @@ class _ViewTabsSheet extends HookConsumerWidget {
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: switch (tabsViewMode) {
|
||||
child: switch (effectiveTabsViewMode) {
|
||||
TabsViewMode.list => ViewTabListWidget(
|
||||
scrollController: scrollController,
|
||||
showNewTabFab: true,
|
||||
|
||||
@@ -26,6 +26,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/contro
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart';
|
||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/presentation/hooks/scroll_visibility.dart';
|
||||
|
||||
@@ -37,6 +38,16 @@ class TabViewScreen extends HookConsumerWidget {
|
||||
final tabsViewMode = ref.watch(tabsViewModeControllerProvider);
|
||||
final tabsReorderable = ref.watch(tabsReorderableControllerProvider);
|
||||
|
||||
final isSyncedScope = ref.watch(
|
||||
effectiveTabsTrayScopeProvider.select(
|
||||
(scope) => scope == TabsTrayScope.synced,
|
||||
),
|
||||
);
|
||||
|
||||
final effectiveTabsViewMode = isSyncedScope
|
||||
? TabsViewMode.list
|
||||
: tabsViewMode;
|
||||
|
||||
final scrollController = useScrollController(keys: [tabsReorderable]);
|
||||
|
||||
// Track FAB visibility based on scroll direction
|
||||
@@ -45,7 +56,7 @@ class TabViewScreen extends HookConsumerWidget {
|
||||
return Dialog.fullscreen(
|
||||
child: Scaffold(
|
||||
body: SafeArea(
|
||||
child: switch (tabsViewMode) {
|
||||
child: switch (effectiveTabsViewMode) {
|
||||
TabsViewMode.list => ViewTabListWidget(
|
||||
key: ValueKey(tabsReorderable),
|
||||
scrollController: scrollController,
|
||||
@@ -73,31 +84,35 @@ class TabViewScreen extends HookConsumerWidget {
|
||||
),
|
||||
},
|
||||
),
|
||||
floatingActionButton: AnimatedSlide(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
offset: isFabVisible.value ? Offset.zero : const Offset(0, 2),
|
||||
curve: Curves.easeInOut,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: isFabVisible.value ? 1.0 : 0.0,
|
||||
child: FloatingActionButton(
|
||||
onPressed: () async {
|
||||
final settings = ref.read(generalSettingsWithDefaultsProvider);
|
||||
floatingActionButton: isSyncedScope
|
||||
? null
|
||||
: AnimatedSlide(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
offset: isFabVisible.value ? Offset.zero : const Offset(0, 2),
|
||||
curve: Curves.easeInOut,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: isFabVisible.value ? 1.0 : 0.0,
|
||||
child: FloatingActionButton(
|
||||
onPressed: () async {
|
||||
final settings = ref.read(
|
||||
generalSettingsWithDefaultsProvider,
|
||||
);
|
||||
|
||||
await SearchRoute(
|
||||
tabType:
|
||||
ref.read(selectedTabTypeProvider) ??
|
||||
settings.defaultCreateTabType,
|
||||
).push(context);
|
||||
await SearchRoute(
|
||||
tabType:
|
||||
ref.read(selectedTabTypeProvider) ??
|
||||
settings.defaultCreateTabType,
|
||||
).push(context);
|
||||
|
||||
if (context.mounted) {
|
||||
const BrowserRoute().go(context);
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (context.mounted) {
|
||||
const BrowserRoute().go(context);
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+1
@@ -670,6 +670,7 @@ class ShareMenuButton extends HookConsumerWidget {
|
||||
OpenInAppMenuItemButton(selectedTabId: selectedTabId),
|
||||
ShareScreenshotMenuItemButton(selectedTabId: selectedTabId),
|
||||
ShareMenuItemButton(selectedTabId: selectedTabId),
|
||||
SendTabToDeviceMenuItemButton(selectedTabId: selectedTabId),
|
||||
ShowQrCodeMenuItemButton(selectedTabId: selectedTabId),
|
||||
],
|
||||
builder: (context, controller, child) {
|
||||
|
||||
+79
-1
@@ -18,6 +18,8 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@@ -31,12 +33,15 @@ import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart';
|
||||
import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
|
||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
||||
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
|
||||
import 'package:weblibre/features/user/domain/providers.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/presentation/icons/tor_icons.dart';
|
||||
import 'package:weblibre/utils/exit_app.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||
|
||||
/// Navigation drawer for the browser screen.
|
||||
/// Contains all navigation destinations and settings.
|
||||
@@ -52,7 +57,7 @@ class BrowserNavigationDrawer extends HookConsumerWidget {
|
||||
children: [
|
||||
// Profile Header
|
||||
_ProfileHeader(),
|
||||
|
||||
_SyncTile(),
|
||||
const Divider(),
|
||||
|
||||
// Section 1: Tools & Configuration
|
||||
@@ -327,3 +332,76 @@ class _ExtensionsSection extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync tile widget shown in navigation drawer when sync is active.
|
||||
/// Displays sync status and allows manual sync trigger.
|
||||
class _SyncTile extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isAuthenticated = ref.watch(syncIsAuthenticatedProvider);
|
||||
|
||||
// Only show if sync is active
|
||||
if (!isAuthenticated) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final syncInfo = ref.watch(
|
||||
syncRepositoryProvider.select((value) => value.value?.account),
|
||||
);
|
||||
|
||||
final syncStarted = ref.watch(
|
||||
syncEventProvider.select(
|
||||
(value) => value.isLoading || value.value?.$1 == SyncEvent.started,
|
||||
),
|
||||
);
|
||||
final isSyncing = syncStarted || syncInfo?.syncing == true;
|
||||
|
||||
final controller = useAnimationController(
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
if (isSyncing) {
|
||||
unawaited(controller.repeat());
|
||||
} else {
|
||||
controller.stop();
|
||||
controller.reset();
|
||||
}
|
||||
return null;
|
||||
}, [isSyncing]);
|
||||
|
||||
return ListTile(
|
||||
leading: RotationTransition(
|
||||
turns: Tween<double>(begin: 0, end: -1).animate(controller),
|
||||
child: const Icon(Icons.sync),
|
||||
),
|
||||
title: const Text('Sync Now'),
|
||||
onTap: () async {
|
||||
await ref.read(syncRepositoryProvider.notifier).syncNow();
|
||||
|
||||
final openedTabs = await ref
|
||||
.read(syncRepositoryProvider.notifier)
|
||||
.pollIncomingTabsAndOpen();
|
||||
|
||||
if (context.mounted) {
|
||||
if (openedTabs > 0) {
|
||||
ui_helper.showOpenedTabsFromAnotherDeviceMessage(
|
||||
context,
|
||||
openedTabs,
|
||||
);
|
||||
} else {
|
||||
ui_helper.showInfoMessage(
|
||||
context,
|
||||
'Synchronization complete',
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+97
@@ -26,13 +26,16 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/content_selection_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/qr_code.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||
|
||||
class ShareMenuItemButton extends HookConsumerWidget {
|
||||
const ShareMenuItemButton({super.key, required this.selectedTabId});
|
||||
@@ -287,3 +290,97 @@ class CopyAddressMenuItemButton extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SendTabToDeviceMenuItemButton extends HookConsumerWidget {
|
||||
final String? selectedTabId;
|
||||
|
||||
const SendTabToDeviceMenuItemButton({super.key, required this.selectedTabId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (selectedTabId == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final isAuthenticated = ref.watch(syncIsAuthenticatedProvider);
|
||||
final devices = ref.watch(syncDevicesProvider);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Skeletonizer(
|
||||
enabled: devices.isLoading && devices.value == null,
|
||||
child: SubmenuButton(
|
||||
leadingIcon: const Icon(Icons.send_outlined),
|
||||
menuChildren: devices.when(
|
||||
data: (deviceList) {
|
||||
final targets = deviceList
|
||||
.where((device) => !device.isCurrentDevice && device.canSendTab)
|
||||
.toList(growable: false);
|
||||
|
||||
if (targets.isEmpty) {
|
||||
return const [MenuItemButton(child: Text('No target devices'))];
|
||||
}
|
||||
|
||||
return targets
|
||||
.map((device) {
|
||||
return MenuItemButton(
|
||||
closeOnActivate: false,
|
||||
leadingIcon: const Icon(Icons.devices_other),
|
||||
child: Text(device.displayName),
|
||||
onPressed: () async {
|
||||
final tabState = ref.read(
|
||||
tabStateProvider(selectedTabId),
|
||||
);
|
||||
if (tabState == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final title = tabState.title.isNotEmpty
|
||||
? tabState.title
|
||||
: tabState.url.toString();
|
||||
|
||||
final success = await ref
|
||||
.read(syncRepositoryProvider.notifier)
|
||||
.sendTabToDevice(
|
||||
deviceId: device.deviceId,
|
||||
title: title,
|
||||
url: tabState.url.toString(),
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
if (success) {
|
||||
ui_helper.showInfoMessage(
|
||||
context,
|
||||
'Sent tab to ${device.displayName}',
|
||||
);
|
||||
} else {
|
||||
ui_helper.showErrorMessage(
|
||||
context,
|
||||
'Failed to send tab',
|
||||
);
|
||||
}
|
||||
|
||||
MenuController.maybeOf(context)?.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
},
|
||||
loading: () => const [
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(Icons.devices_other),
|
||||
child: Text('Loading devices...'),
|
||||
),
|
||||
],
|
||||
error: (_, _) => const [
|
||||
MenuItemButton(child: Text('Failed to load devices')),
|
||||
],
|
||||
),
|
||||
child: const Text('Send To Device'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,6 +475,7 @@ class TabMenu extends HookConsumerWidget {
|
||||
OpenInAppMenuItemButton(selectedTabId: selectedTabId),
|
||||
ShareScreenshotMenuItemButton(selectedTabId: selectedTabId),
|
||||
ShareMenuItemButton(selectedTabId: selectedTabId),
|
||||
SendTabToDeviceMenuItemButton(selectedTabId: selectedTabId),
|
||||
ShowQrCodeMenuItemButton(selectedTabId: selectedTabId),
|
||||
],
|
||||
leadingIcon: const Icon(Icons.share),
|
||||
|
||||
+59
-2
@@ -44,6 +44,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selec
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
|
||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
|
||||
class _TabDraggable extends HookConsumerWidget {
|
||||
@@ -149,8 +150,58 @@ class _TabListView extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// final screenWidth = MediaQuery.of(context).size.width;
|
||||
final scope = ref.watch(effectiveTabsTrayScopeProvider);
|
||||
|
||||
return switch (scope) {
|
||||
TabsTrayScope.synced => _buildSyncedTabsView(context, ref),
|
||||
_ => _buildLocalTabsView(context, ref),
|
||||
};
|
||||
}
|
||||
|
||||
Widget _buildSyncedTabsView(BuildContext context, WidgetRef ref) {
|
||||
final syncedTabs = ref.watch(syncedTabsForSelectedDeviceProvider);
|
||||
|
||||
return syncedTabs.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (tabs) {
|
||||
if (tabs.isEmpty) {
|
||||
return const Center(child: Text('No synced tabs available'));
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: tabs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final tab = tabs[index].tab;
|
||||
final uri = Uri.tryParse(tab.url);
|
||||
|
||||
if (uri == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SyncedListTabPreview(
|
||||
title: tab.title.isNotEmpty ? tab.title : tab.url,
|
||||
url: uri,
|
||||
deviceName: tabs[index].deviceName,
|
||||
onTap: () async {
|
||||
await OpenSharedContentRoute(
|
||||
sharedUrl: uri.toString(),
|
||||
).push(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) =>
|
||||
Center(child: Text('Failed to load synced tabs: $error')),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLocalTabsView(BuildContext context, WidgetRef ref) {
|
||||
final containerId = ref.watch(selectedContainerProvider);
|
||||
|
||||
final filteredTabEntities = ref.watch(
|
||||
@@ -417,6 +468,12 @@ class ViewTabListWidget extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isSyncedScope = ref.watch(
|
||||
effectiveTabsTrayScopeProvider.select(
|
||||
(scope) => scope == TabsTrayScope.synced,
|
||||
),
|
||||
);
|
||||
|
||||
final isFabVisible = useState(true);
|
||||
final lastSheetSize = useRef(0.0);
|
||||
final isInitialized = useRef(false);
|
||||
@@ -516,7 +573,7 @@ class ViewTabListWidget extends HookConsumerWidget {
|
||||
onClose: onClose,
|
||||
),
|
||||
),
|
||||
if (showNewTabFab)
|
||||
if (showNewTabFab && !isSyncedScope)
|
||||
AnimatedSlide(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
offset: isFabVisible.value ? Offset.zero : const Offset(0, 2),
|
||||
|
||||
+46
@@ -36,6 +36,7 @@ import 'package:weblibre/features/user/domain/repositories/general_settings.dart
|
||||
import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
||||
import 'package:weblibre/presentation/widgets/safe_raw_image.dart';
|
||||
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||
|
||||
class GridTabItemContainer extends StatelessWidget {
|
||||
@@ -378,6 +379,51 @@ class ListTabPreview extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class SyncedListTabPreview extends StatelessWidget {
|
||||
const SyncedListTabPreview({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.deviceName,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Uri url;
|
||||
final String deviceName;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
onTap: onTap,
|
||||
contentPadding: const EdgeInsets.only(left: 8, right: 6),
|
||||
leading: UrlIcon([url], iconSize: 20),
|
||||
title: Text(title, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.devices, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
deviceName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
UriBreadcrumb(uri: url),
|
||||
],
|
||||
),
|
||||
trailing: const Icon(Icons.open_in_new),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SingleGridTabPreview extends HookConsumerWidget {
|
||||
final String tabId;
|
||||
final String? activeTabId;
|
||||
|
||||
+279
-172
@@ -43,6 +43,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selec
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart';
|
||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
|
||||
import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
@@ -50,6 +51,151 @@ import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
||||
import 'package:weblibre/presentation/widgets/speech_to_text_button.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||
|
||||
/// Widget for tab filters (container chips with synced option)
|
||||
class _TabFilters extends ConsumerWidget {
|
||||
final TabsViewMode tabsViewMode;
|
||||
|
||||
const _TabFilters({required this.tabsViewMode});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isSyncedScope = ref.watch(
|
||||
effectiveTabsTrayScopeProvider.select(
|
||||
(scope) => scope == TabsTrayScope.synced,
|
||||
),
|
||||
);
|
||||
|
||||
final selectedContainer = ref.watch(
|
||||
selectedContainerDataProvider.select((value) => value.value),
|
||||
);
|
||||
|
||||
final isAuthenticated = ref.watch(syncIsAuthenticatedProvider);
|
||||
final syncedTabCountAsync = ref.watch(syncedTabsTotalCountProvider);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ContainerChips(
|
||||
showGroupSuggestions: switch (tabsViewMode) {
|
||||
TabsViewMode.list || TabsViewMode.grid => true,
|
||||
TabsViewMode.tree => false,
|
||||
},
|
||||
enableDragAndDrop: switch (tabsViewMode) {
|
||||
TabsViewMode.list || TabsViewMode.grid => true,
|
||||
TabsViewMode.tree => false,
|
||||
},
|
||||
showSyncedChip: isAuthenticated && tabsViewMode != TabsViewMode.tree,
|
||||
syncedChipSelected: isSyncedScope,
|
||||
syncedTabCount: syncedTabCountAsync.when(
|
||||
data: (count) => count,
|
||||
loading: () => 0,
|
||||
error: (_, _) => 0,
|
||||
),
|
||||
onSyncedChipSelected: () {
|
||||
ref.read(tabsTrayScopeControllerProvider.notifier).showSynced();
|
||||
},
|
||||
selectedContainer: selectedContainer,
|
||||
onSelected: (container) async {
|
||||
ref.read(tabsTrayScopeControllerProvider.notifier).showLocal();
|
||||
|
||||
if (container != null) {
|
||||
final result = await ref
|
||||
.read(selectedContainerProvider.notifier)
|
||||
.setContainerId(container.id);
|
||||
|
||||
if (context.mounted &&
|
||||
result == SetContainerResult.successHasProxy) {
|
||||
final shouldStartProxy = await ref
|
||||
.read(startProxyControllerProvider.notifier)
|
||||
.shouldPromptProxyStart();
|
||||
|
||||
if (!context.mounted || !shouldStartProxy) return;
|
||||
|
||||
final dialogResult = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return const TorDialog();
|
||||
},
|
||||
);
|
||||
|
||||
if (dialogResult == true) {
|
||||
await ref
|
||||
.read(startProxyControllerProvider.notifier)
|
||||
.startProxy();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ref.read(selectedContainerProvider.notifier).clearContainer();
|
||||
}
|
||||
},
|
||||
onDeleted: (container) {
|
||||
ref.read(tabsTrayScopeControllerProvider.notifier).showLocal();
|
||||
ref.read(selectedContainerProvider.notifier).clearContainer();
|
||||
},
|
||||
onLongPress: (container) async {
|
||||
await ContainerEditRoute(
|
||||
containerData: jsonEncode(container.toJson()),
|
||||
).push(context);
|
||||
},
|
||||
),
|
||||
if (isSyncedScope) ...[
|
||||
const SizedBox(height: 8),
|
||||
_SyncedDeviceSelector(),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget for synced device selector
|
||||
class _SyncedDeviceSelector extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final remoteDevicesAsync = ref.watch(syncRemoteTabsProvider);
|
||||
final selectedDeviceId = ref.watch(selectedSyncedTabsDeviceIdProvider);
|
||||
|
||||
return remoteDevicesAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (devices) {
|
||||
if (devices.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final effectiveSelectedDeviceId =
|
||||
selectedDeviceId != null &&
|
||||
devices.any((device) => device.deviceId == selectedDeviceId)
|
||||
? selectedDeviceId
|
||||
: devices.first.deviceId;
|
||||
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: devices
|
||||
.map((device) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: ChoiceChip(
|
||||
label: Text(device.deviceName),
|
||||
selected: effectiveSelectedDeviceId == device.deviceId,
|
||||
onSelected: (_) {
|
||||
ref
|
||||
.read(selectedSyncedTabsDeviceIdProvider.notifier)
|
||||
.selectDevice(device.deviceId);
|
||||
},
|
||||
),
|
||||
);
|
||||
})
|
||||
.toList(growable: false),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TabViewHeader extends HookConsumerWidget {
|
||||
static const headerSize = 124.0;
|
||||
|
||||
@@ -82,6 +228,13 @@ class TabViewHeader extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
|
||||
final selectedContainerId = ref.watch(selectedContainerProvider);
|
||||
final isSyncedScope = ref.watch(
|
||||
effectiveTabsTrayScopeProvider.select(
|
||||
(scope) => scope == TabsTrayScope.synced,
|
||||
),
|
||||
);
|
||||
|
||||
useOnListenableChange(searchTextController, () async {
|
||||
if (ref.exists(tabSearchRepositoryProvider(TabSearchPartition.preview))) {
|
||||
await ref
|
||||
@@ -153,13 +306,15 @@ class TabViewHeader extends HookConsumerWidget {
|
||||
.toList(),
|
||||
child: IconButton(
|
||||
tooltip: 'Change view mode',
|
||||
onPressed: () {
|
||||
if (viewModeMenuController.isOpen) {
|
||||
viewModeMenuController.close();
|
||||
} else {
|
||||
viewModeMenuController.open();
|
||||
}
|
||||
},
|
||||
onPressed: isSyncedScope
|
||||
? null
|
||||
: () {
|
||||
if (viewModeMenuController.isOpen) {
|
||||
viewModeMenuController.close();
|
||||
} else {
|
||||
viewModeMenuController.open();
|
||||
}
|
||||
},
|
||||
icon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -278,122 +433,133 @@ class TabViewHeader extends HookConsumerWidget {
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.closeCircle),
|
||||
onPressed: isSyncedScope
|
||||
? null
|
||||
: () async {
|
||||
final result = await showCloseAllTabsDialog(
|
||||
context,
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
final count = await ref
|
||||
.read(
|
||||
tabDataRepositoryProvider.notifier,
|
||||
)
|
||||
.closeContainerTabs(
|
||||
selectedContainerId,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ui_helper.showTabUndoClose(
|
||||
context,
|
||||
ref
|
||||
.read(
|
||||
tabRepositoryProvider.notifier,
|
||||
)
|
||||
.undoClose,
|
||||
count: count.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Close All Tabs'),
|
||||
onPressed: () async {
|
||||
final result = await showCloseAllTabsDialog(
|
||||
context,
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
final container = ref.read(
|
||||
selectedContainerProvider,
|
||||
);
|
||||
|
||||
final count = await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.closeContainerTabs(container);
|
||||
|
||||
if (context.mounted) {
|
||||
ui_helper.showTabUndoClose(
|
||||
context,
|
||||
ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.undoClose,
|
||||
count: count.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.incognitoCircleOff),
|
||||
onPressed: isSyncedScope
|
||||
? null
|
||||
: () async {
|
||||
final result =
|
||||
await showCloseAllPrivateTabsDialog(
|
||||
context,
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
final count = await ref
|
||||
.read(
|
||||
tabDataRepositoryProvider.notifier,
|
||||
)
|
||||
.closeContainerTabs(
|
||||
selectedContainerId,
|
||||
includeRegular: false,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ui_helper.showTabUndoClose(
|
||||
context,
|
||||
ref
|
||||
.read(
|
||||
tabRepositoryProvider.notifier,
|
||||
)
|
||||
.undoClose,
|
||||
count: count.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Close Private Tabs'),
|
||||
onPressed: () async {
|
||||
final result = await showCloseAllPrivateTabsDialog(
|
||||
context,
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
final container = ref.read(
|
||||
selectedContainerProvider,
|
||||
);
|
||||
|
||||
final count = await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.closeContainerTabs(
|
||||
container,
|
||||
includeRegular: false,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ui_helper.showTabUndoClose(
|
||||
context,
|
||||
ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.undoClose,
|
||||
count: count.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.bookmarkPlusOutline),
|
||||
child: const Text('Bookmark all'),
|
||||
onPressed: () async {
|
||||
final choice = await showBookmarkAllDialog(context);
|
||||
if (choice == null || !context.mounted) return;
|
||||
|
||||
final containerId = ref.read(
|
||||
selectedContainerProvider,
|
||||
);
|
||||
|
||||
final tabData = await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.getContainerTabsData(containerId);
|
||||
|
||||
if (choice == BookmarkAllChoice.fast) {
|
||||
if (!context.mounted) return;
|
||||
final folderGuid = await showSelectFolderDialog(
|
||||
context,
|
||||
);
|
||||
if (folderGuid == null) return;
|
||||
|
||||
final repo = ref.read(
|
||||
bookmarksRepositoryProvider.notifier,
|
||||
);
|
||||
for (final tab in tabData) {
|
||||
if (tab.url != null) {
|
||||
await repo.addBookmark(
|
||||
parentGuid: folderGuid,
|
||||
url: tab.url!,
|
||||
title: tab.title ?? tab.url.toString(),
|
||||
onPressed: isSyncedScope
|
||||
? null
|
||||
: () async {
|
||||
final choice = await showBookmarkAllDialog(
|
||||
context,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (choice == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ui_helper.showInfoMessage(
|
||||
context,
|
||||
'${tabData.length} bookmark(s) added',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (final tab in tabData) {
|
||||
if (context.mounted) {
|
||||
await BookmarkEntryAddRoute(
|
||||
bookmarkInfo: jsonEncode(
|
||||
BookmarkInfo(
|
||||
title: tab.title,
|
||||
url: tab.url.toString(),
|
||||
).encode(),
|
||||
),
|
||||
).push(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
final tabData = await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.getContainerTabsData(
|
||||
selectedContainerId,
|
||||
);
|
||||
|
||||
if (choice == BookmarkAllChoice.fast) {
|
||||
if (!context.mounted) return;
|
||||
final folderGuid =
|
||||
await showSelectFolderDialog(context);
|
||||
if (folderGuid == null) return;
|
||||
|
||||
final repo = ref.read(
|
||||
bookmarksRepositoryProvider.notifier,
|
||||
);
|
||||
for (final tab in tabData) {
|
||||
if (tab.url != null) {
|
||||
await repo.addBookmark(
|
||||
parentGuid: folderGuid,
|
||||
url: tab.url!,
|
||||
title:
|
||||
tab.title ?? tab.url.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ui_helper.showInfoMessage(
|
||||
context,
|
||||
'${tabData.length} bookmark(s) added',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (final tab in tabData) {
|
||||
if (context.mounted) {
|
||||
await BookmarkEntryAddRoute(
|
||||
bookmarkInfo: jsonEncode(
|
||||
BookmarkInfo(
|
||||
title: tab.title,
|
||||
url: tab.url.toString(),
|
||||
).encode(),
|
||||
),
|
||||
).push(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Bookmark all'),
|
||||
),
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
@@ -582,66 +748,7 @@ class TabViewHeader extends HookConsumerWidget {
|
||||
),
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final selectedContainer = ref.watch(
|
||||
selectedContainerDataProvider.select(
|
||||
(value) => value.value,
|
||||
),
|
||||
);
|
||||
|
||||
return ContainerChips(
|
||||
showGroupSuggestions: switch (tabsViewMode) {
|
||||
TabsViewMode.list || TabsViewMode.grid => true,
|
||||
TabsViewMode.tree => false,
|
||||
},
|
||||
enableDragAndDrop: switch (tabsViewMode) {
|
||||
TabsViewMode.list || TabsViewMode.grid => true,
|
||||
TabsViewMode.tree => false,
|
||||
},
|
||||
selectedContainer: selectedContainer,
|
||||
onSelected: (container) async {
|
||||
if (container != null) {
|
||||
final result = await ref
|
||||
.read(selectedContainerProvider.notifier)
|
||||
.setContainerId(container.id);
|
||||
|
||||
if (context.mounted &&
|
||||
result == SetContainerResult.successHasProxy) {
|
||||
final shouldStartProxy = await ref
|
||||
.read(startProxyControllerProvider.notifier)
|
||||
.shouldPromptProxyStart();
|
||||
|
||||
if (!context.mounted || !shouldStartProxy) return;
|
||||
|
||||
final dialogResult = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return const TorDialog();
|
||||
},
|
||||
);
|
||||
|
||||
if (dialogResult == true) {
|
||||
await ref
|
||||
.read(startProxyControllerProvider.notifier)
|
||||
.startProxy();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ref
|
||||
.read(selectedContainerProvider.notifier)
|
||||
.clearContainer();
|
||||
}
|
||||
},
|
||||
onDeleted: (container) {
|
||||
ref
|
||||
.read(selectedContainerProvider.notifier)
|
||||
.clearContainer();
|
||||
},
|
||||
onLongPress: (container) async {
|
||||
await ContainerEditRoute(
|
||||
containerData: jsonEncode(container.toJson()),
|
||||
).push(context);
|
||||
},
|
||||
);
|
||||
return _TabFilters(tabsViewMode: tabsViewMode);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
+1
-1
@@ -178,7 +178,7 @@ class FeedSearch extends HookConsumerWidget {
|
||||
child: Text(
|
||||
ref
|
||||
.read(formatProvider.notifier)
|
||||
.fullDateTimeWithTimezone(articleDate),
|
||||
.fullDateTime(articleDate),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
|
||||
@@ -186,7 +186,7 @@ class ContainerDao extends DatabaseAccessor<TabDatabase>
|
||||
);
|
||||
}
|
||||
|
||||
SingleSelectable<String> siteAssignedContainerId(Uri uri) {
|
||||
Selectable<String> siteAssignedContainerId(Uri uri) {
|
||||
return db.definitionsDrift.siteAssignedContainerId(uri: uri.origin);
|
||||
}
|
||||
|
||||
|
||||
@@ -174,7 +174,8 @@ class ContainerRepository extends _$ContainerRepository {
|
||||
.read(tabDatabaseProvider)
|
||||
.containerDao
|
||||
.siteAssignedContainerId(uri)
|
||||
.getSingle();
|
||||
.get()
|
||||
.then((value) => value.firstOrNull);
|
||||
}
|
||||
|
||||
Future<List<String>> getContainersToClearOnExit() async {
|
||||
|
||||
@@ -42,7 +42,7 @@ final class ContainerRepositoryProvider
|
||||
}
|
||||
|
||||
String _$containerRepositoryHash() =>
|
||||
r'62c2b06970db4385ea0d2edc68bcf076c291a9b6';
|
||||
r'5e46b6d9d3510aeeb70646ede75d71c2db9efb0f';
|
||||
|
||||
abstract class _$ContainerRepository extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
@@ -39,12 +39,12 @@ import 'package:weblibre/presentation/widgets/selectable_chips.dart';
|
||||
|
||||
class _UnassignedContainerChip extends ConsumerWidget {
|
||||
final int? Function()? containerBadgeCount;
|
||||
final ContainerData? selectedContainer;
|
||||
final bool selected;
|
||||
final void Function(ContainerDataWithCount?)? onSelected;
|
||||
|
||||
const _UnassignedContainerChip({
|
||||
required this.containerBadgeCount,
|
||||
required this.selectedContainer,
|
||||
required this.selected,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ class _UnassignedContainerChip extends ConsumerWidget {
|
||||
label: (tabCount > 0)
|
||||
? Text(tabCount.toString())
|
||||
: const SizedBox.shrink(),
|
||||
selected: selectedContainer == null,
|
||||
selected: selected,
|
||||
showCheckmark: false,
|
||||
onSelected: (value) {
|
||||
if (value) {
|
||||
@@ -76,6 +76,34 @@ class _UnassignedContainerChip extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SyncedTabsChip extends ConsumerWidget {
|
||||
final bool selected;
|
||||
final int count;
|
||||
final VoidCallback onSelected;
|
||||
|
||||
const _SyncedTabsChip({
|
||||
required this.selected,
|
||||
required this.count,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return FilterChip(
|
||||
avatar: const Icon(Icons.devices_other),
|
||||
labelPadding: count > 0 ? null : const EdgeInsets.only(right: 2.0),
|
||||
label: count > 0 ? Text(count.toString()) : const SizedBox.shrink(),
|
||||
selected: selected,
|
||||
showCheckmark: false,
|
||||
onSelected: (value) {
|
||||
if (value) {
|
||||
onSelected();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ContainerSuggestionsChip extends ConsumerWidget {
|
||||
const _ContainerSuggestionsChip();
|
||||
|
||||
@@ -135,6 +163,11 @@ class ContainerChips extends HookConsumerWidget {
|
||||
final bool showUnassignedChip;
|
||||
final bool showGroupSuggestions;
|
||||
final bool enableDragAndDrop;
|
||||
final bool showSyncedChip;
|
||||
final bool syncedChipSelected;
|
||||
final bool? unassignedChipSelected;
|
||||
final int syncedTabCount;
|
||||
final VoidCallback? onSyncedChipSelected;
|
||||
|
||||
final ContainerData? selectedContainer;
|
||||
final bool Function(ContainerDataWithCount)? containerFilter;
|
||||
@@ -157,6 +190,11 @@ class ContainerChips extends HookConsumerWidget {
|
||||
this.showUnassignedChip = true,
|
||||
this.showGroupSuggestions = false,
|
||||
this.enableDragAndDrop = true,
|
||||
this.showSyncedChip = false,
|
||||
this.syncedChipSelected = false,
|
||||
this.unassignedChipSelected,
|
||||
this.syncedTabCount = 0,
|
||||
this.onSyncedChipSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -217,6 +255,12 @@ class ContainerChips extends HookConsumerWidget {
|
||||
}
|
||||
: null,
|
||||
prefixListItems: [
|
||||
if (showSyncedChip)
|
||||
_SyncedTabsChip(
|
||||
selected: syncedChipSelected,
|
||||
count: syncedTabCount,
|
||||
onSelected: onSyncedChipSelected ?? () {},
|
||||
),
|
||||
if (showUnassignedChip)
|
||||
enableDragAndDrop
|
||||
? TabDragContainerTarget(
|
||||
@@ -224,14 +268,20 @@ class ContainerChips extends HookConsumerWidget {
|
||||
child: _UnassignedContainerChip(
|
||||
containerBadgeCount: () =>
|
||||
containerBadgeCount?.call(null),
|
||||
selectedContainer: selectedContainer,
|
||||
selected:
|
||||
unassignedChipSelected ??
|
||||
(selectedContainer == null &&
|
||||
!syncedChipSelected),
|
||||
onSelected: onSelected,
|
||||
),
|
||||
)
|
||||
: _UnassignedContainerChip(
|
||||
containerBadgeCount: () =>
|
||||
containerBadgeCount?.call(null),
|
||||
selectedContainer: selectedContainer,
|
||||
selected:
|
||||
unassignedChipSelected ??
|
||||
(selectedContainer == null &&
|
||||
!syncedChipSelected),
|
||||
onSelected: onSelected,
|
||||
),
|
||||
if (showGroupSuggestions)
|
||||
|
||||
@@ -41,6 +41,7 @@ class SettingsScreen extends HookConsumerWidget {
|
||||
_PrivacySecurityTile(),
|
||||
_SearchContentTile(),
|
||||
_TabsBehaviorTile(),
|
||||
_SyncTile(),
|
||||
_FingerprintingTile(),
|
||||
_AdvancedTile(),
|
||||
],
|
||||
@@ -176,6 +177,31 @@ class _FingerprintingTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SyncTile extends StatelessWidget {
|
||||
const _SyncTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
color: Theme.of(context).highlightColor,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
title: const Text('Firefox Sync'),
|
||||
subtitle: const Text('Account, sync now, engine selection'),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0,
|
||||
horizontal: 16.0,
|
||||
),
|
||||
leading: const Icon(Icons.sync),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () async {
|
||||
await SyncSettingsRoute().push(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdvancedTile extends StatelessWidget {
|
||||
const _AdvancedTile();
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
|
||||
part 'sync_repository_state.g.dart';
|
||||
|
||||
enum SyncEvent {
|
||||
started,
|
||||
completed,
|
||||
error;
|
||||
}
|
||||
|
||||
@CopyWith()
|
||||
class SyncRepositoryState with FastEquatable {
|
||||
final SyncAccountInfo account;
|
||||
final List<SyncDeviceTabs> remoteTabs;
|
||||
final List<SyncDevice> devices;
|
||||
final String? deviceName;
|
||||
final SyncEvent? lastSyncEvent;
|
||||
final String? lastSyncError;
|
||||
|
||||
SyncRepositoryState({
|
||||
required this.account,
|
||||
this.remoteTabs = const <SyncDeviceTabs>[],
|
||||
this.devices = const <SyncDevice>[],
|
||||
this.deviceName,
|
||||
this.lastSyncEvent,
|
||||
this.lastSyncError,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters =>
|
||||
[account, remoteTabs, devices, deviceName, lastSyncEvent, lastSyncError];
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'sync_repository_state.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// CopyWithGenerator
|
||||
// **************************************************************************
|
||||
|
||||
abstract class _$SyncRepositoryStateCWProxy {
|
||||
SyncRepositoryState account(SyncAccountInfo account);
|
||||
|
||||
SyncRepositoryState remoteTabs(List<SyncDeviceTabs> remoteTabs);
|
||||
|
||||
SyncRepositoryState devices(List<SyncDevice> devices);
|
||||
|
||||
SyncRepositoryState deviceName(String? deviceName);
|
||||
|
||||
SyncRepositoryState lastSyncEvent(SyncEvent? lastSyncEvent);
|
||||
|
||||
SyncRepositoryState lastSyncError(String? lastSyncError);
|
||||
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `SyncRepositoryState(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// SyncRepositoryState(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
SyncRepositoryState call({
|
||||
SyncAccountInfo account,
|
||||
List<SyncDeviceTabs> remoteTabs,
|
||||
List<SyncDevice> devices,
|
||||
String? deviceName,
|
||||
SyncEvent? lastSyncEvent,
|
||||
String? lastSyncError,
|
||||
});
|
||||
}
|
||||
|
||||
/// Callable proxy for `copyWith` functionality.
|
||||
/// Use as `instanceOfSyncRepositoryState.copyWith(...)` or call `instanceOfSyncRepositoryState.copyWith.fieldName(value)` for a single field.
|
||||
class _$SyncRepositoryStateCWProxyImpl implements _$SyncRepositoryStateCWProxy {
|
||||
const _$SyncRepositoryStateCWProxyImpl(this._value);
|
||||
|
||||
final SyncRepositoryState _value;
|
||||
|
||||
@override
|
||||
SyncRepositoryState account(SyncAccountInfo account) =>
|
||||
call(account: account);
|
||||
|
||||
@override
|
||||
SyncRepositoryState remoteTabs(List<SyncDeviceTabs> remoteTabs) =>
|
||||
call(remoteTabs: remoteTabs);
|
||||
|
||||
@override
|
||||
SyncRepositoryState devices(List<SyncDevice> devices) =>
|
||||
call(devices: devices);
|
||||
|
||||
@override
|
||||
SyncRepositoryState deviceName(String? deviceName) =>
|
||||
call(deviceName: deviceName);
|
||||
|
||||
@override
|
||||
SyncRepositoryState lastSyncEvent(SyncEvent? lastSyncEvent) =>
|
||||
call(lastSyncEvent: lastSyncEvent);
|
||||
|
||||
@override
|
||||
SyncRepositoryState lastSyncError(String? lastSyncError) =>
|
||||
call(lastSyncError: lastSyncError);
|
||||
|
||||
@override
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `SyncRepositoryState(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// SyncRepositoryState(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
SyncRepositoryState call({
|
||||
Object? account = const $CopyWithPlaceholder(),
|
||||
Object? remoteTabs = const $CopyWithPlaceholder(),
|
||||
Object? devices = const $CopyWithPlaceholder(),
|
||||
Object? deviceName = const $CopyWithPlaceholder(),
|
||||
Object? lastSyncEvent = const $CopyWithPlaceholder(),
|
||||
Object? lastSyncError = const $CopyWithPlaceholder(),
|
||||
}) {
|
||||
return SyncRepositoryState(
|
||||
account: account == const $CopyWithPlaceholder() || account == null
|
||||
? _value.account
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: account as SyncAccountInfo,
|
||||
remoteTabs:
|
||||
remoteTabs == const $CopyWithPlaceholder() || remoteTabs == null
|
||||
? _value.remoteTabs
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: remoteTabs as List<SyncDeviceTabs>,
|
||||
devices: devices == const $CopyWithPlaceholder() || devices == null
|
||||
? _value.devices
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: devices as List<SyncDevice>,
|
||||
deviceName: deviceName == const $CopyWithPlaceholder()
|
||||
? _value.deviceName
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: deviceName as String?,
|
||||
lastSyncEvent: lastSyncEvent == const $CopyWithPlaceholder()
|
||||
? _value.lastSyncEvent
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: lastSyncEvent as SyncEvent?,
|
||||
lastSyncError: lastSyncError == const $CopyWithPlaceholder()
|
||||
? _value.lastSyncError
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: lastSyncError as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension $SyncRepositoryStateCopyWith on SyncRepositoryState {
|
||||
/// Returns a callable class used to build a new instance with modified fields.
|
||||
/// Example: `instanceOfSyncRepositoryState.copyWith(...)` or `instanceOfSyncRepositoryState.copyWith.fieldName(...)`.
|
||||
// ignore: library_private_types_in_public_api
|
||||
_$SyncRepositoryStateCWProxy get copyWith =>
|
||||
_$SyncRepositoryStateCWProxyImpl(this);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
|
||||
class SyncedTabItem with FastEquatable {
|
||||
final String deviceId;
|
||||
final String deviceName;
|
||||
final SyncRemoteTab tab;
|
||||
|
||||
SyncedTabItem({
|
||||
required this.deviceId,
|
||||
required this.deviceName,
|
||||
required this.tab,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [deviceId, deviceName, tab];
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||
import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
|
||||
import 'package:weblibre/features/sync/domain/entities/synced_tab_item.dart';
|
||||
|
||||
part 'sync.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
bool syncIsAuthenticated(Ref ref) {
|
||||
return ref.watch(
|
||||
syncRepositoryProvider.select(
|
||||
(value) => value.value?.account.authenticated == true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
enum TabsTrayScope { local, synced }
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class TabsTrayScopeController extends _$TabsTrayScopeController {
|
||||
@override
|
||||
TabsTrayScope build() => TabsTrayScope.local;
|
||||
|
||||
void showLocal() {
|
||||
state = TabsTrayScope.local;
|
||||
}
|
||||
|
||||
void showSynced() {
|
||||
state = TabsTrayScope.synced;
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
TabsTrayScope effectiveTabsTrayScope(Ref ref) {
|
||||
final scope = ref.watch(tabsTrayScopeControllerProvider);
|
||||
final isAuthenticated = ref.watch(syncIsAuthenticatedProvider);
|
||||
|
||||
if (!isAuthenticated && scope == TabsTrayScope.synced) {
|
||||
return TabsTrayScope.local;
|
||||
}
|
||||
|
||||
return scope;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class SelectedSyncedTabsDeviceId extends _$SelectedSyncedTabsDeviceId {
|
||||
@override
|
||||
String? build() => null;
|
||||
|
||||
// ignore: use_setters_to_change_properties
|
||||
void selectDevice(String? value) {
|
||||
state = value;
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<int> syncedTabsTotalCount(Ref ref) {
|
||||
return ref.watch(
|
||||
syncRemoteTabsProvider.selectAsync(
|
||||
(devices) =>
|
||||
devices.fold<int>(0, (count, device) => count + device.tabs.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<String?> effectiveSyncedTabsDeviceId(Ref ref) async {
|
||||
final devices = await ref.watch(
|
||||
syncRemoteTabsProvider.selectAsync((tabs) => tabs),
|
||||
);
|
||||
final selectedDeviceId = ref.watch(selectedSyncedTabsDeviceIdProvider);
|
||||
|
||||
if (devices.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selectedDeviceId != null &&
|
||||
devices.any((device) => device.deviceId == selectedDeviceId)) {
|
||||
return selectedDeviceId;
|
||||
}
|
||||
|
||||
return devices.first.deviceId;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<List<SyncedTabItem>> syncedTabsForSelectedDevice(Ref ref) async {
|
||||
final devices = await ref.watch(
|
||||
syncRemoteTabsProvider.selectAsync((tabs) => tabs),
|
||||
);
|
||||
|
||||
final selectedDeviceId = ref.watch(selectedSyncedTabsDeviceIdProvider);
|
||||
|
||||
final effectiveSelectedDeviceId =
|
||||
selectedDeviceId != null &&
|
||||
devices.any((device) => device.deviceId == selectedDeviceId)
|
||||
? selectedDeviceId
|
||||
: devices.firstOrNull?.deviceId;
|
||||
|
||||
if (effectiveSelectedDeviceId == null) {
|
||||
return const <SyncedTabItem>[];
|
||||
}
|
||||
|
||||
final device = devices.firstWhereOrNull(
|
||||
(item) => item.deviceId == effectiveSelectedDeviceId,
|
||||
);
|
||||
|
||||
if (device == null) {
|
||||
return const <SyncedTabItem>[];
|
||||
}
|
||||
|
||||
final tabs = device.tabs
|
||||
.map(
|
||||
(tab) => SyncedTabItem(
|
||||
deviceId: device.deviceId,
|
||||
deviceName: device.deviceName,
|
||||
tab: tab,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
|
||||
tabs.sort((a, b) => b.tab.lastUsed.compareTo(a.tab.lastUsed));
|
||||
return tabs;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class SyncRepository extends _$SyncRepository {
|
||||
final _service = GeckoSyncService();
|
||||
|
||||
StreamSubscription<SyncAccountInfo>? _authStateSub;
|
||||
StreamSubscription<void>? _syncStartedSub;
|
||||
StreamSubscription<void>? _syncCompletedSub;
|
||||
StreamSubscription<String?>? _syncErrorSub;
|
||||
|
||||
Future<void> _awaitInitialized() => future;
|
||||
|
||||
void _update(SyncRepositoryState Function(SyncRepositoryState) updater) {
|
||||
final current = state.value;
|
||||
if (current != null) {
|
||||
state = AsyncData(updater(current));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshAccount() async {
|
||||
await _awaitInitialized();
|
||||
|
||||
final account = await _service.getAccountInfo();
|
||||
_update((s) => s.copyWith(account: account));
|
||||
}
|
||||
|
||||
Future<void> _refreshTabs() async {
|
||||
await _awaitInitialized();
|
||||
|
||||
try {
|
||||
final tabs = await _service.getSyncedTabs();
|
||||
_update((s) => s.copyWith(remoteTabs: tabs));
|
||||
} on Exception catch (e) {
|
||||
logger.e('Failed to refresh synced tabs', error: e);
|
||||
_update(
|
||||
(s) => s.copyWith(
|
||||
lastSyncEvent: SyncEvent.error,
|
||||
lastSyncError: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshDevices() async {
|
||||
await _awaitInitialized();
|
||||
|
||||
try {
|
||||
final devices = await _service.getDevices();
|
||||
_update((s) => s.copyWith(devices: devices));
|
||||
} on Exception catch (e) {
|
||||
logger.e('Failed to refresh devices', error: e);
|
||||
_update(
|
||||
(s) => s.copyWith(
|
||||
lastSyncEvent: SyncEvent.error,
|
||||
lastSyncError: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshDeviceName() async {
|
||||
await _awaitInitialized();
|
||||
|
||||
final deviceName = await _service.getDeviceName();
|
||||
_update((s) => s.copyWith(deviceName: deviceName));
|
||||
}
|
||||
|
||||
Future<SyncAccountInfo> refresh() async {
|
||||
await _refreshAccount();
|
||||
return state.value!.account;
|
||||
}
|
||||
|
||||
Future<void> signIn() async {
|
||||
await _service.beginAuthentication();
|
||||
}
|
||||
|
||||
Future<void> signInWithPairing(String pairingUrl) async {
|
||||
await _service.beginPairingAuthentication(pairingUrl);
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
await _service.logout();
|
||||
}
|
||||
|
||||
Future<void> syncNow() async {
|
||||
await _service.syncNow();
|
||||
await Future.wait([_refreshAccount(), _refreshTabs(), _refreshDevices()]);
|
||||
}
|
||||
|
||||
Future<void> setEngineEnabled(SyncEngineValue engine, bool enabled) async {
|
||||
await _service.setEngineEnabled(engine, enabled);
|
||||
await Future.wait([_refreshAccount(), _refreshTabs(), _refreshDevices()]);
|
||||
}
|
||||
|
||||
Future<bool> sendTabToDevice({
|
||||
required String deviceId,
|
||||
required String title,
|
||||
required String url,
|
||||
}) {
|
||||
return _service.sendTabToDevice(deviceId, title, url);
|
||||
}
|
||||
|
||||
Future<bool> setDeviceName(String newName) async {
|
||||
final result = await _service.setDeviceName(newName);
|
||||
|
||||
if (result) {
|
||||
await Future.wait([_refreshDevices(), _refreshDeviceName()]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> refreshDevices() async {
|
||||
await _service.refreshDevices();
|
||||
await _refreshDevices();
|
||||
}
|
||||
|
||||
Future<int> pollIncomingTabsAndOpen() async {
|
||||
await _service.pollDeviceCommands();
|
||||
final incomingTabs = await _service.drainIncomingTabs();
|
||||
|
||||
for (final tab in incomingTabs) {
|
||||
await _openUrlInAssignedContainer(tab.url);
|
||||
}
|
||||
|
||||
await _refreshTabs();
|
||||
return incomingTabs.length;
|
||||
}
|
||||
|
||||
Future<void> openSyncedTab(SyncRemoteTab tab) async {
|
||||
await _openUrlInAssignedContainer(tab.url);
|
||||
}
|
||||
|
||||
Future<void> _openUrlInAssignedContainer(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final containerRepository = ref.read(containerRepositoryProvider.notifier);
|
||||
|
||||
final containerId = await containerRepository.siteAssignedContainerId(uri);
|
||||
|
||||
final assignedContainer = containerId == null
|
||||
? null
|
||||
: await containerRepository.getContainerData(containerId);
|
||||
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: uri,
|
||||
selectTab: true,
|
||||
private: false,
|
||||
container: Value(assignedContainer),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SyncRepositoryState> build() async {
|
||||
final syncStateService = ref.read(geckoSyncStateServiceProvider);
|
||||
|
||||
_syncStartedSub = syncStateService.syncStartedEvents.listen((_) {
|
||||
_update(
|
||||
(s) => s.copyWith(
|
||||
lastSyncEvent: SyncEvent.started,
|
||||
// ignore: avoid_redundant_argument_values
|
||||
lastSyncError: null,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
_syncCompletedSub = syncStateService.syncCompletedEvents.listen((_) {
|
||||
_update(
|
||||
(s) => s.copyWith(
|
||||
lastSyncEvent: SyncEvent.completed,
|
||||
// ignore: avoid_redundant_argument_values
|
||||
lastSyncError: null,
|
||||
),
|
||||
);
|
||||
unawaited(Future.wait([_refreshTabs(), _refreshDevices()]));
|
||||
});
|
||||
|
||||
_syncErrorSub = syncStateService.syncErrorEvents.listen((error) {
|
||||
logger.e('Sync failed', error: error ?? 'Unknown synchronization error');
|
||||
_update(
|
||||
(s) => s.copyWith(lastSyncEvent: SyncEvent.error, lastSyncError: error),
|
||||
);
|
||||
unawaited(Future.wait([_refreshTabs(), _refreshDevices()]));
|
||||
});
|
||||
|
||||
_authStateSub = syncStateService.authStateEvents.listen((info) {
|
||||
final previous = state.value;
|
||||
|
||||
if (previous == null) {
|
||||
state = AsyncData(SyncRepositoryState(account: info));
|
||||
return;
|
||||
}
|
||||
|
||||
final unauthenticated = !info.authenticated;
|
||||
state = AsyncData(
|
||||
unauthenticated
|
||||
? SyncRepositoryState(account: info)
|
||||
: previous.copyWith(account: info),
|
||||
);
|
||||
|
||||
final authStateChanged =
|
||||
previous.account.authenticated != info.authenticated ||
|
||||
previous.account.needsReauth != info.needsReauth;
|
||||
|
||||
if (authStateChanged && info.authenticated) {
|
||||
unawaited(
|
||||
Future.wait([
|
||||
_refreshAccount(),
|
||||
_refreshTabs(),
|
||||
_refreshDevices(),
|
||||
_refreshDeviceName(),
|
||||
]),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ref.onDispose(() {
|
||||
unawaited(_authStateSub?.cancel());
|
||||
unawaited(_syncStartedSub?.cancel());
|
||||
unawaited(_syncCompletedSub?.cancel());
|
||||
unawaited(_syncErrorSub?.cancel());
|
||||
});
|
||||
|
||||
final (account, tabs, devices, deviceName) = await (
|
||||
_service.getAccountInfo(),
|
||||
_service.getSyncedTabs(),
|
||||
_service.getDevices(),
|
||||
_service.getDeviceName(),
|
||||
).wait;
|
||||
|
||||
return SyncRepositoryState(
|
||||
account: account,
|
||||
remoteTabs: tabs,
|
||||
devices: devices,
|
||||
deviceName: deviceName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
GeckoSyncStateService geckoSyncStateService(Ref ref) {
|
||||
final service = GeckoSyncStateService.setUp();
|
||||
ref.onDispose(() => service.dispose());
|
||||
return service;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<String?> syncDeviceName(Ref ref) {
|
||||
return ref.watch(
|
||||
syncRepositoryProvider.selectAsync((value) => value.deviceName),
|
||||
);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<List<SyncDeviceTabs>> syncRemoteTabs(Ref ref) {
|
||||
return ref.watch(
|
||||
syncRepositoryProvider.selectAsync((value) => value.remoteTabs),
|
||||
);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<List<SyncDevice>> syncDevices(Ref ref) {
|
||||
return ref.watch(
|
||||
syncRepositoryProvider.selectAsync((value) => value.devices),
|
||||
);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<(SyncEvent?, String?)> syncEvent(Ref ref) {
|
||||
return ref.watch(
|
||||
syncRepositoryProvider.selectAsync(
|
||||
(s) => (s.lastSyncEvent, s.lastSyncError),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'sync.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(syncIsAuthenticated)
|
||||
final syncIsAuthenticatedProvider = SyncIsAuthenticatedProvider._();
|
||||
|
||||
final class SyncIsAuthenticatedProvider
|
||||
extends $FunctionalProvider<bool, bool, bool>
|
||||
with $Provider<bool> {
|
||||
SyncIsAuthenticatedProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'syncIsAuthenticatedProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$syncIsAuthenticatedHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
bool create(Ref ref) {
|
||||
return syncIsAuthenticated(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$syncIsAuthenticatedHash() =>
|
||||
r'40e108a5c1d4d885fd31edde04146bf4131dd51c';
|
||||
|
||||
@ProviderFor(TabsTrayScopeController)
|
||||
final tabsTrayScopeControllerProvider = TabsTrayScopeControllerProvider._();
|
||||
|
||||
final class TabsTrayScopeControllerProvider
|
||||
extends $NotifierProvider<TabsTrayScopeController, TabsTrayScope> {
|
||||
TabsTrayScopeControllerProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'tabsTrayScopeControllerProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$tabsTrayScopeControllerHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
TabsTrayScopeController create() => TabsTrayScopeController();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(TabsTrayScope value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<TabsTrayScope>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$tabsTrayScopeControllerHash() =>
|
||||
r'e9415c1f57e1a78804ec4eee122c9db9ee416066';
|
||||
|
||||
abstract class _$TabsTrayScopeController extends $Notifier<TabsTrayScope> {
|
||||
TabsTrayScope build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<TabsTrayScope, TabsTrayScope>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<TabsTrayScope, TabsTrayScope>,
|
||||
TabsTrayScope,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(effectiveTabsTrayScope)
|
||||
final effectiveTabsTrayScopeProvider = EffectiveTabsTrayScopeProvider._();
|
||||
|
||||
final class EffectiveTabsTrayScopeProvider
|
||||
extends $FunctionalProvider<TabsTrayScope, TabsTrayScope, TabsTrayScope>
|
||||
with $Provider<TabsTrayScope> {
|
||||
EffectiveTabsTrayScopeProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'effectiveTabsTrayScopeProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$effectiveTabsTrayScopeHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<TabsTrayScope> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
TabsTrayScope create(Ref ref) {
|
||||
return effectiveTabsTrayScope(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(TabsTrayScope value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<TabsTrayScope>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$effectiveTabsTrayScopeHash() =>
|
||||
r'ac295d7413a7014882835d3533bf31cd3c031a1d';
|
||||
|
||||
@ProviderFor(SelectedSyncedTabsDeviceId)
|
||||
final selectedSyncedTabsDeviceIdProvider =
|
||||
SelectedSyncedTabsDeviceIdProvider._();
|
||||
|
||||
final class SelectedSyncedTabsDeviceIdProvider
|
||||
extends $NotifierProvider<SelectedSyncedTabsDeviceId, String?> {
|
||||
SelectedSyncedTabsDeviceIdProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'selectedSyncedTabsDeviceIdProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$selectedSyncedTabsDeviceIdHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SelectedSyncedTabsDeviceId create() => SelectedSyncedTabsDeviceId();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(String? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<String?>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$selectedSyncedTabsDeviceIdHash() =>
|
||||
r'61f86d2f17d6c2f04e1fb76dae900c4695aa6af0';
|
||||
|
||||
abstract class _$SelectedSyncedTabsDeviceId extends $Notifier<String?> {
|
||||
String? build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<String?, String?>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<String?, String?>,
|
||||
String?,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(syncedTabsTotalCount)
|
||||
final syncedTabsTotalCountProvider = SyncedTabsTotalCountProvider._();
|
||||
|
||||
final class SyncedTabsTotalCountProvider
|
||||
extends $FunctionalProvider<AsyncValue<int>, int, FutureOr<int>>
|
||||
with $FutureModifier<int>, $FutureProvider<int> {
|
||||
SyncedTabsTotalCountProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'syncedTabsTotalCountProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$syncedTabsTotalCountHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<int> $createElement($ProviderPointer pointer) =>
|
||||
$FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<int> create(Ref ref) {
|
||||
return syncedTabsTotalCount(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$syncedTabsTotalCountHash() =>
|
||||
r'507b14aac187c434cf02925d895151c1a25159ea';
|
||||
|
||||
@ProviderFor(effectiveSyncedTabsDeviceId)
|
||||
final effectiveSyncedTabsDeviceIdProvider =
|
||||
EffectiveSyncedTabsDeviceIdProvider._();
|
||||
|
||||
final class EffectiveSyncedTabsDeviceIdProvider
|
||||
extends $FunctionalProvider<AsyncValue<String?>, String?, FutureOr<String?>>
|
||||
with $FutureModifier<String?>, $FutureProvider<String?> {
|
||||
EffectiveSyncedTabsDeviceIdProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'effectiveSyncedTabsDeviceIdProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$effectiveSyncedTabsDeviceIdHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<String?> $createElement($ProviderPointer pointer) =>
|
||||
$FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<String?> create(Ref ref) {
|
||||
return effectiveSyncedTabsDeviceId(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$effectiveSyncedTabsDeviceIdHash() =>
|
||||
r'26ed7e56191a8019cab5c89dc8d5622e1a709e20';
|
||||
|
||||
@ProviderFor(syncedTabsForSelectedDevice)
|
||||
final syncedTabsForSelectedDeviceProvider =
|
||||
SyncedTabsForSelectedDeviceProvider._();
|
||||
|
||||
final class SyncedTabsForSelectedDeviceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<SyncedTabItem>>,
|
||||
List<SyncedTabItem>,
|
||||
FutureOr<List<SyncedTabItem>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<List<SyncedTabItem>>,
|
||||
$FutureProvider<List<SyncedTabItem>> {
|
||||
SyncedTabsForSelectedDeviceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'syncedTabsForSelectedDeviceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$syncedTabsForSelectedDeviceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<SyncedTabItem>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<SyncedTabItem>> create(Ref ref) {
|
||||
return syncedTabsForSelectedDevice(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$syncedTabsForSelectedDeviceHash() =>
|
||||
r'a85c4e972ab55b872f1de64cd27ba18d7dcc023a';
|
||||
|
||||
@ProviderFor(SyncRepository)
|
||||
final syncRepositoryProvider = SyncRepositoryProvider._();
|
||||
|
||||
final class SyncRepositoryProvider
|
||||
extends $AsyncNotifierProvider<SyncRepository, SyncRepositoryState> {
|
||||
SyncRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'syncRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$syncRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SyncRepository create() => SyncRepository();
|
||||
}
|
||||
|
||||
String _$syncRepositoryHash() => r'5312aed1abf60ce3e04afa7d2f60b38367c09312';
|
||||
|
||||
abstract class _$SyncRepository extends $AsyncNotifier<SyncRepositoryState> {
|
||||
FutureOr<SyncRepositoryState> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref =
|
||||
this.ref as $Ref<AsyncValue<SyncRepositoryState>, SyncRepositoryState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<SyncRepositoryState>, SyncRepositoryState>,
|
||||
AsyncValue<SyncRepositoryState>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(geckoSyncStateService)
|
||||
final geckoSyncStateServiceProvider = GeckoSyncStateServiceProvider._();
|
||||
|
||||
final class GeckoSyncStateServiceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
GeckoSyncStateService,
|
||||
GeckoSyncStateService,
|
||||
GeckoSyncStateService
|
||||
>
|
||||
with $Provider<GeckoSyncStateService> {
|
||||
GeckoSyncStateServiceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'geckoSyncStateServiceProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$geckoSyncStateServiceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<GeckoSyncStateService> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
GeckoSyncStateService create(Ref ref) {
|
||||
return geckoSyncStateService(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(GeckoSyncStateService value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<GeckoSyncStateService>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$geckoSyncStateServiceHash() =>
|
||||
r'1e96db4a6229a3d2a31862cc9cf88c463405819b';
|
||||
|
||||
@ProviderFor(syncDeviceName)
|
||||
final syncDeviceNameProvider = SyncDeviceNameProvider._();
|
||||
|
||||
final class SyncDeviceNameProvider
|
||||
extends $FunctionalProvider<AsyncValue<String?>, String?, FutureOr<String?>>
|
||||
with $FutureModifier<String?>, $FutureProvider<String?> {
|
||||
SyncDeviceNameProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'syncDeviceNameProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$syncDeviceNameHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<String?> $createElement($ProviderPointer pointer) =>
|
||||
$FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<String?> create(Ref ref) {
|
||||
return syncDeviceName(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$syncDeviceNameHash() => r'3d7e531cba481c4233a895b1d80e915d646fc6b6';
|
||||
|
||||
@ProviderFor(syncRemoteTabs)
|
||||
final syncRemoteTabsProvider = SyncRemoteTabsProvider._();
|
||||
|
||||
final class SyncRemoteTabsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<SyncDeviceTabs>>,
|
||||
List<SyncDeviceTabs>,
|
||||
FutureOr<List<SyncDeviceTabs>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<List<SyncDeviceTabs>>,
|
||||
$FutureProvider<List<SyncDeviceTabs>> {
|
||||
SyncRemoteTabsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'syncRemoteTabsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$syncRemoteTabsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<SyncDeviceTabs>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<SyncDeviceTabs>> create(Ref ref) {
|
||||
return syncRemoteTabs(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$syncRemoteTabsHash() => r'5040a010e578f2fe395302e7d34a91df1854f8a9';
|
||||
|
||||
@ProviderFor(syncDevices)
|
||||
final syncDevicesProvider = SyncDevicesProvider._();
|
||||
|
||||
final class SyncDevicesProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<SyncDevice>>,
|
||||
List<SyncDevice>,
|
||||
FutureOr<List<SyncDevice>>
|
||||
>
|
||||
with $FutureModifier<List<SyncDevice>>, $FutureProvider<List<SyncDevice>> {
|
||||
SyncDevicesProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'syncDevicesProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$syncDevicesHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<SyncDevice>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<SyncDevice>> create(Ref ref) {
|
||||
return syncDevices(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$syncDevicesHash() => r'6249b4891f95fc6f8e2f70f1555aff0e6356ccad';
|
||||
|
||||
@ProviderFor(syncEvent)
|
||||
final syncEventProvider = SyncEventProvider._();
|
||||
|
||||
final class SyncEventProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<(SyncEvent?, String?)>,
|
||||
(SyncEvent?, String?),
|
||||
FutureOr<(SyncEvent?, String?)>
|
||||
>
|
||||
with
|
||||
$FutureModifier<(SyncEvent?, String?)>,
|
||||
$FutureProvider<(SyncEvent?, String?)> {
|
||||
SyncEventProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'syncEventProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$syncEventHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<(SyncEvent?, String?)> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<(SyncEvent?, String?)> create(Ref ref) {
|
||||
return syncEvent(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$syncEventHash() => r'c84d251502578779f66e17ee79e353ffa3b23e1a';
|
||||
@@ -0,0 +1,463 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
|
||||
import 'package:weblibre/core/providers/format.dart';
|
||||
import 'package:weblibre/features/qr_scanner/presentation/dialogs/qr_scanner_dialog.dart';
|
||||
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
|
||||
import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
|
||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||
|
||||
class SyncSettingsScreen extends HookConsumerWidget {
|
||||
const SyncSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final syncInfo = ref.watch(
|
||||
syncRepositoryProvider.select((value) => value.value?.account),
|
||||
);
|
||||
|
||||
final generalSettings = ref.watch(generalSettingsWithDefaultsProvider);
|
||||
|
||||
final syncStarted = ref.watch(
|
||||
syncEventProvider.select(
|
||||
(value) => value.isLoading || value.value?.$1 == SyncEvent.started,
|
||||
),
|
||||
);
|
||||
final isSyncing = syncStarted || syncInfo?.syncing == true;
|
||||
|
||||
final syncText = useMemoized(() {
|
||||
if (isSyncing) {
|
||||
return 'Synchronization in progress';
|
||||
}
|
||||
|
||||
final timestamp = syncInfo?.lastSyncedAt;
|
||||
if (timestamp == null || timestamp <= 0) {
|
||||
return 'Never synced';
|
||||
}
|
||||
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||
final formattedDate = ref
|
||||
.read(formatProvider.notifier)
|
||||
.fullDateTime(date.toLocal());
|
||||
|
||||
return 'Last synced: $formattedDate';
|
||||
}, [syncInfo, isSyncing]);
|
||||
|
||||
final syncController = useAnimationController(
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
if (isSyncing) {
|
||||
unawaited(syncController.repeat());
|
||||
} else {
|
||||
syncController.stop();
|
||||
syncController.reset();
|
||||
}
|
||||
return null;
|
||||
}, [isSyncing]);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Firefox Sync')),
|
||||
body: SafeArea(
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView(
|
||||
controller: controller,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
children: [
|
||||
const SettingSection(name: 'Account'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.account_circle_outlined),
|
||||
title: Text(syncInfo?.email ?? 'Not signed in'),
|
||||
subtitle: Text(
|
||||
syncInfo?.needsReauth == true
|
||||
? 'Authentication expired. Sign in again to continue syncing.'
|
||||
: syncInfo?.authenticated == true
|
||||
? (syncInfo?.displayName ?? 'Signed in')
|
||||
: 'Sign in to synchronize tabs, bookmarks, and history',
|
||||
),
|
||||
trailing: syncInfo?.authenticated == true
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
tooltip: 'Sign Out',
|
||||
onPressed: isSyncing
|
||||
? null
|
||||
: () async {
|
||||
final confirmed =
|
||||
await _showSignOutConfirmation(context);
|
||||
if (confirmed == true) {
|
||||
await ref
|
||||
.read(syncRepositoryProvider.notifier)
|
||||
.signOut();
|
||||
}
|
||||
},
|
||||
)
|
||||
: const Icon(Icons.login),
|
||||
onTap: (syncInfo?.authenticated == true || isSyncing)
|
||||
? null
|
||||
: () async {
|
||||
await ref
|
||||
.read(syncRepositoryProvider.notifier)
|
||||
.signIn();
|
||||
},
|
||||
),
|
||||
if (syncInfo?.authenticated != true && !isSyncing)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.qr_code_scanner),
|
||||
title: const Text('Scan QR Code to pair'),
|
||||
subtitle: const Text(
|
||||
'Scan a QR code from firefox.com/pair on desktop',
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () async {
|
||||
final barcode = await showDialog<Barcode>(
|
||||
context: context,
|
||||
builder: (_) => const QrScannerDialog(),
|
||||
);
|
||||
|
||||
final code = barcode?.code;
|
||||
if (code == null || code.isEmpty) return;
|
||||
|
||||
final uri = Uri.tryParse(code);
|
||||
if (uri == null || !uri.hasScheme) {
|
||||
if (context.mounted) {
|
||||
ui_helper.showErrorMessage(
|
||||
context,
|
||||
'Invalid QR code: not a valid URL',
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(syncRepositoryProvider.notifier)
|
||||
.signInWithPairing(code);
|
||||
},
|
||||
),
|
||||
if (syncInfo?.authenticated == true)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.devices),
|
||||
title: const Text('Device Name'),
|
||||
subtitle: ref
|
||||
.watch(syncDeviceNameProvider)
|
||||
.when(
|
||||
data: (name) => Text(name ?? 'Unknown'),
|
||||
loading: () => const Text('Loading...'),
|
||||
error: (_, _) => const Text('Unknown'),
|
||||
),
|
||||
trailing: const Icon(Icons.edit_outlined),
|
||||
onTap: isSyncing
|
||||
? null
|
||||
: () async {
|
||||
final currentName = ref
|
||||
.read(syncRepositoryProvider)
|
||||
.value
|
||||
?.deviceName;
|
||||
|
||||
if (currentName != null && context.mounted) {
|
||||
await _showDeviceNameDialog(
|
||||
context,
|
||||
currentName: currentName,
|
||||
onSave: (newName) {
|
||||
return ref
|
||||
.read(syncRepositoryProvider.notifier)
|
||||
.setDeviceName(newName);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SettingSection(name: 'Synchronization'),
|
||||
ListTile(
|
||||
leading: RotationTransition(
|
||||
turns: Tween<double>(
|
||||
begin: 0,
|
||||
end: -1,
|
||||
).animate(syncController),
|
||||
child: const Icon(Icons.sync),
|
||||
),
|
||||
title: const Text('Sync Now'),
|
||||
subtitle: Text(syncText),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: isSyncing
|
||||
? null
|
||||
: () async {
|
||||
await ref
|
||||
.read(syncRepositoryProvider.notifier)
|
||||
.syncNow();
|
||||
},
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
title: const Text('Sync History'),
|
||||
value: _engineEnabled(syncInfo, SyncEngineValue.history),
|
||||
onChanged: (syncInfo == null || isSyncing)
|
||||
? null
|
||||
: (value) async {
|
||||
await ref
|
||||
.read(syncRepositoryProvider.notifier)
|
||||
.setEngineEnabled(SyncEngineValue.history, value);
|
||||
},
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
title: const Text('Sync Bookmarks'),
|
||||
value: _engineEnabled(syncInfo, SyncEngineValue.bookmarks),
|
||||
onChanged: (syncInfo == null || isSyncing)
|
||||
? null
|
||||
: (value) async {
|
||||
await ref
|
||||
.read(syncRepositoryProvider.notifier)
|
||||
.setEngineEnabled(
|
||||
SyncEngineValue.bookmarks,
|
||||
value,
|
||||
);
|
||||
},
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
title: const Text('Sync Open Tabs'),
|
||||
value: _engineEnabled(syncInfo, SyncEngineValue.tabs),
|
||||
onChanged: (syncInfo == null || isSyncing)
|
||||
? null
|
||||
: (value) async {
|
||||
await ref
|
||||
.read(syncRepositoryProvider.notifier)
|
||||
.setEngineEnabled(SyncEngineValue.tabs, value);
|
||||
},
|
||||
),
|
||||
const SettingSection(name: 'Server Overrides'),
|
||||
ListTile(
|
||||
title: const Text('FxA Server Override'),
|
||||
subtitle: Text(
|
||||
generalSettings.syncServerOverride.isEmpty
|
||||
? 'Default Mozilla server'
|
||||
: generalSettings.syncServerOverride,
|
||||
),
|
||||
trailing: const Icon(Icons.edit_outlined),
|
||||
onTap: isSyncing
|
||||
? null
|
||||
: () => _showTextSettingDialog(
|
||||
context,
|
||||
title: 'FxA Server Override',
|
||||
initialValue: generalSettings.syncServerOverride,
|
||||
hint: 'https://accounts.firefox.com',
|
||||
onSave: (value) {
|
||||
return ref
|
||||
.read(
|
||||
saveGeneralSettingsControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.save(
|
||||
(current) => current.copyWith
|
||||
.syncServerOverride(value.trim()),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Sync Token Server Override'),
|
||||
subtitle: Text(
|
||||
generalSettings.syncTokenServerOverride.isEmpty
|
||||
? 'Automatic from FxA server'
|
||||
: generalSettings.syncTokenServerOverride,
|
||||
),
|
||||
trailing: const Icon(Icons.edit_outlined),
|
||||
onTap: isSyncing
|
||||
? null
|
||||
: () => _showTextSettingDialog(
|
||||
context,
|
||||
title: 'Sync Token Server Override',
|
||||
initialValue: generalSettings.syncTokenServerOverride,
|
||||
hint:
|
||||
'https://token.services.mozilla.com/1.0/sync/1.5',
|
||||
onSave: (value) {
|
||||
return ref
|
||||
.read(
|
||||
saveGeneralSettingsControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.save(
|
||||
(current) => current.copyWith
|
||||
.syncTokenServerOverride(value.trim()),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const ListTile(
|
||||
dense: true,
|
||||
title: Text(
|
||||
'Restart the app after changing server overrides.',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static bool _engineEnabled(SyncAccountInfo? info, SyncEngineValue engine) {
|
||||
final engines = info?.engines;
|
||||
if (engines == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (final status in engines) {
|
||||
if (status.engine == engine) {
|
||||
return status.enabled;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static Future<bool?> _showSignOutConfirmation(BuildContext context) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Sign out?'),
|
||||
content: const Text(
|
||||
'Are you sure you want to sign out of Firefox Sync?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Sign Out'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _showDeviceNameDialog(
|
||||
BuildContext context, {
|
||||
required String currentName,
|
||||
required Future<bool> Function(String name) onSave,
|
||||
}) async {
|
||||
final controller = TextEditingController(text: currentName);
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Device Name'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(hintText: 'Enter device name'),
|
||||
autofocus: true,
|
||||
inputFormatters: [LengthLimitingTextInputFormatter(128)],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final newName = controller.text.trim();
|
||||
if (newName.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Device name cannot be empty'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final success = await onSave(newName).catchError((_) => false);
|
||||
|
||||
if (!success) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Failed to update device name'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _showTextSettingDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String initialValue,
|
||||
required String hint,
|
||||
required Future<void> Function(String value) onSave,
|
||||
}) async {
|
||||
final controller = TextEditingController(text: initialValue);
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(hintText: hint),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final value = controller.text.trim();
|
||||
if (value.isNotEmpty) {
|
||||
final uri = Uri.tryParse(value);
|
||||
if (uri == null || uri.scheme != 'https') {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Must be a valid HTTPS URL'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
await onSave(controller.text);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,8 @@ class GeneralSettings with FastEquatable {
|
||||
final bool tabListShowFavicons;
|
||||
final bool quickTabSwitcherShowTitles;
|
||||
final bool drawerGestureEnabled;
|
||||
final String syncServerOverride;
|
||||
final String syncTokenServerOverride;
|
||||
|
||||
GeneralSettings({
|
||||
required this.themeMode,
|
||||
@@ -120,6 +122,8 @@ class GeneralSettings with FastEquatable {
|
||||
required this.tabListShowFavicons,
|
||||
required this.quickTabSwitcherShowTitles,
|
||||
required this.drawerGestureEnabled,
|
||||
required this.syncServerOverride,
|
||||
required this.syncTokenServerOverride,
|
||||
});
|
||||
|
||||
GeneralSettings.withDefaults({
|
||||
@@ -152,6 +156,8 @@ class GeneralSettings with FastEquatable {
|
||||
bool? tabListShowFavicons,
|
||||
bool? quickTabSwitcherShowTitles,
|
||||
bool? drawerGestureEnabled,
|
||||
String? syncServerOverride,
|
||||
String? syncTokenServerOverride,
|
||||
}) : themeMode = themeMode ?? ThemeMode.dark,
|
||||
enableReadability = enableReadability ?? true,
|
||||
enforceReadability = enforceReadability ?? false,
|
||||
@@ -184,7 +190,9 @@ class GeneralSettings with FastEquatable {
|
||||
allowClipboardAccess = allowClipboardAccess ?? true,
|
||||
tabListShowFavicons = tabListShowFavicons ?? false,
|
||||
quickTabSwitcherShowTitles = quickTabSwitcherShowTitles ?? true,
|
||||
drawerGestureEnabled = drawerGestureEnabled ?? false;
|
||||
drawerGestureEnabled = drawerGestureEnabled ?? false,
|
||||
syncServerOverride = syncServerOverride ?? '',
|
||||
syncTokenServerOverride = syncTokenServerOverride ?? '';
|
||||
|
||||
factory GeneralSettings.fromJson(Map<String, dynamic> json) =>
|
||||
_$GeneralSettingsFromJson(json);
|
||||
@@ -222,5 +230,7 @@ class GeneralSettings with FastEquatable {
|
||||
tabListShowFavicons,
|
||||
quickTabSwitcherShowTitles,
|
||||
drawerGestureEnabled,
|
||||
syncServerOverride,
|
||||
syncTokenServerOverride,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@ abstract class _$GeneralSettingsCWProxy {
|
||||
|
||||
GeneralSettings drawerGestureEnabled(bool drawerGestureEnabled);
|
||||
|
||||
GeneralSettings syncServerOverride(String syncServerOverride);
|
||||
|
||||
GeneralSettings syncTokenServerOverride(String syncTokenServerOverride);
|
||||
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
@@ -114,6 +118,8 @@ abstract class _$GeneralSettingsCWProxy {
|
||||
bool tabListShowFavicons,
|
||||
bool quickTabSwitcherShowTitles,
|
||||
bool drawerGestureEnabled,
|
||||
String syncServerOverride,
|
||||
String syncTokenServerOverride,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -245,6 +251,14 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
||||
GeneralSettings drawerGestureEnabled(bool drawerGestureEnabled) =>
|
||||
call(drawerGestureEnabled: drawerGestureEnabled);
|
||||
|
||||
@override
|
||||
GeneralSettings syncServerOverride(String syncServerOverride) =>
|
||||
call(syncServerOverride: syncServerOverride);
|
||||
|
||||
@override
|
||||
GeneralSettings syncTokenServerOverride(String syncTokenServerOverride) =>
|
||||
call(syncTokenServerOverride: syncTokenServerOverride);
|
||||
|
||||
@override
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
|
||||
@@ -283,6 +297,8 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
||||
Object? tabListShowFavicons = const $CopyWithPlaceholder(),
|
||||
Object? quickTabSwitcherShowTitles = const $CopyWithPlaceholder(),
|
||||
Object? drawerGestureEnabled = const $CopyWithPlaceholder(),
|
||||
Object? syncServerOverride = const $CopyWithPlaceholder(),
|
||||
Object? syncTokenServerOverride = const $CopyWithPlaceholder(),
|
||||
}) {
|
||||
return GeneralSettings(
|
||||
themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null
|
||||
@@ -455,6 +471,18 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
||||
? _value.drawerGestureEnabled
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: drawerGestureEnabled as bool,
|
||||
syncServerOverride:
|
||||
syncServerOverride == const $CopyWithPlaceholder() ||
|
||||
syncServerOverride == null
|
||||
? _value.syncServerOverride
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: syncServerOverride as String,
|
||||
syncTokenServerOverride:
|
||||
syncTokenServerOverride == const $CopyWithPlaceholder() ||
|
||||
syncTokenServerOverride == null
|
||||
? _value.syncTokenServerOverride
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: syncTokenServerOverride as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -534,6 +562,8 @@ GeneralSettings _$GeneralSettingsFromJson(
|
||||
tabListShowFavicons: json['tabListShowFavicons'] as bool?,
|
||||
quickTabSwitcherShowTitles: json['quickTabSwitcherShowTitles'] as bool?,
|
||||
drawerGestureEnabled: json['drawerGestureEnabled'] as bool?,
|
||||
syncServerOverride: json['syncServerOverride'] as String?,
|
||||
syncTokenServerOverride: json['syncTokenServerOverride'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$GeneralSettingsToJson(
|
||||
@@ -577,6 +607,8 @@ Map<String, dynamic> _$GeneralSettingsToJson(
|
||||
'tabListShowFavicons': instance.tabListShowFavicons,
|
||||
'quickTabSwitcherShowTitles': instance.quickTabSwitcherShowTitles,
|
||||
'drawerGestureEnabled': instance.drawerGestureEnabled,
|
||||
'syncServerOverride': instance.syncServerOverride,
|
||||
'syncTokenServerOverride': instance.syncTokenServerOverride,
|
||||
};
|
||||
|
||||
const _$ThemeModeEnumMap = {
|
||||
|
||||
@@ -60,9 +60,7 @@ class ProfileBackupListScreen extends HookConsumerWidget {
|
||||
key: ValueKey(file.path),
|
||||
title: Text(profileName),
|
||||
subtitle: Text(
|
||||
ref
|
||||
.read(formatProvider.notifier)
|
||||
.fullDateTimeWithTimezone(dateTime),
|
||||
ref.read(formatProvider.notifier).fullDateTime(dateTime),
|
||||
),
|
||||
onTap: () async {
|
||||
await RestoreProfileRoute(
|
||||
|
||||
@@ -155,6 +155,14 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'syncServerOverride': settings['syncServerOverride']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'syncTokenServerOverride': settings['syncTokenServerOverride']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
|
||||
}
|
||||
|
||||
String _$generalSettingsRepositoryHash() =>
|
||||
r'33730ccb09471c1a7c187bd19760110f524e055b';
|
||||
r'ed5bca200b840e0802980b85a0c0f682d7b24a8c';
|
||||
|
||||
abstract class _$GeneralSettingsRepository
|
||||
extends $StreamNotifier<GeneralSettings> {
|
||||
|
||||
@@ -147,13 +147,13 @@ class FeedArticleScreen extends HookConsumerWidget {
|
||||
children: [
|
||||
const Divider(),
|
||||
Text(
|
||||
'Published: ${hasArticleCreated ? ref.read(formatProvider.notifier).fullDateTimeWithTimezone(article.created!) : 'N/A'}',
|
||||
'Published: ${hasArticleCreated ? ref.read(formatProvider.notifier).fullDateTime(article.created!) : 'N/A'}',
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(fontStyle: FontStyle.italic),
|
||||
),
|
||||
if (hasArticleUpdated)
|
||||
Text(
|
||||
'Updated: ${ref.read(formatProvider.notifier).fullDateTimeWithTimezone(article.updated!)}',
|
||||
'Updated: ${ref.read(formatProvider.notifier).fullDateTime(article.updated!)}',
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(fontStyle: FontStyle.italic),
|
||||
),
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:background_fetch/background_fetch.dart';
|
||||
@@ -101,6 +102,9 @@ class _MainWidget extends HookConsumerWidget {
|
||||
final engineSettings = await ref
|
||||
.read(engineSettingsRepositoryProvider.notifier)
|
||||
.fetchSettings();
|
||||
final generalSettings = await ref
|
||||
.read(generalSettingsRepositoryProvider.notifier)
|
||||
.fetchSettings();
|
||||
|
||||
try {
|
||||
await GeckoBrowserService().initialize(
|
||||
@@ -108,6 +112,8 @@ class _MainWidget extends HookConsumerWidget {
|
||||
kDebugMode ? LogLevel.debug : LogLevel.warn,
|
||||
engineSettings.contentBlocking,
|
||||
engineSettings.addonCollection,
|
||||
generalSettings.syncServerOverride,
|
||||
generalSettings.syncTokenServerOverride,
|
||||
);
|
||||
} on PlatformException catch (e, s) {
|
||||
logger.e(
|
||||
|
||||
@@ -21,7 +21,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/core/providers/router.dart';
|
||||
import 'package:weblibre/domain/services/app_initialization.dart';
|
||||
import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
|
||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||
|
||||
class MainApp extends HookConsumerWidget {
|
||||
final ThemeData? theme;
|
||||
@@ -72,6 +75,9 @@ class MainApp extends HookConsumerWidget {
|
||||
darkTheme: darkTheme,
|
||||
themeMode: themeMode,
|
||||
routerConfig: router.value,
|
||||
builder: (context, child) {
|
||||
return _SyncEventListener(child: child ?? const SizedBox.shrink());
|
||||
},
|
||||
);
|
||||
},
|
||||
onFailure: (errorMessage) {
|
||||
@@ -99,3 +105,41 @@ class MainApp extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SyncEventListener extends ConsumerWidget {
|
||||
final Widget child;
|
||||
|
||||
const _SyncEventListener({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ref.listen(syncEventProvider, (previous, next) {
|
||||
if (next.isLoading || !next.hasValue) return;
|
||||
|
||||
final event = next.value;
|
||||
if (event == null) return;
|
||||
|
||||
final (syncEvent, syncError) = event;
|
||||
|
||||
switch (syncEvent) {
|
||||
// case SyncEvent.completed:
|
||||
// ui_helper.showInfoMessage(
|
||||
// context,
|
||||
// 'Synchronization complete',
|
||||
// duration: const Duration(seconds: 2),
|
||||
// );
|
||||
case SyncEvent.error:
|
||||
ui_helper.showErrorMessage(
|
||||
context,
|
||||
syncError ?? 'Synchronization failed',
|
||||
);
|
||||
case SyncEvent.started:
|
||||
case SyncEvent.completed:
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ SnackBar _createFloatingSnackBar({
|
||||
required Widget content,
|
||||
Color? backgroundColor,
|
||||
SnackBarAction? action,
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
bool persist = false,
|
||||
required Duration duration,
|
||||
required bool persist,
|
||||
}) {
|
||||
return SnackBar(
|
||||
content: content,
|
||||
@@ -43,34 +43,69 @@ SnackBar _createFloatingSnackBar({
|
||||
);
|
||||
}
|
||||
|
||||
void showErrorMessage(BuildContext context, String message) {
|
||||
void showErrorMessage(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
bool persist = false,
|
||||
}) {
|
||||
final snackBar = _createFloatingSnackBar(
|
||||
content: Text(
|
||||
message,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.onError,
|
||||
duration: duration,
|
||||
persist: persist,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||
}
|
||||
|
||||
void showInfoMessage(BuildContext context, String message) {
|
||||
final snackBar = _createFloatingSnackBar(content: Text(message));
|
||||
void showInfoMessage(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
bool persist = false,
|
||||
}) {
|
||||
final snackBar = _createFloatingSnackBar(
|
||||
content: Text(message),
|
||||
duration: duration,
|
||||
persist: persist,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||
}
|
||||
|
||||
void showOpenedTabsFromAnotherDeviceMessage(
|
||||
BuildContext context,
|
||||
int openedTabs, {
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
bool persist = false,
|
||||
}) {
|
||||
if (openedTabs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final message = openedTabs == 1
|
||||
? 'Opened 1 tab received from another device'
|
||||
: 'Opened $openedTabs tabs received from another device';
|
||||
|
||||
showInfoMessage(context, message, duration: duration, persist: persist);
|
||||
}
|
||||
|
||||
void showTabBackButtonMessage(
|
||||
BuildContext context,
|
||||
int tabCount,
|
||||
Duration duration,
|
||||
) {
|
||||
Duration duration, {
|
||||
bool persist = false,
|
||||
}) {
|
||||
final snackbar = _createFloatingSnackBar(
|
||||
content: (tabCount > 1)
|
||||
? const Text('Navigate BACK again to close current tab')
|
||||
: const Text('Navigate BACK again to exit app'),
|
||||
duration: duration,
|
||||
persist: persist,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context)
|
||||
@@ -83,6 +118,7 @@ void showTabOpenedMessage(
|
||||
String? tabName,
|
||||
void Function()? onShow,
|
||||
Duration duration = const Duration(seconds: 3),
|
||||
bool persist = false,
|
||||
}) {
|
||||
final message = switch (tabName.whenNotEmpty) {
|
||||
String() => "New tab '$tabName' opened in background",
|
||||
@@ -95,6 +131,7 @@ void showTabOpenedMessage(
|
||||
(onPressed) => SnackBarAction(label: 'Show', onPressed: onPressed),
|
||||
),
|
||||
duration: duration,
|
||||
persist: persist,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||
@@ -104,6 +141,7 @@ Future<void> showSuggestNewTabMessage(
|
||||
BuildContext context, {
|
||||
required void Function(String? searchText) onAdd,
|
||||
Duration duration = const Duration(seconds: 3),
|
||||
bool persist = false,
|
||||
}) async {
|
||||
final clipboardUrl = await tryGetUriFromClipboard();
|
||||
|
||||
@@ -117,6 +155,7 @@ Future<void> showSuggestNewTabMessage(
|
||||
},
|
||||
),
|
||||
duration: duration,
|
||||
persist: persist,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
@@ -130,6 +169,7 @@ void showTabSwitchMessage(
|
||||
String? tabName,
|
||||
void Function()? onSwitch,
|
||||
Duration duration = const Duration(seconds: 3),
|
||||
bool persist = false,
|
||||
}) {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
|
||||
@@ -144,6 +184,7 @@ void showTabSwitchMessage(
|
||||
(onPressed) => SnackBarAction(label: 'Switch', onPressed: onPressed),
|
||||
),
|
||||
duration: duration,
|
||||
persist: persist,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||
@@ -179,6 +220,7 @@ void showTabUndoClose(
|
||||
VoidCallback onUndo, {
|
||||
int count = 1,
|
||||
Duration duration = const Duration(seconds: 3),
|
||||
bool persist = false,
|
||||
}) {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
|
||||
@@ -188,6 +230,7 @@ void showTabUndoClose(
|
||||
: const Text('Tab closed'),
|
||||
action: SnackBarAction(label: 'Undo', onPressed: onUndo),
|
||||
duration: duration,
|
||||
persist: persist,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||
@@ -197,6 +240,7 @@ void showDismissOverrideMessage(
|
||||
BuildContext context,
|
||||
VoidCallback onDismiss, {
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
bool persist = false,
|
||||
}) {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
|
||||
@@ -204,6 +248,7 @@ void showDismissOverrideMessage(
|
||||
content: const Text('Hiding disabled by site'),
|
||||
action: SnackBarAction(label: 'Dismiss', onPressed: onDismiss),
|
||||
duration: duration,
|
||||
persist: persist,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||
|
||||
Reference in New Issue
Block a user