Custom app detail page

This commit is contained in:
2025-08-24 22:33:41 +08:00
parent abf395ff9a
commit 246ac52d0a
15 changed files with 1107 additions and 95 deletions

View File

@@ -0,0 +1,131 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/custom_app.dart';
import 'package:island/screens/developers/app_secrets.dart';
import 'package:island/screens/developers/apps.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class AppDetailScreen extends HookConsumerWidget {
final String publisherName;
final String projectId;
final String appId;
const AppDetailScreen({
super.key,
required this.publisherName,
required this.projectId,
required this.appId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabController = useTabController(initialLength: 2);
final appData = ref.watch(customAppProvider(publisherName, projectId, appId));
return AppScaffold(
appBar: AppBar(
title: Text(appData.value?.name ?? 'appDetails'.tr()),
actions: [
IconButton(
icon: const Icon(Symbols.edit),
onPressed: appData.value == null
? null
: () {
context.pushNamed(
'developerAppEdit',
pathParameters: {
'name': publisherName,
'projectId': projectId,
'id': appId,
},
);
},
),
],
bottom: TabBar(
controller: tabController,
tabs: [Tab(text: 'overview'.tr()), Tab(text: 'secrets'.tr())],
),
),
body: appData.when(
data: (app) {
return TabBarView(
controller: tabController,
physics: const NeverScrollableScrollPhysics(),
children: [
_AppOverview(app: app),
AppSecretsScreen(
publisherName: publisherName,
projectId: projectId,
appId: appId,
),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => ResponseErrorWidget(
error: err,
onRetry: () => ref.invalidate(
customAppProvider(publisherName, projectId, appId),
),
),
),
);
}
}
class _AppOverview extends StatelessWidget {
final CustomApp app;
const _AppOverview({required this.app});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [
Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: app.background != null
? CloudFileWidget(
item: app.background!,
fit: BoxFit.cover,
)
: const SizedBox.shrink(),
),
Positioned(
left: 20,
bottom: -32,
child: ProfilePictureWidget(
fileId: app.picture?.id,
radius: 40,
fallbackIcon: Symbols.apps,
),
),
],
),
).padding(bottom: 32),
ListTile(title: Text('name'.tr()), subtitle: Text(app.name)),
ListTile(title: Text('slug'.tr()), subtitle: Text(app.slug)),
if (app.description?.isNotEmpty ?? false)
ListTile(
title: Text('description'.tr()),
subtitle: Text(app.description!),
),
],
).padding(bottom: 24),
);
}
}

View File

