Files
App/lib/widgets/cmp/pattle.dart
2025-12-21 21:48:46 +08:00

725 lines
26 KiB
Dart

import 'dart:io';
import 'dart:math' as math;
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:island/models/chat.dart';
import 'package:island/models/route_item.dart';
import 'package:island/pods/chat/chat_room.dart';
import 'package:island/pods/chat/chat_summary.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/route.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:island/services/event_bus.dart';
import 'package:url_launcher/url_launcher.dart';
class CommandPattleWidget extends HookConsumerWidget {
final VoidCallback onDismiss;
const CommandPattleWidget({super.key, required this.onDismiss});
static List<SpecialAction> _getSpecialActions(BuildContext context) {
return [
SpecialAction(
name: 'postCompose'.tr(),
description: 'postComposeDescription'.tr(),
icon: Symbols.edit,
action: () {
eventBus.fire(const ShowComposeSheetEvent());
},
),
SpecialAction(
name: 'notifications'.tr(),
description: 'notificationsDescription'.tr(),
searchableAliases: ['notifications', 'alert', 'bell'],
icon: Symbols.notifications,
action: () {
eventBus.fire(const ShowNotificationSheetEvent());
},
),
];
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final textController = useTextEditingController();
final focusNode = useFocusNode();
final searchQuery = useState('');
final focusedIndex = useState<int?>(null);
final scrollController = useScrollController();
final animationController = useAnimationController(
duration: const Duration(milliseconds: 200),
);
final scaleAnimation = useAnimation(
Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(parent: animationController, curve: Curves.easeOut),
),
);
final opacityAnimation = useAnimation(
Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: animationController, curve: Curves.easeOut),
),
);
useEffect(() {
focusNode.requestFocus();
animationController.forward();
return null;
}, []);
useEffect(() {
void listener() {
searchQuery.value = textController.text;
// Reset focused index when search changes
focusedIndex.value = null;
}
textController.addListener(listener);
return () => textController.removeListener(listener);
}, [textController]);
final chatRooms = ref.watch(chatRoomJoinedProvider);
bool isDesktop() =>
kIsWeb ||
(!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS));
final filteredChats = chatRooms.maybeWhen(
data: (rooms) {
if (searchQuery.value.isEmpty) return <SnChatRoom>[];
return rooms
.where((room) {
final title = room.name ?? '';
final desc = room.description ?? '';
final query = searchQuery.value.toLowerCase();
return title.toLowerCase().contains(query) ||
desc.toLowerCase().contains(query) ||
(room.members?.any(
(member) =>
member.account.name.contains(query) ||
member.account.nick.contains(query),
) ??
false);
})
.take(5) // Limit to 5 results
.toList();
},
orElse: () => <SnChatRoom>[],
);
final filteredRoutes = searchQuery.value.isEmpty
? <RouteItem>[]
: kAvailableRoutes
.where((route) {
final query = searchQuery.value.toLowerCase();
return route.name.toLowerCase().contains(query) ||
route.description.toLowerCase().contains(query) ||
route.searchableAliases.any(
(e) => e.toLowerCase().contains(query),
);
})
.take(5) // Limit to 5 results
.toList();
final filteredSpecialActions = searchQuery.value.isEmpty
? <SpecialAction>[]
: _getSpecialActions(context)
.where((action) {
final query = searchQuery.value.toLowerCase();
return action.name.toLowerCase().contains(query) ||
action.description.toLowerCase().contains(query) ||
action.searchableAliases.any(
(e) => e.toLowerCase().contains(query),
);
})
.take(5) // Limit to 5 results
.toList();
final filteredFallbacks =
searchQuery.value.isNotEmpty &&
filteredChats.isEmpty &&
filteredSpecialActions.isEmpty &&
filteredRoutes.isEmpty
? _getFallbackActions(context, searchQuery.value)
: <FallbackAction>[];
// Combine results: fallbacks first, then chats, special actions, routes
final allResults = [
...filteredFallbacks,
...filteredChats,
...filteredSpecialActions,
...filteredRoutes,
];
// Scroll to focused item
useEffect(() {
if (focusedIndex.value != null && allResults.isNotEmpty) {
// Wait for the next frame to ensure ScrollController is attached
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
// Estimate item height (ListTile is typically around 72-88 pixels)
const double estimatedItemHeight = 80.0;
final double itemTopOffset =
focusedIndex.value! * estimatedItemHeight;
final double viewportHeight =
scrollController.position.viewportDimension;
final double centeredOffset =
itemTopOffset -
(viewportHeight / 2) +
(estimatedItemHeight / 2);
// Animate scroll to center the focused item
scrollController.animateTo(
centeredOffset.clamp(
0.0,
scrollController.position.maxScrollExtent,
),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
return null;
}, [focusedIndex.value]);
return KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: (event) {
if (event is KeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.escape) {
onDismiss();
} else if (isDesktop()) {
if (event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.numpadEnter) {
final item = allResults[focusedIndex.value ?? 0];
_executeItem(context, ref, item);
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
if (allResults.isNotEmpty) {
if (focusedIndex.value == null) {
focusedIndex.value = 0;
} else {
focusedIndex.value = math.max(0, focusedIndex.value! - 1);
}
}
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
if (allResults.isNotEmpty) {
if (focusedIndex.value == null) {
focusedIndex.value = 0;
} else {
focusedIndex.value = math.min(
allResults.length - 1,
focusedIndex.value! + 1,
);
}
}
}
}
}
},
child: GestureDetector(
onTap: onDismiss,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: Container(
color: Colors.black.withOpacity(0.5),
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: EdgeInsets.only(
top: MediaQuery.of(context).size.height * 0.2,
),
child: AnimatedBuilder(
animation: animationController,
builder: (context, child) => Opacity(
opacity: opacityAnimation,
child: Transform.scale(scale: scaleAnimation, child: child),
),
child: GestureDetector(
onTap:
() {}, // Prevent tap from dismissing when tapping inside
child: Container(
width: math.max(
MediaQuery.of(context).size.width * 0.6,
320,
),
constraints: const BoxConstraints(
maxWidth: 600,
maxHeight: 500,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 10,
spreadRadius: 2,
),
],
),
child: Material(
elevation: 0,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SearchBar(
controller: textController,
focusNode: focusNode,
hintText: 'searchChatsAndPages'.tr(),
leading: CircleAvatar(
child: const Icon(Symbols.keyboard_command_key),
).padding(horizontal: 8),
onSubmitted: !isDesktop() && allResults.isNotEmpty
? (value) => _executeItem(
context,
ref,
allResults[0],
)
: null,
),
AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
child: allResults.isNotEmpty
? ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 300,
),
child: ListView.builder(
padding: EdgeInsets.zero,
controller: scrollController,
shrinkWrap: true,
itemCount: allResults.length,
itemBuilder: (context, index) {
final item = allResults[index];
if (item is SnChatRoom) {
return _ChatRoomSearchResult(
room: item,
isFocused:
index == focusedIndex.value,
onTap: () => _navigateToChat(
context,
ref,
item,
),
);
} else if (item is SpecialAction) {
return _SpecialActionSearchResult(
action: item,
isFocused:
index == focusedIndex.value,
onTap: () {
onDismiss();
item.action();
},
);
} else if (item is RouteItem) {
return _RouteSearchResult(
route: item,
isFocused:
index == focusedIndex.value,
onTap: () => _navigateToRoute(
context,
ref,
item,
),
);
} else if (item is FallbackAction) {
return _FallbackSearchResult(
action: item,
isFocused:
index == focusedIndex.value,
onTap: () {
onDismiss();
item.action();
},
);
}
return const SizedBox.shrink();
},
),
)
: const SizedBox.shrink(),
),
],
),
),
),
),
),
),
),
),
),
),
);
}
void _navigateToChat(BuildContext context, WidgetRef ref, SnChatRoom room) {
onDismiss();
if (isWideScreen(context)) {
debugPrint('${room.name}');
ref
.read(routerProvider)
.replaceNamed('chatRoom', pathParameters: {'id': room.id});
} else {
ref
.read(routerProvider)
.pushNamed('chatRoom', pathParameters: {'id': room.id});
}
}
void _navigateToRoute(BuildContext context, WidgetRef ref, RouteItem route) {
onDismiss();
ref.read(routerProvider).go(route.path);
}
void _executeItem(BuildContext context, WidgetRef ref, dynamic item) {
if (item is SnChatRoom) {
_navigateToChat(context, ref, item);
} else if (item is SpecialAction) {
onDismiss();
item.action();
} else if (item is RouteItem) {
_navigateToRoute(context, ref, item);
} else if (item is FallbackAction) {
onDismiss();
item.action();
}
}
static List<FallbackAction> _getFallbackActions(
BuildContext context,
String query,
) {
final List<FallbackAction> actions = [];
// Check if query is a URL
final Uri? uri = Uri.tryParse(query);
final isValidUrl =
uri != null && (uri.scheme == 'http' || uri.scheme == 'https');
final isDomain = RegExp(
r'^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$',
).hasMatch(query);
if (isValidUrl || isDomain) {
final finalUri = isDomain ? Uri.parse('https://$query') : uri!;
actions.add(
FallbackAction(
name: 'Open URL',
description: 'Open ${finalUri.toString()} in browser',
icon: Symbols.open_in_new,
action: () async {
if (await canLaunchUrl(finalUri)) {
await launchUrl(finalUri, mode: LaunchMode.externalApplication);
}
},
),
);
}
// Ask the AI
// Bugged, DO NOT USE
// actions.add(
// FallbackAction(
// name: 'Ask the AI',
// description: 'Ask "$query" to the AI',
// icon: Symbols.bubble_chart,
// action: () {
// eventBus.fire(ShowThoughtSheetEvent(initialMessage: query));
// },
// ),
// );
// Search the web
actions.add(
FallbackAction(
name: 'Search the web',
description: 'Search "$query" on Google',
icon: Symbols.search,
action: () async {
final searchUri = Uri.https('www.google.com', '/search', {
'q': query,
});
if (await canLaunchUrl(searchUri)) {
await launchUrl(searchUri, mode: LaunchMode.externalApplication);
}
},
),
);
return actions;
}
}
class FallbackAction {
final String name;
final String description;
final IconData icon;
final VoidCallback action;
final List<String> searchableAliases;
const FallbackAction({
required this.name,
required this.description,
required this.icon,
required this.action,
this.searchableAliases = const [],
});
}
class _RouteSearchResult extends StatelessWidget {
final RouteItem route;
final bool isFocused;
final VoidCallback onTap;
const _RouteSearchResult({
required this.route,
required this.isFocused,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: isFocused
? Theme.of(context).colorScheme.surfaceContainerHighest
: null,
borderRadius: const BorderRadius.all(Radius.circular(28)),
),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer,
child: Icon(route.icon),
),
title: Text(route.name),
subtitle: Text(route.description),
onTap: onTap,
),
);
}
}
class _SpecialActionSearchResult extends StatelessWidget {
final SpecialAction action;
final bool isFocused;
final VoidCallback onTap;
const _SpecialActionSearchResult({
required this.action,
required this.isFocused,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: isFocused
? Theme.of(context).colorScheme.surfaceContainerHighest
: null,
borderRadius: const BorderRadius.all(Radius.circular(24)),
),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.tertiaryContainer,
foregroundColor: Theme.of(context).colorScheme.onTertiaryContainer,
child: Icon(action.icon),
),
title: Text(action.name),
subtitle: Text(action.description),
onTap: onTap,
),
);
}
}
class _FallbackSearchResult extends StatelessWidget {
final FallbackAction action;
final bool isFocused;
final VoidCallback onTap;
const _FallbackSearchResult({
required this.action,
required this.isFocused,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: isFocused
? Theme.of(context).colorScheme.surfaceContainerHighest
: null,
borderRadius: const BorderRadius.all(Radius.circular(24)),
),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer,
child: Icon(action.icon),
),
title: Text(action.name),
subtitle: Text(action.description),
onTap: onTap,
),
);
}
}
class _ChatRoomSearchResult extends HookConsumerWidget {
final SnChatRoom room;
final bool isFocused;
final VoidCallback onTap;
const _ChatRoomSearchResult({
required this.room,
required this.isFocused,
required this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userInfo = ref.watch(userInfoProvider);
final summary = ref
.watch(chatSummaryProvider)
.whenData((summaries) => summaries[room.id]);
var validMembers = room.members ?? [];
if (validMembers.isNotEmpty && userInfo.value != null) {
validMembers = validMembers
.where((e) => e.accountId != userInfo.value!.id)
.toList();
}
String titleText;
if (room.type == 1 && room.name == null) {
if (room.members?.isNotEmpty ?? false) {
titleText = validMembers.map((e) => e.account.nick).join(', ');
} else {
titleText = 'Direct Message';
}
} else {
titleText = room.name ?? '';
}
Widget buildSubtitle() {
return summary.when(
data: (data) => data == null
? (room.type == 1 && room.description == null
? Text(
validMembers.map((e) => '@${e.account.name}').join(', '),
)
: Text(room.description ?? ''))
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (data.unreadCount > 0)
Text(
'unreadMessages'.plural(data.unreadCount),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
if (data.lastMessage == null)
room.type == 1 && room.description == null
? Text(
validMembers
.map((e) => '@${e.account.name}')
.join(', '),
)
: Text(room.description ?? '')
else
Row(
spacing: 4,
children: [
Badge(
label: Text(data.lastMessage!.sender.account.nick),
textColor: Theme.of(context).colorScheme.onPrimary,
backgroundColor: Theme.of(
context,
).colorScheme.primary,
),
Expanded(
child: Text(
(data.lastMessage!.content?.isNotEmpty ?? false)
? data.lastMessage!.content!
: 'messageNone'.tr(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
),
Align(
alignment: Alignment.centerRight,
child: Text(
RelativeTime(
context,
).format(data.lastMessage!.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
],
),
loading: () => room.type == 1 && room.description == null
? Text(validMembers.map((e) => '@${e.account.name}').join(', '))
: Text(room.description ?? ''),
error: (_, _) => room.type == 1 && room.description == null
? Text(validMembers.map((e) => '@${e.account.name}').join(', '))
: Text(room.description ?? ''),
);
}
final isDirect = room.type == 1;
return Container(
decoration: BoxDecoration(
color: isFocused
? Theme.of(context).colorScheme.surfaceContainerHighest
: null,
borderRadius: const BorderRadius.all(Radius.circular(24)),
),
child: ListTile(
leading: Badge(
isLabelVisible: summary.maybeWhen(
data: (data) => (data?.unreadCount ?? 0) > 0,
orElse: () => false,
),
child: (isDirect && room.picture?.id == null)
? SplitAvatarWidget(
filesId: validMembers
.map((e) => e.account.profile.picture?.id)
.toList(),
)
: room.picture?.id == null
? CircleAvatar(child: Text((room.name ?? 'DM')[0].toUpperCase()))
: ProfilePictureWidget(
fileId: room.picture?.id,
), // Placeholder for now
),
title: Text(titleText),
subtitle: buildSubtitle(),
onTap: onTap,
),
);
}
}