♻️ Refactored developer hub

This commit is contained in:
2025-10-12 14:28:18 +08:00
parent 27157e7cc1
commit 6ffd498761
11 changed files with 1023 additions and 660 deletions

View File

@@ -6,7 +6,7 @@ part of 'messages_notifier.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$messagesNotifierHash() => r'e4b760068f7349cc2991d0788055dbd855184f82'; String _$messagesNotifierHash() => r'639286fd8e4e0cfdef5be6cf5d80eea769007cb3';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {

View File

@@ -10,13 +10,11 @@ import 'package:island/screens/developers/app_detail.dart';
import 'package:island/screens/developers/bot_detail.dart'; import 'package:island/screens/developers/bot_detail.dart';
import 'package:island/screens/developers/edit_app.dart'; import 'package:island/screens/developers/edit_app.dart';
import 'package:island/screens/developers/edit_bot.dart'; import 'package:island/screens/developers/edit_bot.dart';
import 'package:island/screens/developers/new_app.dart';
import 'package:island/screens/developers/hub.dart'; import 'package:island/screens/developers/hub.dart';
import 'package:island/screens/developers/new_app.dart';
import 'package:island/screens/developers/new_bot.dart'; import 'package:island/screens/developers/new_bot.dart';
import 'package:island/screens/developers/projects.dart';
import 'package:island/screens/developers/edit_project.dart'; import 'package:island/screens/developers/edit_project.dart';
import 'package:island/screens/developers/new_project.dart'; import 'package:island/screens/developers/new_project.dart';
import 'package:island/screens/developers/project_detail.dart';
import 'package:island/screens/discovery/articles.dart'; import 'package:island/screens/discovery/articles.dart';
import 'package:island/screens/files/file_list.dart'; import 'package:island/screens/files/file_list.dart';
import 'package:island/screens/posts/post_categories_list.dart'; import 'package:island/screens/posts/post_categories_list.dart';
@@ -224,27 +222,18 @@ final routerProvider = Provider<GoRouter>((ref) {
), ),
], ],
), ),
ShellRoute( GoRoute(
name: 'developerHub',
path: '/developers',
builder: builder:
(context, state, child) => (context, state) => DeveloperHubScreen(
DeveloperHubShellScreen(child: child), initialPublisherName: state.uri.queryParameters['publisher'],
initialProjectId: state.uri.queryParameters['project'],
),
routes: [ routes: [
GoRoute(
name: 'developerHub',
path: '/developers',
builder: (context, state) => const DeveloperHubScreen(),
),
GoRoute(
name: 'developerProjects',
path: '/developers/:name/projects',
builder:
(context, state) => DevProjectsScreen(
publisherName: state.pathParameters['name']!,
),
),
GoRoute( GoRoute(
name: 'developerProjectNew', name: 'developerProjectNew',
path: '/developers/:name/projects/new', path: ':name/projects/new',
builder: builder:
(context, state) => NewProjectScreen( (context, state) => NewProjectScreen(
publisherName: state.pathParameters['name']!, publisherName: state.pathParameters['name']!,
@@ -252,7 +241,7 @@ final routerProvider = Provider<GoRouter>((ref) {
), ),
GoRoute( GoRoute(
name: 'developerProjectEdit', name: 'developerProjectEdit',
path: '/developers/:name/projects/:id/edit', path: ':name/projects/:id/edit',
builder: builder:
(context, state) => EditProjectScreen( (context, state) => EditProjectScreen(
publisherName: state.pathParameters['name']!, publisherName: state.pathParameters['name']!,
@@ -261,12 +250,18 @@ final routerProvider = Provider<GoRouter>((ref) {
), ),
GoRoute( GoRoute(
name: 'developerProjectDetail', name: 'developerProjectDetail',
path: '/developers/:name/projects/:projectId', path: ':name/projects/:projectId',
builder: builder: (context, state) {
(context, state) => ProjectDetailScreen( final name = state.pathParameters['name']!;
publisherName: state.pathParameters['name']!, final projectId = state.pathParameters['projectId']!;
projectId: state.pathParameters['projectId']!, // Redirect to hub with project selected
), WidgetsBinding.instance.addPostFrameCallback((_) {
context.go(
'/developers?publisher=$name&project=$projectId',
);
});
return const SizedBox.shrink(); // Temporary placeholder
},
routes: [ routes: [
GoRoute( GoRoute(
name: 'developerAppNew', name: 'developerAppNew',

View File

@@ -109,6 +109,7 @@ class PublisherSelector extends StatelessWidget {
final bool isReadOnly; final bool isReadOnly;
const PublisherSelector({ const PublisherSelector({
super.key,
required this.currentPublisher, required this.currentPublisher,
required this.publishersMenu, required this.publishersMenu,
this.onChanged, this.onChanged,

View File

@@ -5,7 +5,7 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/dev_project.dart'; import 'package:island/models/dev_project.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/screens/developers/projects.dart'; import 'package:island/screens/developers/hub.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/response.dart'; import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';

View File

@@ -5,10 +5,12 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/dev_project.dart';
import 'package:island/models/developer.dart'; import 'package:island/models/developer.dart';
import 'package:island/models/publisher.dart'; import 'package:island/models/publisher.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/screens/creators/publishers_form.dart'; import 'package:island/screens/creators/publishers_form.dart';
import 'package:island/screens/developers/project_detail_view.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
@@ -39,45 +41,341 @@ Future<List<SnDeveloper>> developers(Ref ref) async {
.toList(); .toList();
} }
class DeveloperHubShellScreen extends StatelessWidget { @riverpod
final Widget child; Future<List<DevProject>> devProjects(Ref ref, String pubName) async {
const DeveloperHubShellScreen({super.key, required this.child}); if (pubName.isEmpty) return [];
final client = ref.watch(apiClientProvider);
@override final resp = await client.get('/develop/developers/$pubName/projects');
Widget build(BuildContext context) { return (resp.data as List)
final isWide = isWideScreen(context); .map((e) => DevProject.fromJson(e))
if (isWide) { .cast<DevProject>()
return AppBackground( .toList();
isRoot: true,
child: Row(
children: [
Flexible(flex: 2, child: const DeveloperHubScreen(isAside: true)),
const VerticalDivider(width: 1),
Flexible(flex: 3, child: child),
],
),
);
}
return AppBackground(isRoot: true, child: child);
}
} }
class DeveloperHubScreen extends HookConsumerWidget { class DeveloperHubScreen extends HookConsumerWidget {
final bool isAside; final String? initialPublisherName;
const DeveloperHubScreen({super.key, this.isAside = false}); final String? initialProjectId;
const DeveloperHubScreen({
super.key,
this.initialPublisherName,
this.initialProjectId,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isWide = isWideScreen(context); final isWide = isWideScreen(context);
if (isWide && !isAside) {
return Container(color: Theme.of(context).colorScheme.surface);
}
final developers = ref.watch(developersProvider); final developers = ref.watch(developersProvider);
final currentDeveloper = useState<SnDeveloper?>( final currentDeveloper = useState<SnDeveloper?>(
developers.value?.firstOrNull, developers.value?.firstOrNull,
); );
final projects =
currentDeveloper.value?.publisher?.name != null
? ref.watch(
devProjectsProvider(currentDeveloper.value!.publisher!.name),
)
: const AsyncValue<List<DevProject>>.data([]);
final currentProject = useState<DevProject?>(
projects.value?.where((p) => p.id == initialProjectId).firstOrNull,
);
final developerStats = ref.watch(
developerStatsProvider(currentDeveloper.value?.publisher?.name),
);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
leading: const PageBackButton(),
title: Text('Solar Network Cloud'),
actions: [
if (currentProject.value != null)
ProjectSelector(
currentDeveloper: currentDeveloper.value,
currentProject: currentProject.value,
onProjectChanged: (value) {
currentProject.value = value;
},
),
if (!isWide)
DeveloperSelector(
isReadOnly: false,
currentDeveloper: currentDeveloper.value,
onDeveloperChanged: (value) {
currentDeveloper.value = value;
},
),
const Gap(8),
],
),
body: LayoutBuilder(
builder: (context, constraints) {
final maxWidth = isWide ? 800.0 : double.infinity;
return Center(
child:
currentProject.value != null
? ProjectDetailView(
publisherName: currentDeveloper.value!.publisher!.name,
project: currentProject.value!,
onBackToHub: () {
currentProject.value = null;
},
)
: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: developerStats.when(
data:
(stats) => SingleChildScrollView(
child:
currentDeveloper.value == null
? ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 640,
),
child: _DeveloperUnselectedWidget(
onDeveloperSelected: (developer) {
currentDeveloper.value = developer;
},
),
).center()
: isWide
? Column(
spacing: 8,
children: [
DeveloperSelector(
isReadOnly: true,
currentDeveloper:
currentDeveloper.value,
onDeveloperChanged: (value) {
currentDeveloper.value = value;
},
),
if (stats != null)
_DeveloperStatsWidget(
stats: stats,
).padding(horizontal: 12),
Card(
margin: const EdgeInsets.symmetric(
horizontal: 12,
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(
16,
),
child: Row(
children: [
const Icon(
Symbols.folder_code,
),
const Gap(12),
Text(
'projects',
style:
Theme.of(context)
.textTheme
.titleMedium,
).tr(),
const Spacer(),
IconButton(
visualDensity:
VisualDensity
.compact,
icon: const Icon(
Symbols.add,
),
onPressed: () {
context.pushNamed(
'developerProjectNew',
pathParameters: {
'name':
currentDeveloper
.value!
.publisher!
.name,
},
);
},
),
],
),
),
if (projects
.value
?.isNotEmpty ??
false)
...(projects.value?.map(
(
project,
) => _ProjectListTile(
project: project,
publisherName:
currentDeveloper
.value!
.publisher!
.name,
onProjectSelected: (
selectedProject,
) {
currentProject
.value =
selectedProject;
},
),
) ??
[])
else
Padding(
padding:
const EdgeInsets.all(
16,
),
child: Center(
child:
Text(
'noProjects',
).tr(),
),
),
],
),
),
],
)
: Column(
spacing: 12,
children: [
if (stats != null)
_DeveloperStatsWidget(
stats: stats,
).padding(horizontal: 16),
Card(
margin: const EdgeInsets.symmetric(
horizontal: 16,
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(
16,
),
child: Row(
children: [
Text(
'projects',
style:
Theme.of(context)
.textTheme
.titleMedium,
).tr(),
const Spacer(),
IconButton(
icon: const Icon(
Symbols.add,
),
onPressed: () {
context.pushNamed(
'developerProjectNew',
pathParameters: {
'name':
currentDeveloper
.value!
.publisher!
.name,
},
);
},
),
],
),
),
if (projects
.value
?.isNotEmpty ??
false)
...(projects.value?.map(
(
project,
) => _ProjectListTile(
project: project,
publisherName:
currentDeveloper
.value!
.publisher!
.name,
onProjectSelected: (
selectedProject,
) {
currentProject
.value =
selectedProject;
},
),
) ??
[])
else
Padding(
padding:
const EdgeInsets.all(
16,
),
child: Center(
child:
Text(
'noProjects',
).tr(),
),
),
],
),
),
],
),
),
loading:
() => const Center(
child: CircularProgressIndicator(),
),
error:
(err, stack) => ResponseErrorWidget(
error: err,
onRetry: () {
ref.invalidate(
developerStatsProvider(
currentDeveloper.value?.publisher!.name,
),
);
},
),
),
),
);
},
),
);
}
}
class DeveloperSelector extends HookConsumerWidget {
final bool isReadOnly;
final SnDeveloper? currentDeveloper;
final ValueChanged<SnDeveloper?> onDeveloperChanged;
const DeveloperSelector({
super.key,
required this.isReadOnly,
required this.currentDeveloper,
required this.onDeveloperChanged,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final developers = ref.watch(developersProvider);
final List<DropdownMenuItem<SnDeveloper>> developersMenu = developers.when( final List<DropdownMenuItem<SnDeveloper>> developersMenu = developers.when(
data: data:
(data) => (data) =>
@@ -94,7 +392,7 @@ class DeveloperHubScreen extends HookConsumerWidget {
title: Text(item.publisher!.nick), title: Text(item.publisher!.nick),
subtitle: Text('@${item.publisher!.name}'), subtitle: Text('@${item.publisher!.name}'),
trailing: trailing:
currentDeveloper.value?.id == item.id currentDeveloper?.id == item.id
? const Icon(Icons.check) ? const Icon(Icons.check)
: null, : null,
contentPadding: EdgeInsets.symmetric(horizontal: 8), contentPadding: EdgeInsets.symmetric(horizontal: 8),
@@ -106,171 +404,289 @@ class DeveloperHubScreen extends HookConsumerWidget {
error: (_, _) => [], error: (_, _) => [],
); );
final developerStats = ref.watch( if (isReadOnly || currentDeveloper == null) {
developerStatsProvider(currentDeveloper.value?.publisher?.name), return ProfilePictureWidget(
radius: 16,
fileId: currentDeveloper?.publisher?.picture?.id,
).center().padding(right: 8);
}
return DropdownButtonHideUnderline(
child: DropdownButton2<SnDeveloper>(
alignment: Alignment.centerRight,
value: currentDeveloper,
hint: CircleAvatar(
radius: 16,
child: Icon(
Symbols.person,
color: Theme.of(
context,
).colorScheme.onSecondaryContainer.withOpacity(0.9),
fill: 1,
),
).center().padding(right: 8),
items: [...developersMenu],
onChanged: onDeveloperChanged,
selectedItemBuilder: (context) {
return [
...developersMenu.map(
(e) => ProfilePictureWidget(
radius: 16,
fileId: e.value?.publisher?.picture?.id,
).center().padding(right: 8),
),
];
},
buttonStyleData: ButtonStyleData(
height: 40,
padding: const EdgeInsets.only(left: 14, right: 8),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)),
),
dropdownStyleData: DropdownStyleData(
width: 320,
padding: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(4)),
),
menuItemStyleData: const MenuItemStyleData(
height: 64,
padding: EdgeInsets.only(left: 14, right: 14),
),
iconStyleData: IconStyleData(
icon: Icon(Icons.arrow_drop_down),
iconSize: 19,
iconEnabledColor: Theme.of(context).appBarTheme.foregroundColor!,
iconDisabledColor: Theme.of(context).appBarTheme.foregroundColor!,
),
),
);
}
}
class ProjectSelector extends HookConsumerWidget {
final SnDeveloper? currentDeveloper;
final DevProject? currentProject;
final ValueChanged<DevProject?> onProjectChanged;
const ProjectSelector({
super.key,
required this.currentDeveloper,
required this.currentProject,
required this.onProjectChanged,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (currentDeveloper == null) {
return const SizedBox.shrink();
}
final projects = ref.watch(
devProjectsProvider(currentDeveloper!.publisher!.name),
); );
return AppScaffold( if (projects.value == null) {
isNoBackground: false, return const SizedBox.shrink();
appBar: AppBar( }
leading: !isWide ? const PageBackButton() : null,
title: Text('developerHub').tr(), final List<DropdownMenuItem<DevProject>> projectsMenu =
actions: [ projects.value!
DropdownButtonHideUnderline( .map(
child: DropdownButton2<SnDeveloper>( (item) => DropdownMenuItem<DevProject>(
alignment: Alignment.centerRight, value: item,
value: currentDeveloper.value, child: ListTile(
hint: CircleAvatar( minTileHeight: 48,
radius: 16, leading: CircleAvatar(
child: Icon( radius: 16,
Symbols.person, backgroundColor: Theme.of(context).colorScheme.primary,
color: Theme.of( child: Text(
context, item.name.isNotEmpty ? item.name[0].toUpperCase() : '?',
).colorScheme.onSecondaryContainer.withOpacity(0.9), style: TextStyle(
fill: 1, color: Theme.of(context).colorScheme.onPrimary,
),
).center().padding(right: 8),
items: [...developersMenu],
onChanged: (value) {
currentDeveloper.value = value;
},
selectedItemBuilder: (context) {
return [
...developersMenu.map(
(e) => ProfilePictureWidget(
radius: 16,
fileId: e.value?.publisher?.picture?.id,
).center().padding(right: 8),
),
];
},
buttonStyleData: ButtonStyleData(
height: 40,
padding: const EdgeInsets.only(left: 14, right: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
),
),
dropdownStyleData: DropdownStyleData(
width: 320,
padding: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
),
),
menuItemStyleData: const MenuItemStyleData(
height: 64,
padding: EdgeInsets.only(left: 14, right: 14),
),
iconStyleData: IconStyleData(
icon: Icon(Icons.arrow_drop_down),
iconSize: 19,
iconEnabledColor:
Theme.of(context).appBarTheme.foregroundColor!,
iconDisabledColor:
Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
const Gap(8),
],
),
body: developerStats.when(
data:
(stats) => SingleChildScrollView(
child:
currentDeveloper.value == null
? Column(
children: [
const Gap(24),
const Icon(Symbols.info, size: 32).padding(bottom: 4),
Text(
'developerHubUnselectedHint',
textAlign: TextAlign.center,
).tr(),
const Gap(24),
const Divider(height: 1),
...(developers.value?.map(
(developer) => ListTile(
leading: ProfilePictureWidget(
file: developer.publisher?.picture,
),
title: Text(developer.publisher!.nick),
subtitle: Text(
'@${developer.publisher!.name}',
),
onTap: () {
currentDeveloper.value = developer;
},
),
) ??
[]),
ListTile(
leading: const CircleAvatar(
child: Icon(Symbols.add),
),
title: Text('enrollDeveloper').tr(),
subtitle: Text('enrollDeveloperHint').tr(),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(_) => const _DeveloperEnrollmentSheet(),
).then((value) {
if (value == true) {
ref.invalidate(developersProvider);
}
});
},
),
],
)
: Column(
children: [
if (stats != null)
_DeveloperStatsWidget(
stats: stats,
).padding(vertical: 12, horizontal: 12),
ListTile(
minTileHeight: 48,
title: Text('projects').tr(),
trailing: const Icon(Symbols.chevron_right),
leading: const Icon(Symbols.folder_managed),
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
),
onTap: () {
context.pushNamed(
'developerProjects',
pathParameters: {
'name':
currentDeveloper.value!.publisher!.name,
},
);
},
),
],
), ),
), ),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(err, stack) => ResponseErrorWidget(
error: err,
onRetry: () {
ref.invalidate(
developerStatsProvider(
currentDeveloper.value?.publisher!.name,
), ),
); title: Text(item.name),
}, subtitle: Text(
), item.description ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing:
currentProject?.id == item.id
? const Icon(Icons.check)
: null,
contentPadding: EdgeInsets.symmetric(horizontal: 8),
),
),
)
.toList();
return DropdownButtonHideUnderline(
child: DropdownButton2<DevProject>(
value: currentProject,
hint: CircleAvatar(
radius: 16,
backgroundColor: Theme.of(context).colorScheme.primary,
child: Text(
'?',
style: TextStyle(color: Theme.of(context).colorScheme.onPrimary),
),
).center().padding(right: 8),
items: projectsMenu,
onChanged: onProjectChanged,
selectedItemBuilder: (context) {
final isWider = isWiderScreen(context);
return projectsMenu
.map(
(e) =>
isWider
? Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 16,
backgroundColor:
Theme.of(context).colorScheme.primary,
child: Text(
e.value?.name.isNotEmpty ?? false
? e.value!.name[0].toUpperCase()
: '?',
style: TextStyle(
color:
Theme.of(context).colorScheme.onPrimary,
),
),
),
const Gap(8),
Text(
e.value?.name ?? '?',
style: TextStyle(
color:
Theme.of(
context,
).appBarTheme.foregroundColor,
),
),
],
).padding(right: 8)
: CircleAvatar(
radius: 16,
backgroundColor:
Theme.of(context).colorScheme.primary,
child: Text(
e.value?.name.isNotEmpty ?? false
? e.value!.name[0].toUpperCase()
: '?',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
),
),
).center().padding(right: 8),
)
.toList();
},
buttonStyleData: ButtonStyleData(
height: 40,
padding: const EdgeInsets.only(left: 14, right: 8),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)),
),
dropdownStyleData: DropdownStyleData(
width: 320,
padding: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(4)),
),
menuItemStyleData: const MenuItemStyleData(
height: 64,
padding: EdgeInsets.only(left: 14, right: 14),
),
iconStyleData: IconStyleData(
icon: Icon(Icons.arrow_drop_down),
iconSize: 19,
iconEnabledColor: Theme.of(context).appBarTheme.foregroundColor!,
iconDisabledColor: Theme.of(context).appBarTheme.foregroundColor!,
),
), ),
); );
} }
} }
class _ProjectListTile extends HookConsumerWidget {
final DevProject project;
final String publisherName;
final ValueChanged<DevProject>? onProjectSelected;
const _ProjectListTile({
required this.project,
required this.publisherName,
this.onProjectSelected,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
leading: const Icon(Symbols.folder_managed),
title: Text(project.name),
subtitle: Text(project.description ?? ''),
contentPadding: const EdgeInsets.only(left: 16, right: 17),
trailing: PopupMenuButton(
itemBuilder:
(context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Symbols.edit),
const SizedBox(width: 12),
Text('edit').tr(),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Symbols.delete, color: Colors.red),
const SizedBox(width: 12),
Text(
'delete',
style: const TextStyle(color: Colors.red),
).tr(),
],
),
),
],
onSelected: (value) {
if (value == 'edit') {
context.pushNamed(
'developerProjectEdit',
pathParameters: {'name': publisherName, 'id': project.id},
);
} else if (value == 'delete') {
showConfirmAlert(
'deleteProjectHint'.tr(),
'deleteProject'.tr(),
).then((confirm) {
if (confirm) {
final client = ref.read(apiClientProvider);
client.delete(
'/develop/developers/$publisherName/projects/${project.id}',
);
ref.invalidate(devProjectsProvider(publisherName));
}
});
}
},
),
onTap: () {
onProjectSelected?.call(project);
},
);
}
}
class _DeveloperStatsWidget extends StatelessWidget { class _DeveloperStatsWidget extends StatelessWidget {
final DeveloperStats stats; final DeveloperStats stats;
const _DeveloperStatsWidget({required this.stats}); const _DeveloperStatsWidget({required this.stats});
@@ -331,6 +747,76 @@ class _DeveloperStatsWidget extends StatelessWidget {
} }
} }
class _DeveloperUnselectedWidget extends HookConsumerWidget {
final ValueChanged<SnDeveloper> onDeveloperSelected;
const _DeveloperUnselectedWidget({required this.onDeveloperSelected});
@override
Widget build(BuildContext context, WidgetRef ref) {
final developers = ref.watch(developersProvider);
final hasDevelopers = developers.value?.isNotEmpty ?? false;
return Card(
margin: const EdgeInsets.all(16),
child: Column(
children: [
if (!hasDevelopers) ...[
const Icon(
Symbols.info,
fill: 1,
size: 32,
).padding(bottom: 6, top: 24),
Text(
'developerHubUnselectedHint',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
).tr(),
const Gap(24),
],
if (hasDevelopers)
...(developers.value?.map(
(developer) => ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
leading: ProfilePictureWidget(
file: developer.publisher?.picture,
),
title: Text(developer.publisher!.nick),
subtitle: Text('@${developer.publisher!.name}'),
onTap: () => onDeveloperSelected(developer),
),
) ??
[]),
const Divider(height: 1),
ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
leading: const CircleAvatar(child: Icon(Symbols.add)),
title: Text('enrollDeveloper').tr(),
subtitle: Text('enrollDeveloperHint').tr(),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => const _DeveloperEnrollmentSheet(),
).then((value) {
if (value == true) {
ref.invalidate(developersProvider);
}
});
},
),
],
),
);
}
}
class _DeveloperEnrollmentSheet extends HookConsumerWidget { class _DeveloperEnrollmentSheet extends HookConsumerWidget {
const _DeveloperEnrollmentSheet(); const _DeveloperEnrollmentSheet();

View File

@@ -168,5 +168,125 @@ final developersProvider =
@Deprecated('Will be removed in 3.0. Use Ref instead') @Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element // ignore: unused_element
typedef DevelopersRef = AutoDisposeFutureProviderRef<List<SnDeveloper>>; typedef DevelopersRef = AutoDisposeFutureProviderRef<List<SnDeveloper>>;
String _$devProjectsHash() => r'87fdcab47cd7d79ab019a5625617abeb1ffa1f39';
/// See also [devProjects].
@ProviderFor(devProjects)
const devProjectsProvider = DevProjectsFamily();
/// See also [devProjects].
class DevProjectsFamily extends Family<AsyncValue<List<DevProject>>> {
/// See also [devProjects].
const DevProjectsFamily();
/// See also [devProjects].
DevProjectsProvider call(String pubName) {
return DevProjectsProvider(pubName);
}
@override
DevProjectsProvider getProviderOverride(
covariant DevProjectsProvider provider,
) {
return call(provider.pubName);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'devProjectsProvider';
}
/// See also [devProjects].
class DevProjectsProvider extends AutoDisposeFutureProvider<List<DevProject>> {
/// See also [devProjects].
DevProjectsProvider(String pubName)
: this._internal(
(ref) => devProjects(ref as DevProjectsRef, pubName),
from: devProjectsProvider,
name: r'devProjectsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$devProjectsHash,
dependencies: DevProjectsFamily._dependencies,
allTransitiveDependencies: DevProjectsFamily._allTransitiveDependencies,
pubName: pubName,
);
DevProjectsProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.pubName,
}) : super.internal();
final String pubName;
@override
Override overrideWith(
FutureOr<List<DevProject>> Function(DevProjectsRef provider) create,
) {
return ProviderOverride(
origin: this,
override: DevProjectsProvider._internal(
(ref) => create(ref as DevProjectsRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
pubName: pubName,
),
);
}
@override
AutoDisposeFutureProviderElement<List<DevProject>> createElement() {
return _DevProjectsProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is DevProjectsProvider && other.pubName == pubName;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, pubName.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin DevProjectsRef on AutoDisposeFutureProviderRef<List<DevProject>> {
/// The parameter `pubName` of this provider.
String get pubName;
}
class _DevProjectsProviderElement
extends AutoDisposeFutureProviderElement<List<DevProject>>
with DevProjectsRef {
_DevProjectsProviderElement(super.provider);
@override
String get pubName => (origin as DevProjectsProvider).pubName;
}
// ignore_for_file: type=lint // ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -1,92 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/screens/developers/apps.dart';
import 'package:island/screens/developers/bots.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart';
class ProjectDetailScreen extends HookConsumerWidget {
final String publisherName;
final String projectId;
const ProjectDetailScreen({
super.key,
required this.publisherName,
required this.projectId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabController = useTabController(initialLength: 2);
return AppScaffold(
appBar: AppBar(
title: Text('projectDetails').tr(),
actions: [
IconButton(
icon: const Icon(Symbols.add),
onPressed: () {
// Get current tab index
final index = tabController.index;
switch (index) {
case 0:
context.pushNamed(
'developerAppNew',
pathParameters: {
'name': publisherName,
'projectId': projectId,
},
);
break;
case 1:
context.pushNamed(
'developerBotNew',
pathParameters: {
'name': publisherName,
'projectId': projectId,
},
);
break;
}
},
),
const Gap(8),
],
bottom: TabBar(
controller: tabController,
tabs: [
Tab(
child: Text(
'customApps'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
Tab(
child: Text(
'bots'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
],
),
),
body: TabBarView(
controller: tabController,
children: [
CustomAppsScreen(publisherName: publisherName, projectId: projectId),
BotsScreen(publisherName: publisherName, projectId: projectId),
],
),
);
}
}

View File

@@ -0,0 +1,116 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/dev_project.dart';
import 'package:island/screens/developers/apps.dart';
import 'package:island/screens/developers/bots.dart';
import 'package:island/services/responsive.dart';
class ProjectDetailView extends HookConsumerWidget {
final String publisherName;
final DevProject project;
final VoidCallback onBackToHub;
const ProjectDetailView({
super.key,
required this.publisherName,
required this.project,
required this.onBackToHub,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabController = useTabController(initialLength: 2);
final isWide = isWideScreen(context);
if (isWide) {
return Row(
spacing: 8,
children: [
Card(
margin: const EdgeInsets.only(left: 16, bottom: 16, top: 12),
child: Transform.translate(
offset: const Offset(0, -56),
child: NavigationRail(
extended: isWiderScreen(context),
scrollable: true,
labelType:
isWiderScreen(context)
? null
: NavigationRailLabelType.selected,
backgroundColor: Colors.transparent,
selectedIndex: tabController.index,
onDestinationSelected:
(index) => tabController.animateTo(index),
destinations: [
NavigationRailDestination(
icon: Icon(Icons.apps),
label: Text('customApps'.tr()),
),
NavigationRailDestination(
icon: Icon(Icons.smart_toy),
label: Text('bots'.tr()),
),
],
),
),
),
Expanded(
child: TabBarView(
controller: tabController,
children: [
CustomAppsScreen(
publisherName: publisherName,
projectId: project.id,
),
BotsScreen(publisherName: publisherName, projectId: project.id),
],
),
),
],
);
} else {
return Column(
children: [
TabBar(
controller: tabController,
tabs: [
Tab(
child: Text(
'customApps'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
Tab(
child: Text(
'bots'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
],
),
Expanded(
child: TabBarView(
controller: tabController,
children: [
CustomAppsScreen(
publisherName: publisherName,
projectId: project.id,
),
BotsScreen(publisherName: publisherName, projectId: project.id),
],
),
),
],
);
}
}
}

View File

@@ -1,150 +0,0 @@
import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/dev_project.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'projects.g.dart';
@riverpod
Future<List<DevProject>> devProjects(Ref ref, String pubName) async {
final client = ref.watch(apiClientProvider);
final resp = await client.get('/develop/developers/$pubName/projects');
return (resp.data as List)
.map((e) => DevProject.fromJson(e))
.cast<DevProject>()
.toList();
}
class DevProjectsScreen extends HookConsumerWidget {
final String publisherName;
const DevProjectsScreen({super.key, required this.publisherName});
@override
Widget build(BuildContext context, WidgetRef ref) {
final projects = ref.watch(devProjectsProvider(publisherName));
return AppScaffold(
appBar: AppBar(
title: Text('projects').tr(),
actions: [
IconButton(
icon: const Icon(Symbols.add),
onPressed: () {
context.pushNamed(
'developerProjectNew',
pathParameters: {'name': publisherName},
);
},
),
const Gap(8),
],
),
body: projects.when(
data: (data) {
if (data.isEmpty) {
return Center(child: Text('noProjects').tr());
}
return RefreshIndicator(
onRefresh:
() => ref.refresh(devProjectsProvider(publisherName).future),
child: ListView.builder(
padding: EdgeInsets.only(top: 4),
itemCount: data.length,
itemBuilder: (context, index) {
final project = data[index];
return Card(
margin: const EdgeInsets.all(8.0),
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
contentPadding: EdgeInsets.only(left: 20, right: 12),
title: Text(project.name),
subtitle: Text(project.description ?? ''),
trailing: PopupMenuButton(
itemBuilder:
(context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Symbols.edit),
const SizedBox(width: 12),
Text('edit').tr(),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Symbols.delete, color: Colors.red),
const SizedBox(width: 12),
Text(
'delete',
style: TextStyle(color: Colors.red),
).tr(),
],
),
),
],
onSelected: (value) {
if (value == 'edit') {
context.pushNamed(
'developerProjectEdit',
pathParameters: {
'name': publisherName,
'id': project.id,
},
);
} else if (value == 'delete') {
showConfirmAlert(
'deleteProjectHint'.tr(),
'deleteProject'.tr(),
).then((confirm) {
if (confirm) {
final client = ref.read(apiClientProvider);
client.delete(
'/develop/developers/$publisherName/projects/${project.id}',
);
ref.invalidate(
devProjectsProvider(publisherName),
);
}
});
}
},
),
onTap: () {
context.pushNamed(
'developerProjectDetail',
pathParameters: {
'name': publisherName,
'projectId': project.id,
},
);
},
),
);
},
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error:
(err, stack) => ResponseErrorWidget(
error: err,
onRetry: () => ref.invalidate(devProjectsProvider(publisherName)),
),
),
);
}
}

View File

@@ -1,151 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'projects.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$devProjectsHash() => r'87fdcab47cd7d79ab019a5625617abeb1ffa1f39';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// See also [devProjects].
@ProviderFor(devProjects)
const devProjectsProvider = DevProjectsFamily();
/// See also [devProjects].
class DevProjectsFamily extends Family<AsyncValue<List<DevProject>>> {
/// See also [devProjects].
const DevProjectsFamily();
/// See also [devProjects].
DevProjectsProvider call(String pubName) {
return DevProjectsProvider(pubName);
}
@override
DevProjectsProvider getProviderOverride(
covariant DevProjectsProvider provider,
) {
return call(provider.pubName);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'devProjectsProvider';
}
/// See also [devProjects].
class DevProjectsProvider extends AutoDisposeFutureProvider<List<DevProject>> {
/// See also [devProjects].
DevProjectsProvider(String pubName)
: this._internal(
(ref) => devProjects(ref as DevProjectsRef, pubName),
from: devProjectsProvider,
name: r'devProjectsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$devProjectsHash,
dependencies: DevProjectsFamily._dependencies,
allTransitiveDependencies: DevProjectsFamily._allTransitiveDependencies,
pubName: pubName,
);
DevProjectsProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.pubName,
}) : super.internal();
final String pubName;
@override
Override overrideWith(
FutureOr<List<DevProject>> Function(DevProjectsRef provider) create,
) {
return ProviderOverride(
origin: this,
override: DevProjectsProvider._internal(
(ref) => create(ref as DevProjectsRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
pubName: pubName,
),
);
}
@override
AutoDisposeFutureProviderElement<List<DevProject>> createElement() {
return _DevProjectsProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is DevProjectsProvider && other.pubName == pubName;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, pubName.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin DevProjectsRef on AutoDisposeFutureProviderRef<List<DevProject>> {
/// The parameter `pubName` of this provider.
String get pubName;
}
class _DevProjectsProviderElement
extends AutoDisposeFutureProviderElement<List<DevProject>>
with DevProjectsRef {
_DevProjectsProviderElement(super.provider);
@override
String get pubName => (origin as DevProjectsProvider).pubName;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -8,6 +8,7 @@ import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/route.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/screens/tabs.dart'; import 'package:island/screens/tabs.dart';
@@ -67,6 +68,28 @@ class WindowScaffold extends HookConsumerWidget {
return null; return null;
}, []); }, []);
final pageButtonActions = [
IconButton(
icon: Icon(Symbols.keyboard_arrow_left),
onPressed:
ref.watch(routerProvider).canPop()
? () => ref.read(routerProvider).pop()
: null,
iconSize: 16,
padding: EdgeInsets.all(8),
constraints: BoxConstraints(),
color: Theme.of(context).iconTheme.color,
),
IconButton(
icon: Icon(Symbols.home),
onPressed: () => ref.read(routerProvider).go('/'),
iconSize: 16,
padding: EdgeInsets.all(8),
constraints: BoxConstraints(),
color: Theme.of(context).iconTheme.color,
),
];
if (!kIsWeb && if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
return Material( return Material(
@@ -77,21 +100,34 @@ class WindowScaffold extends HookConsumerWidget {
Column( Column(
children: [ children: [
DragToMoveArea( DragToMoveArea(
child: Row( child:
crossAxisAlignment: CrossAxisAlignment.center, Platform.isMacOS
mainAxisAlignment: ? Stack(
Platform.isMacOS alignment: Alignment.center,
? MainAxisAlignment.center children: [
: MainAxisAlignment.start, Row(
children: [ children: [
Expanded( if (Platform.isMacOS)
child: const SizedBox(width: 80),
Platform.isMacOS ...pageButtonActions,
? Text( ],
'Solar Network', ),
textAlign: TextAlign.center, Text(
).padding(horizontal: 12, vertical: 5) 'Solar Network',
: Row( textAlign: TextAlign.center,
style: TextStyle(
color:
Theme.of(context).colorScheme.onSurface,
),
),
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Expanded(
child: Row(
children: [ children: [
Image.asset( Image.asset(
Theme.of(context).brightness == Theme.of(context).brightness ==
@@ -108,46 +144,43 @@ class WindowScaffold extends HookConsumerWidget {
), ),
], ],
).padding(horizontal: 12, vertical: 5), ).padding(horizontal: 12, vertical: 5),
), ),
if (!Platform.isMacOS) IconButton(
...([ icon: Icon(Symbols.minimize),
IconButton( onPressed: () => windowManager.minimize(),
icon: Icon(Symbols.minimize), iconSize: 16,
onPressed: () => windowManager.minimize(), padding: EdgeInsets.all(8),
iconSize: 16, constraints: BoxConstraints(),
padding: EdgeInsets.all(8), color: Theme.of(context).iconTheme.color,
constraints: BoxConstraints(), ),
color: Theme.of(context).iconTheme.color, IconButton(
icon: Icon(
isMaximized.value
? Symbols.fullscreen_exit
: Symbols.fullscreen,
),
onPressed: () async {
if (await windowManager.isMaximized()) {
windowManager.restore();
} else {
windowManager.maximize();
}
},
iconSize: 16,
padding: EdgeInsets.all(8),
constraints: BoxConstraints(),
color: Theme.of(context).iconTheme.color,
),
IconButton(
icon: Icon(Symbols.close),
onPressed: () => windowManager.hide(),
iconSize: 16,
padding: EdgeInsets.all(8),
constraints: BoxConstraints(),
color: Theme.of(context).iconTheme.color,
),
],
), ),
IconButton(
icon: Icon(
isMaximized.value
? Symbols.fullscreen_exit
: Symbols.fullscreen,
),
onPressed: () async {
if (await windowManager.isMaximized()) {
windowManager.restore();
} else {
windowManager.maximize();
}
},
iconSize: 16,
padding: EdgeInsets.all(8),
constraints: BoxConstraints(),
color: Theme.of(context).iconTheme.color,
),
IconButton(
icon: Icon(Symbols.close),
onPressed: () => windowManager.hide(),
iconSize: 16,
padding: EdgeInsets.all(8),
constraints: BoxConstraints(),
color: Theme.of(context).iconTheme.color,
),
]),
],
),
), ),
Expanded(child: child), Expanded(child: child),
], ],
@@ -328,6 +361,11 @@ class PageBackButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDesktop =
!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows);
if (isDesktop) return const SizedBox.shrink();
return IconButton( return IconButton(
onPressed: () { onPressed: () {
onWillPop?.call(); onWillPop?.call();