@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/custom_app_secret.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'app_secrets.g.dart';
@riverpod
Future<List<CustomAppSecret>> customAppSecrets(
Ref ref,
String publisherName,
String projectId,
String appId,
) async {
final client = ref.watch(apiClientProvider);
final resp = await client.get(
'/develop/developers/$publisherName/projects/$projectId/apps/$appId/secrets',
);
return (resp.data as List)
.map((e) => CustomAppSecret.fromJson(e))
.cast<CustomAppSecret>()
.toList();
}
class AppSecretsScreen extends HookConsumerWidget {
final String publisherName;
final String projectId;
final String appId;
const AppSecretsScreen({
super.key,
required this.publisherName,
required this.projectId,
required this.appId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final secrets = ref.watch(
customAppSecretsProvider(publisherName, projectId, appId),
);
Future<void> generateSecret() async {
final client = ref.read(apiClientProvider);
try {
showLoadingModal(context);
await client
.post(
'/develop/developers/$publisherName/projects/$projectId/apps/$appId/secrets',
)
.then((_) {
ref.invalidate(
customAppSecretsProvider(publisherName, projectId, appId),
);
});
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
return secrets.when(
data: (data) {
return RefreshIndicator(
onRefresh:
() => ref.refresh(
customAppSecretsProvider(
publisherName,
projectId,
appId,
).future,
),
child: Column(
children: [
ListTile(
leading: const Icon(Symbols.add),
trailing: const Icon(Symbols.chevron_right),
title: Text('appSecretsGenerate').tr(),
onTap: generateSecret,
),
Expanded(
child: ListView.builder(
itemCount: data.length,
itemBuilder: (context, index) {
final secret = data[index];
return ListTile(
title: Text(secret.id),
subtitle: Text(
'created_at'.tr(
args: [secret.createdAt.toIso8601String()],
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Symbols.copy_all),
onPressed: () {
Clipboard.setData(
ClipboardData(text: secret.secret),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('secretCopied'.tr())),
);
},
),
IconButton(
icon: const Icon(Symbols.delete, color: Colors.red),
onPressed: () {
showConfirmAlert(
'deleteSecretHint'.tr(),
'deleteSecret'.tr(),
).then((confirm) {
if (confirm) {
final client = ref.read(apiClientProvider);
client
.delete(
'/develop/developers/$publisherName/projects/$projectId/apps/$appId/secrets/${secret.id}',
)
.then((_) {
ref.invalidate(
customAppSecretsProvider(
publisherName,
projectId,
appId,
),
);
});
}
});
},
),
],
),
);
},
),
),
],
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error:
(err, stack) => ResponseErrorWidget(
error: err,
onRetry:
() => ref.invalidate(
customAppSecretsProvider(publisherName, projectId, appId),
),
),
);
}
}

View File

@@ -0,0 +1,188 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_secrets.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$customAppSecretsHash() => r'1bc62ad812487883ce739793b22a76168d656752';
/// 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 [customAppSecrets].
@ProviderFor(customAppSecrets)
const customAppSecretsProvider = CustomAppSecretsFamily();
/// See also [customAppSecrets].
class CustomAppSecretsFamily extends Family<AsyncValue<List<CustomAppSecret>>> {
/// See also [customAppSecrets].
const CustomAppSecretsFamily();
/// See also [customAppSecrets].
CustomAppSecretsProvider call(
String publisherName,
String projectId,
String appId,
) {
return CustomAppSecretsProvider(publisherName, projectId, appId);
}
@override
CustomAppSecretsProvider getProviderOverride(
covariant CustomAppSecretsProvider provider,
) {
return call(provider.publisherName, provider.projectId, provider.appId);
}
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'customAppSecretsProvider';
}
/// See also [customAppSecrets].
class CustomAppSecretsProvider
extends AutoDisposeFutureProvider<List<CustomAppSecret>> {
/// See also [customAppSecrets].
CustomAppSecretsProvider(String publisherName, String projectId, String appId)
: this._internal(
(ref) => customAppSecrets(
ref as CustomAppSecretsRef,
publisherName,
projectId,
appId,
),
from: customAppSecretsProvider,
name: r'customAppSecretsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$customAppSecretsHash,
dependencies: CustomAppSecretsFamily._dependencies,
allTransitiveDependencies:
CustomAppSecretsFamily._allTransitiveDependencies,
publisherName: publisherName,
projectId: projectId,
appId: appId,
);
CustomAppSecretsProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.publisherName,
required this.projectId,
required this.appId,
}) : super.internal();
final String publisherName;
final String projectId;
final String appId;
@override
Override overrideWith(
FutureOr<List<CustomAppSecret>> Function(CustomAppSecretsRef provider)
create,
) {
return ProviderOverride(
origin: this,
override: CustomAppSecretsProvider._internal(
(ref) => create(ref as CustomAppSecretsRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
publisherName: publisherName,
projectId: projectId,
appId: appId,
),
);
}
@override
AutoDisposeFutureProviderElement<List<CustomAppSecret>> createElement() {
return _CustomAppSecretsProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is CustomAppSecretsProvider &&
other.publisherName == publisherName &&
other.projectId == projectId &&
other.appId == appId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, publisherName.hashCode);
hash = _SystemHash.combine(hash, projectId.hashCode);
hash = _SystemHash.combine(hash, appId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin CustomAppSecretsRef
on AutoDisposeFutureProviderRef<List<CustomAppSecret>> {
/// The parameter `publisherName` of this provider.
String get publisherName;
/// The parameter `projectId` of this provider.
String get projectId;
/// The parameter `appId` of this provider.
String get appId;
}
class _CustomAppSecretsProviderElement
extends AutoDisposeFutureProviderElement<List<CustomAppSecret>>
with CustomAppSecretsRef {
_CustomAppSecretsProviderElement(super.provider);
@override
String get publisherName =>
(origin as CustomAppSecretsProvider).publisherName;
@override
String get projectId => (origin as CustomAppSecretsProvider).projectId;
@override
String get appId => (origin as CustomAppSecretsProvider).appId;
}
// 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

@@ -14,6 +14,20 @@ import 'package:styled_widget/styled_widget.dart';
part 'apps.g.dart';
@riverpod
Future<CustomApp> customApp(
Ref ref,
String publisherName,
String projectId,
String appId,
) async {
final client = ref.watch(apiClientProvider);
final resp = await client.get(
'/develop/developers/$publisherName/projects/$projectId/apps/$appId',
);
return CustomApp.fromJson(resp.data);
}
@riverpod
Future<List<CustomApp>> customApps(
Ref ref,
@@ -81,98 +95,114 @@ class CustomAppsScreen extends HookConsumerWidget {
final app = data[index];
return Card(
margin: const EdgeInsets.all(8.0),
child: Column(
children: [
SizedBox(
height: 150,
child: Stack(
fit: StackFit.expand,
children: [
if (app.background != null)
CloudFileWidget(
item: app.background!,
fit: BoxFit.cover,
).clipRRect(topLeft: 8, topRight: 8),
if (app.picture != null)
Positioned(
left: 16,
bottom: 16,
child: ProfilePictureWidget(
fileId: app.picture!.id,
radius: 40,
fallbackIcon: Symbols.apps,
),
),
],
),
),
ListTile(
title: Text(app.name),
subtitle: Text(
app.slug,
style: GoogleFonts.robotoMono(fontSize: 12),
),
contentPadding: EdgeInsets.only(left: 20, right: 12),
trailing: PopupMenuButton(
itemBuilder:
(context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Symbols.edit),
const SizedBox(width: 12),
Text('edit').tr(),
],
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
context.pushNamed(
'developerAppDetail',
pathParameters: {
'name': publisherName,
'projectId': projectId,
'appId': app.id,
},
);
},
child: Column(
children: [
SizedBox(
height: 150,
child: Stack(
fit: StackFit.expand,
children: [
if (app.background != null)
CloudFileWidget(
item: app.background!,
fit: BoxFit.cover,
).clipRRect(topLeft: 8, topRight: 8),
if (app.picture != null)
Positioned(
left: 16,
bottom: 16,
child: ProfilePictureWidget(
fileId: app.picture!.id,
radius: 40,
fallbackIcon: Symbols.apps,
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(
Symbols.delete,
color: Colors.red,
],
),
),
ListTile(
title: Text(app.name),
subtitle: Text(
app.slug,
style: GoogleFonts.robotoMono(fontSize: 12),
),
contentPadding: EdgeInsets.only(left: 20, right: 12),
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(
'developerAppEdit',
pathParameters: {
'name': publisherName,
'projectId': projectId,
'id': app.id,
},
);
} else if (value == 'delete') {
showConfirmAlert(
'deleteCustomAppHint'.tr(),
'deleteCustomApp'.tr(),
).then((confirm) {
if (confirm) {
final client = ref.read(apiClientProvider);
client.delete(
'/develop/developers/$publisherName/projects/$projectId/apps/${app.id}',
);
ref.invalidate(
customAppsProvider(
publisherName,
projectId,
),
const SizedBox(width: 12),
Text(
'delete',
style: TextStyle(color: Colors.red),
).tr(),
],
),
),
],
onSelected: (value) {
if (value == 'edit') {
context.pushNamed(
'developerAppEdit',
pathParameters: {
'name': publisherName,
'projectId': projectId,
'id': app.id,
},
);
} else if (value == 'delete') {
showConfirmAlert(
'deleteCustomAppHint'.tr(),
'deleteCustomApp'.tr(),
).then((confirm) {
if (confirm) {
final client = ref.read(apiClientProvider);
client.delete(
'/develop/developers/$publisherName/projects/$projectId/apps/${app.id}',
);
ref.invalidate(
customAppsProvider(publisherName, projectId),
);
}
});
}
},
);
}
});
}
},
),
),
),
],
],
),
),
);
},

