From 450d5ebc8103809620ff7345a9a92d3706963db4 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 29 Jun 2025 19:36:37 +0800 Subject: [PATCH] :sparkles: Developer portal basis --- assets/i18n/en-US.json | 8 +- lib/models/developer.dart | 14 ++ lib/models/developer.freezed.dart | 148 ++++++++++++ lib/models/developer.g.dart | 15 ++ lib/route.dart | 12 + lib/screens/account.dart | 4 +- lib/screens/developers/hub.dart | 365 ++++++++++++++++++++++++++++++ lib/screens/developers/hub.g.dart | 172 ++++++++++++++ 8 files changed, 736 insertions(+), 2 deletions(-) create mode 100644 lib/models/developer.dart create mode 100644 lib/models/developer.freezed.dart create mode 100644 lib/models/developer.g.dart create mode 100644 lib/screens/developers/hub.dart create mode 100644 lib/screens/developers/hub.g.dart diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 899f7c3..8c522f1 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -631,5 +631,11 @@ "realmJoinSuccess": "Successfully joined the realm.", "discoverRealms": "Discover Realms", "discoverPublishers": "Discover Publishers", - "search": "Search" + "search": "Search", + "developerHub": "Developer Hub", + "developerHubUnselectedHint": "Select a developer to see stats or enroll a new one.", + "enrollDeveloper": "Enroll as a Developer", + "enrollDeveloperHint": "Enroll one of your publishers to become a developer.", + "noPublishersToEnroll": "You don't have any publishers that can be enrolled as a developer.", + "totalCustomApps": "Total Custom Apps" } diff --git a/lib/models/developer.dart b/lib/models/developer.dart new file mode 100644 index 0000000..037de9a --- /dev/null +++ b/lib/models/developer.dart @@ -0,0 +1,14 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'developer.freezed.dart'; +part 'developer.g.dart'; + +@freezed +sealed class DeveloperStats with _$DeveloperStats { + const factory DeveloperStats({ + @Default(0) int totalCustomApps, + }) = _DeveloperStats; + + factory DeveloperStats.fromJson(Map json) => + _$DeveloperStatsFromJson(json); +} \ No newline at end of file diff --git a/lib/models/developer.freezed.dart b/lib/models/developer.freezed.dart new file mode 100644 index 0000000..5b43b82 --- /dev/null +++ b/lib/models/developer.freezed.dart @@ -0,0 +1,148 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'developer.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$DeveloperStats { + + int get totalCustomApps; +/// Create a copy of DeveloperStats +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$DeveloperStatsCopyWith get copyWith => _$DeveloperStatsCopyWithImpl(this as DeveloperStats, _$identity); + + /// Serializes this DeveloperStats to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is DeveloperStats&&(identical(other.totalCustomApps, totalCustomApps) || other.totalCustomApps == totalCustomApps)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,totalCustomApps); + +@override +String toString() { + return 'DeveloperStats(totalCustomApps: $totalCustomApps)'; +} + + +} + +/// @nodoc +abstract mixin class $DeveloperStatsCopyWith<$Res> { + factory $DeveloperStatsCopyWith(DeveloperStats value, $Res Function(DeveloperStats) _then) = _$DeveloperStatsCopyWithImpl; +@useResult +$Res call({ + int totalCustomApps +}); + + + + +} +/// @nodoc +class _$DeveloperStatsCopyWithImpl<$Res> + implements $DeveloperStatsCopyWith<$Res> { + _$DeveloperStatsCopyWithImpl(this._self, this._then); + + final DeveloperStats _self; + final $Res Function(DeveloperStats) _then; + +/// Create a copy of DeveloperStats +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? totalCustomApps = null,}) { + return _then(_self.copyWith( +totalCustomApps: null == totalCustomApps ? _self.totalCustomApps : totalCustomApps // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// @nodoc +@JsonSerializable() + +class _DeveloperStats implements DeveloperStats { + const _DeveloperStats({this.totalCustomApps = 0}); + factory _DeveloperStats.fromJson(Map json) => _$DeveloperStatsFromJson(json); + +@override@JsonKey() final int totalCustomApps; + +/// Create a copy of DeveloperStats +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$DeveloperStatsCopyWith<_DeveloperStats> get copyWith => __$DeveloperStatsCopyWithImpl<_DeveloperStats>(this, _$identity); + +@override +Map toJson() { + return _$DeveloperStatsToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _DeveloperStats&&(identical(other.totalCustomApps, totalCustomApps) || other.totalCustomApps == totalCustomApps)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,totalCustomApps); + +@override +String toString() { + return 'DeveloperStats(totalCustomApps: $totalCustomApps)'; +} + + +} + +/// @nodoc +abstract mixin class _$DeveloperStatsCopyWith<$Res> implements $DeveloperStatsCopyWith<$Res> { + factory _$DeveloperStatsCopyWith(_DeveloperStats value, $Res Function(_DeveloperStats) _then) = __$DeveloperStatsCopyWithImpl; +@override @useResult +$Res call({ + int totalCustomApps +}); + + + + +} +/// @nodoc +class __$DeveloperStatsCopyWithImpl<$Res> + implements _$DeveloperStatsCopyWith<$Res> { + __$DeveloperStatsCopyWithImpl(this._self, this._then); + + final _DeveloperStats _self; + final $Res Function(_DeveloperStats) _then; + +/// Create a copy of DeveloperStats +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? totalCustomApps = null,}) { + return _then(_DeveloperStats( +totalCustomApps: null == totalCustomApps ? _self.totalCustomApps : totalCustomApps // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +// dart format on diff --git a/lib/models/developer.g.dart b/lib/models/developer.g.dart new file mode 100644 index 0000000..84235ea --- /dev/null +++ b/lib/models/developer.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'developer.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_DeveloperStats _$DeveloperStatsFromJson(Map json) => + _DeveloperStats( + totalCustomApps: (json['total_custom_apps'] as num?)?.toInt() ?? 0, + ); + +Map _$DeveloperStatsToJson(_DeveloperStats instance) => + {'total_custom_apps': instance.totalCustomApps}; diff --git a/lib/route.dart b/lib/route.dart index c3b0234..c5dfeb2 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/screens/developers/hub.dart'; import 'package:island/widgets/app_wrapper.dart'; import 'package:island/screens/tabs.dart'; @@ -152,6 +153,17 @@ final routerProvider = Provider((ref) { ), ], ), + ShellRoute( + builder: + (context, state, child) => + DeveloperHubShellScreen(child: child), + routes: [ + GoRoute( + path: '/developers', + builder: (context, state) => const DeveloperHubScreen(), + ), + ], + ), // Auth routes GoRoute( diff --git a/lib/screens/account.dart b/lib/screens/account.dart index 2704d00..f043341 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -178,7 +178,9 @@ class AccountScreen extends HookConsumerWidget { Text('developerPortalDescription').tr(), ], ).padding(horizontal: 16, vertical: 12), - onTap: () {}, + onTap: () { + context.push('/developers'); + }, ), ).height(140), ), diff --git a/lib/screens/developers/hub.dart b/lib/screens/developers/hub.dart new file mode 100644 index 0000000..c261add --- /dev/null +++ b/lib/screens/developers/hub.dart @@ -0,0 +1,365 @@ +import 'package:dropdown_button2/dropdown_button2.dart'; +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:hooks_riverpod/hooks_riverpod.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.dart'; +import 'package:island/services/responsive.dart'; +import 'package:island/widgets/alert.dart'; +import 'package:island/widgets/app_scaffold.dart'; +import 'package:island/widgets/content/cloud_files.dart'; +import 'package:island/widgets/content/sheet.dart'; +import 'package:island/widgets/response.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:styled_widget/styled_widget.dart'; + +part 'hub.g.dart'; + +@riverpod +Future developerStats(Ref ref, String? uname) async { + if (uname == null) return null; + final apiClient = ref.watch(apiClientProvider); + final resp = await apiClient.get('/developers/$uname/stats'); + return DeveloperStats.fromJson(resp.data); +} + +@riverpod +Future> developers(Ref ref) async { + final client = ref.watch(apiClientProvider); + final resp = await client.get('/developers'); + return resp.data + .map((e) => SnPublisher.fromJson(e)) + .cast() + .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 Row( + children: [ + SizedBox(width: 360, child: const DeveloperHubScreen(isAside: true)), + const VerticalDivider(width: 1), + Expanded(child: child), + ], + ); + } + return child; + } +} + +class DeveloperHubScreen extends HookConsumerWidget { + final bool isAside; + const DeveloperHubScreen({super.key, this.isAside = false}); + + @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 List> developersMenu = developers.when( + data: + (data) => + data + .map( + (item) => DropdownMenuItem( + value: item, + child: ListTile( + minTileHeight: 48, + leading: ProfilePictureWidget( + radius: 16, + fileId: item.picture?.id, + ), + title: Text(item.nick), + subtitle: Text('@${item.name}'), + trailing: + currentDeveloper.value?.id == item.id + ? const Icon(Icons.check) + : null, + contentPadding: EdgeInsets.symmetric(horizontal: 8), + ), + ), + ) + .toList(), + loading: () => [], + error: (_, _) => [], + ); + + final developerStats = ref.watch( + developerStatsProvider(currentDeveloper.value?.name), + ); + + return AppScaffold( + noBackground: 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?.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.picture, + ), + title: Text(developer.nick), + subtitle: Text('@${developer.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), + ], + ), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: + (err, stack) => ResponseErrorWidget( + error: err, + onRetry: () { + ref.invalidate( + developerStatsProvider(currentDeveloper.value?.name), + ); + }, + ), + ), + ); + } +} + +class _DeveloperStatsWidget extends StatelessWidget { + final DeveloperStats stats; + const _DeveloperStatsWidget({required this.stats}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + spacing: 8, + children: [ + Row( + spacing: 8, + children: [ + Expanded( + child: _buildStatsCard( + context, + stats.totalCustomApps.toString(), + 'totalCustomApps', + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildStatsCard( + BuildContext context, + String statValue, + String statLabel, + ) { + return Card( + margin: EdgeInsets.zero, + child: SizedBox( + height: 100, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + statValue, + style: Theme.of(context).textTheme.headlineMedium, + ), + const Gap(4), + Text( + statLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ).tr(), + ], + ), + ), + ), + ); + } +} + +class _DeveloperEnrollmentSheet extends HookConsumerWidget { + const _DeveloperEnrollmentSheet(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final publishers = ref.watch(publishersManagedProvider); + + Future enroll(SnPublisher publisher) async { + try { + final client = ref.read(apiClientProvider); + await client.post('/developers/${publisher.name}/enroll'); + if (context.mounted) { + Navigator.pop(context, true); + } + } catch (err) { + showErrorAlert(err); + } + } + + return SheetScaffold( + titleText: 'enrollDeveloper'.tr(), + child: publishers.when( + data: + (items) => + items.isEmpty + ? Center( + child: + Text( + 'noPublishersToEnroll', + textAlign: TextAlign.center, + ).tr(), + ) + : ListView.builder( + shrinkWrap: true, + itemCount: items.length, + itemBuilder: (context, index) { + final publisher = items[index]; + return ListTile( + leading: ProfilePictureWidget( + fileId: publisher.picture?.id, + fallbackIcon: Symbols.group, + ), + title: Text(publisher.nick), + subtitle: Text('@${publisher.name}'), + onTap: () => enroll(publisher), + ); + }, + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: + (error, _) => ResponseErrorWidget( + error: error, + onRetry: () => ref.invalidate(publishersManagedProvider), + ), + ), + ); + } +} diff --git a/lib/screens/developers/hub.g.dart b/lib/screens/developers/hub.g.dart new file mode 100644 index 0000000..df18385 --- /dev/null +++ b/lib/screens/developers/hub.g.dart @@ -0,0 +1,172 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'hub.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$developerStatsHash() => r'783398cbde09c3d956c3e20b02a1cebd1f8ab748'; + +/// 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 [developerStats]. +@ProviderFor(developerStats) +const developerStatsProvider = DeveloperStatsFamily(); + +/// See also [developerStats]. +class DeveloperStatsFamily extends Family> { + /// See also [developerStats]. + const DeveloperStatsFamily(); + + /// See also [developerStats]. + DeveloperStatsProvider call(String? uname) { + return DeveloperStatsProvider(uname); + } + + @override + DeveloperStatsProvider getProviderOverride( + covariant DeveloperStatsProvider provider, + ) { + return call(provider.uname); + } + + 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'developerStatsProvider'; +} + +/// See also [developerStats]. +class DeveloperStatsProvider + extends AutoDisposeFutureProvider { + /// See also [developerStats]. + DeveloperStatsProvider(String? uname) + : this._internal( + (ref) => developerStats(ref as DeveloperStatsRef, uname), + from: developerStatsProvider, + name: r'developerStatsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$developerStatsHash, + dependencies: DeveloperStatsFamily._dependencies, + allTransitiveDependencies: + DeveloperStatsFamily._allTransitiveDependencies, + uname: uname, + ); + + DeveloperStatsProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.uname, + }) : super.internal(); + + final String? uname; + + @override + Override overrideWith( + FutureOr Function(DeveloperStatsRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: DeveloperStatsProvider._internal( + (ref) => create(ref as DeveloperStatsRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + uname: uname, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _DeveloperStatsProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is DeveloperStatsProvider && other.uname == uname; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, uname.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin DeveloperStatsRef on AutoDisposeFutureProviderRef { + /// The parameter `uname` of this provider. + String? get uname; +} + +class _DeveloperStatsProviderElement + extends AutoDisposeFutureProviderElement + with DeveloperStatsRef { + _DeveloperStatsProviderElement(super.provider); + + @override + String? get uname => (origin as DeveloperStatsProvider).uname; +} + +String _$developersHash() => r'f52639d3c21aafbf235c8ae33f35448baf2989a1'; + +/// See also [developers]. +@ProviderFor(developers) +final developersProvider = + AutoDisposeFutureProvider>.internal( + developers, + name: r'developersProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$developersHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef DevelopersRef = AutoDisposeFutureProviderRef>; +// 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