Files
App/lib/widgets/post/compose_settings_sheet.dart
2025-10-14 01:37:54 +08:00

526 lines
20 KiB
Dart

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:flutter_typeahead/flutter_typeahead.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post_category.dart';
import 'package:island/models/post_tag.dart';
import 'package:island/models/realm.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/realm/realms.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/compose_shared.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
part 'compose_settings_sheet.g.dart';
@riverpod
Future<List<SnPostCategory>> postCategories(Ref ref) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/sphere/posts/categories');
final categories =
resp.data
.map((e) => SnPostCategory.fromJson(e))
.cast<SnPostCategory>()
.toList();
// Remove duplicates based on id
final uniqueCategories = <String, SnPostCategory>{};
for (final category in categories) {
uniqueCategories[category.id] = category;
}
return uniqueCategories.values.toList();
}
class ComposeSettingsSheet extends HookConsumerWidget {
final ComposeState state;
const ComposeSettingsSheet({super.key, required this.state});
Future<List<SnPostTag>> _fetchTagSuggestions(
String query,
WidgetRef ref,
) async {
if (query.isEmpty) return [];
try {
final client = ref.read(apiClientProvider);
final response = await client.get(
'/sphere/posts/tags',
queryParameters: {'query': query},
);
return response.data
.map<SnPostTag>((json) => SnPostTag.fromJson(json))
.toList();
} catch (e) {
return [];
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// Listen to visibility changes to trigger rebuilds
final currentVisibility = useValueListenable(state.visibility);
final currentCategories = useValueListenable(state.categories);
final currentTags = useValueListenable(state.tags);
final currentRealm = useValueListenable(state.realm);
final postCategories = ref.watch(postCategoriesProvider);
final userRealms = ref.watch(realmsJoinedProvider);
IconData getVisibilityIcon(int visibilityValue) {
switch (visibilityValue) {
case 1:
return Symbols.group;
case 2:
return Symbols.link_off;
case 3:
return Symbols.lock;
default:
return Symbols.public;
}
}
String getVisibilityText(int visibilityValue) {
switch (visibilityValue) {
case 1:
return 'postVisibilityFriends';
case 2:
return 'postVisibilityUnlisted';
case 3:
return 'postVisibilityPrivate';
default:
return 'postVisibilityPublic';
}
}
Widget buildVisibilityOption(
BuildContext context,
int value,
IconData icon,
String textKey,
) {
return ListTile(
leading: Icon(icon),
title: Text(textKey.tr()),
onTap: () {
state.visibility.value = value;
Navigator.pop(context);
},
selected: state.visibility.value == value,
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
);
}
void showVisibilitySheet() {
showModalBottomSheet(
context: context,
builder:
(context) => SheetScaffold(
titleText: 'postVisibility'.tr(),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
buildVisibilityOption(
context,
0,
Symbols.public,
'postVisibilityPublic',
),
buildVisibilityOption(
context,
1,
Symbols.group,
'postVisibilityFriends',
),
buildVisibilityOption(
context,
2,
Symbols.link_off,
'postVisibilityUnlisted',
),
buildVisibilityOption(
context,
3,
Symbols.lock,
'postVisibilityPrivate',
),
],
),
),
);
}
return SheetScaffold(
titleText: 'postSettings'.tr(),
heightFactor: 0.6,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16,
children: [
// Slug field
TextField(
controller: state.slugController,
decoration: InputDecoration(
labelText: 'postSlug'.tr(),
hintText: 'postSlugHint'.tr(),
contentPadding: const EdgeInsets.symmetric(
vertical: 9,
horizontal: 16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
// Tags field
Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 1,
),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: [
Text(
'tags'.tr(),
style: Theme.of(context).textTheme.labelLarge,
),
// Existing tags display
if (currentTags.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
children:
currentTags.map((tag) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'#$tag',
style: TextStyle(
color:
Theme.of(
context,
).colorScheme.onPrimary,
fontSize: 14,
),
),
const Gap(4),
InkWell(
onTap: () {
final newTags = List<String>.from(
state.tags.value,
)..remove(tag);
state.tags.value = newTags;
},
child: Icon(
Icons.close,
size: 16,
color:
Theme.of(
context,
).colorScheme.onPrimary,
),
),
],
),
);
}).toList(),
),
// Tag input with autocomplete
TypeAheadField<SnPostTag>(
builder: (context, controller, focusNode) {
return TextField(
controller: controller,
focusNode: focusNode,
decoration: InputDecoration(
hintText: 'addTag'.tr(),
border: InputBorder.none,
isCollapsed: true,
contentPadding: EdgeInsets.zero,
),
onSubmitted: (value) {
state.tags.value = [...state.tags.value, value];
controller.clear();
},
);
},
suggestionsCallback:
(pattern) => _fetchTagSuggestions(pattern, ref),
itemBuilder: (context, suggestion) {
return ListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
title: Text('#${suggestion.slug}'),
dense: true,
);
},
onSelected: (suggestion) {
if (!state.tags.value.contains(suggestion.slug)) {
state.tags.value = [
...state.tags.value,
suggestion.slug,
];
}
},
direction: VerticalDirection.down,
hideOnEmpty: true,
hideOnLoading: true,
debounceDuration: const Duration(milliseconds: 300),
),
],
),
),
// Categories field
DropdownButtonFormField2<SnPostCategory>(
isExpanded: true,
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(vertical: 9),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
hint: Text('categories'.tr(), style: TextStyle(fontSize: 15)),
items:
(postCategories.value ?? <SnPostCategory>[]).map((item) {
return DropdownMenuItem(
value: item,
enabled: false,
child: StatefulBuilder(
builder: (context, menuSetState) {
final isSelected = state.categories.value.contains(
item,
);
return InkWell(
onTap: () {
isSelected
? state.categories.value =
state.categories.value
.where((e) => e != item)
.toList()
: state.categories.value = [
...state.categories.value,
item,
];
menuSetState(() {});
},
child: Container(
height: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
child: Row(
children: [
if (isSelected)
const Icon(Icons.check_box_outlined)
else
const Icon(Icons.check_box_outline_blank),
const SizedBox(width: 16),
Expanded(
child: Text(
item.categoryDisplayTitle,
style: const TextStyle(fontSize: 14),
),
),
],
),
),
);
},
),
);
}).toList(),
value: currentCategories.isEmpty ? null : currentCategories.last,
onChanged: (_) {},
selectedItemBuilder: (context) {
return (postCategories.value ?? []).map((item) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final category in currentCategories)
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.primary,
),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
margin: const EdgeInsets.only(right: 4),
child: Text(
category.categoryDisplayTitle,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 13,
),
),
),
],
),
);
}).toList();
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.only(left: 16, right: 8),
height: 38,
),
menuItemStyleData: const MenuItemStyleData(
height: 38,
padding: EdgeInsets.zero,
),
),
// Realm selection
DropdownButtonFormField2<SnRealm?>(
isExpanded: true,
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(vertical: 9),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
hint: Text('realm'.tr(), style: const TextStyle(fontSize: 15)),
items: [
DropdownMenuItem<SnRealm?>(
value: null,
child: Row(
children: [
const CircleAvatar(
radius: 16,
child: Icon(Symbols.link_off, fill: 1),
),
const SizedBox(width: 12),
Text('postUnlinkRealm').tr(),
],
).padding(left: 16, right: 8),
),
// Include current realm if it's not null and not in joined realms
if (currentRealm != null &&
!(userRealms.value ?? []).any(
(r) => r.id == currentRealm.id,
))
DropdownMenuItem<SnRealm?>(
value: currentRealm,
child: Row(
children: [
ProfilePictureWidget(
fileId: currentRealm.picture?.id,
fallbackIcon: Symbols.workspaces,
radius: 16,
),
const SizedBox(width: 12),
Text(currentRealm.name),
],
).padding(left: 16, right: 8),
),
if (userRealms.hasValue)
...(userRealms.value ?? []).map(
(realm) => DropdownMenuItem<SnRealm?>(
value: realm,
child: Row(
children: [
ProfilePictureWidget(
fileId: realm.picture?.id,
fallbackIcon: Symbols.workspaces,
radius: 16,
),
const SizedBox(width: 12),
Text(realm.name),
],
).padding(left: 16, right: 8),
),
),
],
value: currentRealm,
onChanged: (value) {
state.realm.value = value;
},
selectedItemBuilder: (context) {
return (userRealms.value ?? []).map((_) {
return Row(
children: [
if (currentRealm == null)
const CircleAvatar(
radius: 16,
child: Icon(Symbols.link_off, fill: 1),
)
else
ProfilePictureWidget(
fileId: currentRealm.picture?.id,
fallbackIcon: Symbols.workspaces,
radius: 16,
),
const SizedBox(width: 12),
Text(currentRealm?.name ?? 'postUnlinkRealm'.tr()),
],
);
}).toList();
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.only(left: 16, right: 8),
height: 40,
),
menuItemStyleData: const MenuItemStyleData(
height: 56,
padding: EdgeInsets.zero,
),
),
// Visibility setting
Container(
decoration: BoxDecoration(
border: Border.all(color: colorScheme.outline, width: 1),
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
leading: Icon(getVisibilityIcon(currentVisibility)),
title: Text('postVisibility'.tr()),
subtitle: Text(getVisibilityText(currentVisibility).tr()),
trailing: const Icon(Symbols.chevron_right),
onTap: showVisibilitySheet,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
),
],
),
),
);
}
}