View File

@@ -6,7 +6,7 @@ part of 'apps.dart';
// RiverpodGenerator
// **************************************************************************
String _$customAppsHash() => r'450bedaf4220b8963cb44afeb14d4c0e80f01b11';
String _$customAppHash() => r'be05431ba8bf06fd20ee988a61c3663a68e15fc9';
/// Copied from Dart SDK
class _SystemHash {
@@ -29,6 +29,148 @@ class _SystemHash {
}
}
/// See also [customApp].
@ProviderFor(customApp)
const customAppProvider = CustomAppFamily();
/// See also [customApp].
class CustomAppFamily extends Family<AsyncValue<CustomApp>> {
/// See also [customApp].
const CustomAppFamily();
/// See also [customApp].
CustomAppProvider call(String publisherName, String projectId, String appId) {
return CustomAppProvider(publisherName, projectId, appId);
}
@override
CustomAppProvider getProviderOverride(covariant CustomAppProvider provider) {
return call(provider.publisherName, provider.projectId, provider.appId);
}
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'customAppProvider';
}
/// See also [customApp].
class CustomAppProvider extends AutoDisposeFutureProvider<CustomApp> {
/// See also [customApp].
CustomAppProvider(String publisherName, String projectId, String appId)
: this._internal(
(ref) =>
customApp(ref as CustomAppRef, publisherName, projectId, appId),
from: customAppProvider,
name: r'customAppProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$customAppHash,
dependencies: CustomAppFamily._dependencies,
allTransitiveDependencies: CustomAppFamily._allTransitiveDependencies,
publisherName: publisherName,
projectId: projectId,
appId: appId,
);
CustomAppProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.publisherName,
required this.projectId,
required this.appId,
}) : super.internal();
final String publisherName;
final String projectId;
final String appId;
@override
Override overrideWith(
FutureOr<CustomApp> Function(CustomAppRef provider) create,
) {
return ProviderOverride(
origin: this,
override: CustomAppProvider._internal(
(ref) => create(ref as CustomAppRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
publisherName: publisherName,
projectId: projectId,
appId: appId,
),
);
}
@override
AutoDisposeFutureProviderElement<CustomApp> createElement() {
return _CustomAppProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is CustomAppProvider &&
other.publisherName == publisherName &&
other.projectId == projectId &&
other.appId == appId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, publisherName.hashCode);
hash = _SystemHash.combine(hash, projectId.hashCode);
hash = _SystemHash.combine(hash, appId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin CustomAppRef on AutoDisposeFutureProviderRef<CustomApp> {
/// The parameter `publisherName` of this provider.
String get publisherName;
/// The parameter `projectId` of this provider.
String get projectId;
/// The parameter `appId` of this provider.
String get appId;
}
class _CustomAppProviderElement
extends AutoDisposeFutureProviderElement<CustomApp>
with CustomAppRef {
_CustomAppProviderElement(super.provider);
@override
String get publisherName => (origin as CustomAppProvider).publisherName;
@override
String get projectId => (origin as CustomAppProvider).projectId;
@override
String get appId => (origin as CustomAppProvider).appId;
}
String _$customAppsHash() => r'450bedaf4220b8963cb44afeb14d4c0e80f01b11';
/// See also [customApps].
@ProviderFor(customApps)
const customAppsProvider = CustomAppsFamily();

View File

@@ -183,7 +183,7 @@ class ArticlesScreen extends ConsumerWidget {
),
),
);
}).toList(),
}),
],
),
),

View File

@@ -6,7 +6,7 @@ part of 'articles.dart';
// RiverpodGenerator
// **************************************************************************
String _$subscribedFeedsHash() => r'cd2f5d7d4ea49ad00dc731f8fc2ed65450a3f0e4';
String _$subscribedFeedsHash() => r'5c0c8c30c5f543f6ea1d39786a6778f77ba5b3df';
/// See also [subscribedFeeds].
@ProviderFor(subscribedFeeds)

View File

@@ -1,6 +1,5 @@
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:island/screens/chat/chat.dart';
import 'package:flutter/material.dart';
import 'package:island/models/chat.dart';