diff --git a/lib/pods/chat/messages_notifier.g.dart b/lib/pods/chat/messages_notifier.g.dart index 88c4961c..2ef628c8 100644 --- a/lib/pods/chat/messages_notifier.g.dart +++ b/lib/pods/chat/messages_notifier.g.dart @@ -6,7 +6,7 @@ part of 'messages_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$messagesNotifierHash() => r'e4b760068f7349cc2991d0788055dbd855184f82'; +String _$messagesNotifierHash() => r'639286fd8e4e0cfdef5be6cf5d80eea769007cb3'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/route.dart b/lib/route.dart index d2e5b110..e4cc3fb3 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -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/edit_app.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/new_app.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/new_project.dart'; -import 'package:island/screens/developers/project_detail.dart'; import 'package:island/screens/discovery/articles.dart'; import 'package:island/screens/files/file_list.dart'; import 'package:island/screens/posts/post_categories_list.dart'; @@ -224,27 +222,18 @@ final routerProvider = Provider((ref) { ), ], ), - ShellRoute( + GoRoute( + name: 'developerHub', + path: '/developers', builder: - (context, state, child) => - DeveloperHubShellScreen(child: child), + (context, state) => DeveloperHubScreen( + initialPublisherName: state.uri.queryParameters['publisher'], + initialProjectId: state.uri.queryParameters['project'], + ), 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( name: 'developerProjectNew', - path: '/developers/:name/projects/new', + path: ':name/projects/new', builder: (context, state) => NewProjectScreen( publisherName: state.pathParameters['name']!, @@ -252,7 +241,7 @@ final routerProvider = Provider((ref) { ), GoRoute( name: 'developerProjectEdit', - path: '/developers/:name/projects/:id/edit', + path: ':name/projects/:id/edit', builder: (context, state) => EditProjectScreen( publisherName: state.pathParameters['name']!, @@ -261,12 +250,18 @@ final routerProvider = Provider((ref) { ), GoRoute( name: 'developerProjectDetail', - path: '/developers/:name/projects/:projectId', - builder: - (context, state) => ProjectDetailScreen( - publisherName: state.pathParameters['name']!, - projectId: state.pathParameters['projectId']!, - ), + path: ':name/projects/:projectId', + builder: (context, state) { + final name = state.pathParameters['name']!; + final 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: [ GoRoute( name: 'developerAppNew', diff --git a/lib/screens/creators/hub.dart b/lib/screens/creators/hub.dart index ef45aa70..700034c5 100644 --- a/lib/screens/creators/hub.dart +++ b/lib/screens/creators/hub.dart @@ -109,6 +109,7 @@ class PublisherSelector extends StatelessWidget { final bool isReadOnly; const PublisherSelector({ + super.key, required this.currentPublisher, required this.publishersMenu, this.onChanged, diff --git a/lib/screens/developers/edit_project.dart b/lib/screens/developers/edit_project.dart index 12d888a8..875c17dc 100644 --- a/lib/screens/developers/edit_project.dart +++ b/lib/screens/developers/edit_project.dart @@ -5,7 +5,7 @@ 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/screens/developers/projects.dart'; +import 'package:island/screens/developers/hub.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/response.dart'; import 'package:material_symbols_icons/symbols.dart'; diff --git a/lib/screens/developers/hub.dart b/lib/screens/developers/hub.dart index d0cd0d2f..8eb2b62e 100644 --- a/lib/screens/developers/hub.dart +++ b/lib/screens/developers/hub.dart @@ -5,10 +5,12 @@ 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/models/dev_project.dart'; import 'package:island/models/developer.dart'; import 'package:island/models/publisher.dart'; import 'package:island/pods/network.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/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; @@ -39,45 +41,341 @@ Future> developers(Ref ref) async { .toList(); } -class DeveloperHubShellScreen extends StatelessWidget { - final Widget child; - const DeveloperHubShellScreen({super.key, required this.child}); - - @override - Widget build(BuildContext context) { - final isWide = isWideScreen(context); - if (isWide) { - return AppBackground( - 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); - } +@riverpod +Future> devProjects(Ref ref, String pubName) async { + if (pubName.isEmpty) return []; + 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() + .toList(); } class DeveloperHubScreen extends HookConsumerWidget { - final bool isAside; - const DeveloperHubScreen({super.key, this.isAside = false}); + final String? initialPublisherName; + final String? initialProjectId; + + const DeveloperHubScreen({ + super.key, + this.initialPublisherName, + this.initialProjectId, + }); @override Widget build(BuildContext context, WidgetRef ref) { final isWide = isWideScreen(context); - if (isWide && !isAside) { - return Container(color: Theme.of(context).colorScheme.surface); - } - final developers = ref.watch(developersProvider); final currentDeveloper = useState( developers.value?.firstOrNull, ); + final projects = + currentDeveloper.value?.publisher?.name != null + ? ref.watch( + devProjectsProvider(currentDeveloper.value!.publisher!.name), + ) + : const AsyncValue>.data([]); + + final currentProject = useState( + 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 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> developersMenu = developers.when( data: (data) => @@ -94,7 +392,7 @@ class DeveloperHubScreen extends HookConsumerWidget { title: Text(item.publisher!.nick), subtitle: Text('@${item.publisher!.name}'), trailing: - currentDeveloper.value?.id == item.id + currentDeveloper?.id == item.id ? const Icon(Icons.check) : null, contentPadding: EdgeInsets.symmetric(horizontal: 8), @@ -106,171 +404,289 @@ class DeveloperHubScreen extends HookConsumerWidget { error: (_, _) => [], ); - final developerStats = ref.watch( - developerStatsProvider(currentDeveloper.value?.publisher?.name), + if (isReadOnly || currentDeveloper == null) { + return ProfilePictureWidget( + radius: 16, + fileId: currentDeveloper?.publisher?.picture?.id, + ).center().padding(right: 8); + } + + return DropdownButtonHideUnderline( + child: DropdownButton2( + 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 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( - isNoBackground: false, - appBar: AppBar( - leading: !isWide ? const PageBackButton() : null, - title: Text('developerHub').tr(), - actions: [ - DropdownButtonHideUnderline( - child: DropdownButton2( - alignment: Alignment.centerRight, - value: currentDeveloper.value, - 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: (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, - }, - ); - }, - ), - ], + if (projects.value == null) { + return const SizedBox.shrink(); + } + + final List> projectsMenu = + projects.value! + .map( + (item) => DropdownMenuItem( + value: item, + child: ListTile( + minTileHeight: 48, + leading: CircleAvatar( + radius: 16, + backgroundColor: Theme.of(context).colorScheme.primary, + child: Text( + item.name.isNotEmpty ? item.name[0].toUpperCase() : '?', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, ), - ), - 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( + 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? 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 { final DeveloperStats stats; const _DeveloperStatsWidget({required this.stats}); @@ -331,6 +747,76 @@ class _DeveloperStatsWidget extends StatelessWidget { } } +class _DeveloperUnselectedWidget extends HookConsumerWidget { + final ValueChanged 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 { const _DeveloperEnrollmentSheet(); diff --git a/lib/screens/developers/hub.g.dart b/lib/screens/developers/hub.g.dart index d287526a..8c6f13ac 100644 --- a/lib/screens/developers/hub.g.dart +++ b/lib/screens/developers/hub.g.dart @@ -168,5 +168,125 @@ final developersProvider = @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef DevelopersRef = AutoDisposeFutureProviderRef>; +String _$devProjectsHash() => r'87fdcab47cd7d79ab019a5625617abeb1ffa1f39'; + +/// See also [devProjects]. +@ProviderFor(devProjects) +const devProjectsProvider = DevProjectsFamily(); + +/// See also [devProjects]. +class DevProjectsFamily extends Family>> { + /// 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? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'devProjectsProvider'; +} + +/// See also [devProjects]. +class DevProjectsProvider extends AutoDisposeFutureProvider> { + /// 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> 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> 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> { + /// The parameter `pubName` of this provider. + String get pubName; +} + +class _DevProjectsProviderElement + extends AutoDisposeFutureProviderElement> + 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 diff --git a/lib/screens/developers/project_detail.dart b/lib/screens/developers/project_detail.dart deleted file mode 100644 index 42371f4b..00000000 --- a/lib/screens/developers/project_detail.dart +++ /dev/null @@ -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), - ], - ), - ); - } -} diff --git a/lib/screens/developers/project_detail_view.dart b/lib/screens/developers/project_detail_view.dart new file mode 100644 index 00000000..e17d1a9d --- /dev/null +++ b/lib/screens/developers/project_detail_view.dart @@ -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), + ], + ), + ), + ], + ); + } + } +} diff --git a/lib/screens/developers/projects.dart b/lib/screens/developers/projects.dart deleted file mode 100644 index e89d6faf..00000000 --- a/lib/screens/developers/projects.dart +++ /dev/null @@ -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> 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() - .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)), - ), - ), - ); - } -} diff --git a/lib/screens/developers/projects.g.dart b/lib/screens/developers/projects.g.dart deleted file mode 100644 index 7cdb5b38..00000000 --- a/lib/screens/developers/projects.g.dart +++ /dev/null @@ -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>> { - /// 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? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'devProjectsProvider'; -} - -/// See also [devProjects]. -class DevProjectsProvider extends AutoDisposeFutureProvider> { - /// 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> 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> 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> { - /// The parameter `pubName` of this provider. - String get pubName; -} - -class _DevProjectsProviderElement - extends AutoDisposeFutureProviderElement> - 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 diff --git a/lib/widgets/app_scaffold.dart b/lib/widgets/app_scaffold.dart index 8cc5e036..e87be189 100644 --- a/lib/widgets/app_scaffold.dart +++ b/lib/widgets/app_scaffold.dart @@ -8,6 +8,7 @@ import 'package:go_router/go_router.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/pods/config.dart'; +import 'package:island/route.dart'; import 'package:island/pods/userinfo.dart'; import 'package:island/pods/websocket.dart'; import 'package:island/screens/tabs.dart'; @@ -67,6 +68,28 @@ class WindowScaffold extends HookConsumerWidget { 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 && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { return Material( @@ -77,21 +100,34 @@ class WindowScaffold extends HookConsumerWidget { Column( children: [ DragToMoveArea( - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: - Platform.isMacOS - ? MainAxisAlignment.center - : MainAxisAlignment.start, - children: [ - Expanded( - child: - Platform.isMacOS - ? Text( - 'Solar Network', - textAlign: TextAlign.center, - ).padding(horizontal: 12, vertical: 5) - : Row( + child: + Platform.isMacOS + ? Stack( + alignment: Alignment.center, + children: [ + Row( + children: [ + if (Platform.isMacOS) + const SizedBox(width: 80), + ...pageButtonActions, + ], + ), + Text( + 'Solar Network', + textAlign: TextAlign.center, + style: TextStyle( + color: + Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + child: Row( children: [ Image.asset( Theme.of(context).brightness == @@ -108,46 +144,43 @@ class WindowScaffold extends HookConsumerWidget { ), ], ).padding(horizontal: 12, vertical: 5), - ), - if (!Platform.isMacOS) - ...([ - IconButton( - icon: Icon(Symbols.minimize), - onPressed: () => windowManager.minimize(), - iconSize: 16, - padding: EdgeInsets.all(8), - constraints: BoxConstraints(), - color: Theme.of(context).iconTheme.color, + ), + IconButton( + icon: Icon(Symbols.minimize), + onPressed: () => windowManager.minimize(), + 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, + ), + ], ), - 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), ], @@ -328,6 +361,11 @@ class PageBackButton extends StatelessWidget { @override Widget build(BuildContext context) { + final isDesktop = + !kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows); + + if (isDesktop) return const SizedBox.shrink(); + return IconButton( onPressed: () { onWillPop?.call();