🎨 Continued to rearrange core folders content

This commit is contained in:
2026-02-06 00:57:17 +08:00
parent 862e3b451b
commit dfcbfcb31e
154 changed files with 259 additions and 269 deletions

View File

@@ -9,8 +9,8 @@ import 'package:island/fitness/fitness_data.dart';
import 'package:island/fitness/fitness_service.dart';
import 'package:island/core/services/update_service.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/core/widgets/content/network_status_sheet.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:island/drive/content/network_status_sheet.dart';
import 'package:island/drive/content/sheet_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:island/core/config.dart';
import 'package:talker_flutter/talker_flutter.dart';

View File

@@ -1,30 +0,0 @@
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/settings/tabs_screen.dart';
import 'package:island/core/services/responsive.dart';
class ConditionalBottomNav extends HookConsumerWidget {
final Widget child;
const ConditionalBottomNav({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentLocation = GoRouterState.of(context).uri.toString();
// Force rebuild when route changes
useEffect(() {
// This effect will run whenever currentLocation changes
return null;
}, [currentLocation]);
final routes = kTabRoutes.sublist(
0,
isWideScreen(context) ? null : kWideScreenRouteStart,
);
final shouldShowBottomNav = routes.contains(currentLocation);
return shouldShowBottomNav ? child : const SizedBox.shrink();
}
}

View File

@@ -3,7 +3,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:island/core/widgets/share/share_sheet.dart';
import 'package:island/sharing/share_sheet.dart';
import 'package:share_plus/share_plus.dart';
class SharingIntentService {
@@ -76,23 +76,21 @@ class SharingIntentService {
}
// Convert SharedMediaFile to XFile for files
final List<XFile> files =
sharedFiles
.where(
(file) =>
file.type == SharedMediaType.file ||
file.type == SharedMediaType.video ||
file.type == SharedMediaType.image,
)
.map((file) => XFile(file.path, name: file.path.split('/').last))
.toList();
final List<XFile> files = sharedFiles
.where(
(file) =>
file.type == SharedMediaType.file ||
file.type == SharedMediaType.video ||
file.type == SharedMediaType.image,
)
.map((file) => XFile(file.path, name: file.path.split('/').last))
.toList();
// Extract links from shared content
final List<String> links =
sharedFiles
.where((file) => file.type == SharedMediaType.url)
.map((file) => file.path)
.toList();
final List<String> links = sharedFiles
.where((file) => file.type == SharedMediaType.url)
.map((file) => file.path)
.toList();
// Show ShareSheet with the shared files
if (files.isNotEmpty) {

View File

@@ -8,7 +8,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_app_update/azhon_app_update.dart';
import 'package:flutter_app_update/update_model.dart';
import 'package:island/core/widgets/content/markdown.dart';
import 'package:island/drive/content/markdown.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
@@ -17,7 +17,7 @@ import 'package:process_run/process_run.dart';
import 'package:collection/collection.dart'; // Added for firstWhereOrNull
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:island/drive/content/sheet_scaffold.dart';
import 'package:island/talker.dart';
/// Data model for a GitHub release we care about
@@ -264,24 +264,22 @@ class UpdateService {
showDialog(
context: context,
barrierDismissible: false,
builder:
(context) => _WindowsUpdateDialog(
updateUrl: url,
onComplete: () {
// Close the update sheet
Navigator.of(context).pop();
},
),
builder: (context) => _WindowsUpdateDialog(
updateUrl: url,
onComplete: () {
// Close the update sheet
Navigator.of(context).pop();
},
),
);
}
/// Fetch the latest release info from GitHub.
/// Public so other screens (e.g., About) can manually trigger update checks.
Future<GithubReleaseInfo?> fetchLatestRelease() async {
final apiEndpoint =
useProxy
? '$_proxyBaseUrl${Uri.encodeComponent(_releasesLatestApi)}'
: _releasesLatestApi;
final apiEndpoint = useProxy
? '$_proxyBaseUrl${Uri.encodeComponent(_releasesLatestApi)}'
: _releasesLatestApi;
talker.info(
'[Update] Fetching latest release from GitHub API: $apiEndpoint (Proxy: $useProxy)',
@@ -415,17 +413,16 @@ class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
Navigator.of(context).pop();
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Update Failed'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
builder: (context) => AlertDialog(
title: const Text('Update Failed'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}
@@ -542,11 +539,10 @@ class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
talker.info('[Update] Running Windows installer from: $extractDir');
final dir = Directory(extractDir);
final exeFiles =
dir
.listSync()
.where((f) => f is File && f.path.endsWith('.exe'))
.toList();
final exeFiles = dir
.listSync()
.where((f) => f is File && f.path.endsWith('.exe'))
.toList();
if (exeFiles.isEmpty) {
talker.info('[Update] No .exe file found in extracted directory');
@@ -652,10 +648,9 @@ class _UpdateSheetState extends State<_UpdateSheet> {
vertical: 16,
),
child: MarkdownTextContent(
content:
widget.release.body.isEmpty
? 'noChangelogProvided'.tr()
: widget.release.body,
content: widget.release.body.isEmpty
? 'noChangelogProvided'.tr()
: widget.release.body,
),
),
),

View File

@@ -1,34 +0,0 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:styled_widget/styled_widget.dart';
class TechicalReviewIntroWidget extends StatelessWidget {
const TechicalReviewIntroWidget({super.key});
@override
Widget build(BuildContext context) {
return SheetScaffold(
titleText: '技术性预览',
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('👋').fontSize(32),
Text('你好呀~').fontSize(24),
Text('欢迎来使用 Solar Network 3.0 的技术性预览版。'),
const Gap(24),
Text('技术性预览的初衷是让我们更顺滑的将 3.0 发布出来,帮助我们一点一点的迁移数据。'),
const Gap(24),
Text('同时,既然是测试版,肯定有一系列的 Bug 和问题,请多多包涵,也欢迎积极反馈到 GitHub 上。'),
Text('目前帐号数据已经迁移完毕,其他数据将在未来逐渐迁移。还请耐心等待,不要重复创建以免未来数据冲突。'),
const Gap(24),
Text('最后,感谢你愿意参与技术性预览,祝你使用愉快!'),
const Gap(16),
Text('关掉这个对话框就开始探索吧!').fontSize(11),
],
).padding(horizontal: 20, vertical: 24),
),
);
}
}

View File

@@ -1,36 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/core/services/tour.dart';
const List<String> kStartTours = ['technical_review_intro'];
class TourTriggerWidget extends HookConsumerWidget {
final Widget child;
const TourTriggerWidget({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tourStatus = ref.watch(tourStatusProvider.notifier);
useEffect(() {
Future(() async {
for (final tour in kStartTours) {
final widget = await tourStatus.showTour(tour);
if (widget != null) {
if (!context.mounted) return;
await showModalBottomSheet(
isScrollControlled: true,
useRootNavigator: true,
context: context,
builder: (context) => widget,
);
}
}
});
return null;
}, [tourStatus]);
return child;
}
}

View File

@@ -1,22 +0,0 @@
String getAbuseReportTypeString(int type) {
switch (type) {
case 0:
return 'Copyright';
case 1:
return 'Harassment';
case 2:
return 'Impersonation';
case 3:
return 'Offensive Content';
case 4:
return 'Spam';
case 5:
return 'Privacy Violation';
case 6:
return 'Illegal Content';
case 7:
return 'Other';
default:
return 'Unknown';
}
}

View File

@@ -1,122 +0,0 @@
import 'package:flutter/material.dart';
import 'package:island/accounts/accounts_models/account.dart';
String? getActivityTitle(String? label, Map<String, dynamic>? meta) {
if (meta == null) return label;
if (meta['assets']?['large_text'] is String) {
return meta['assets']?['large_text'];
}
return label;
}
String? getActivitySubtitle(Map<String, dynamic>? meta) {
if (meta == null) return null;
if (meta['assets']?['small_text'] is String) {
return meta['assets']?['small_text'];
}
return null;
}
InlineSpan getActivityFullMessage(SnAccountStatus? status) {
if (status?.meta == null) return TextSpan(text: 'No activity details available');
final meta = status!.meta!;
final List<InlineSpan> spans = [];
if (meta.containsKey('assets') && meta['assets'] is Map) {
final assets = meta['assets'] as Map<String, dynamic>;
if (assets.containsKey('large_text')) {
spans.add(TextSpan(text: assets['large_text'], style: TextStyle(fontWeight: FontWeight.bold)));
}
if (assets.containsKey('small_text')) {
if (spans.isNotEmpty) spans.add(TextSpan(text: '\n'));
spans.add(TextSpan(text: assets['small_text']));
}
}
String normalText = '';
if (meta.containsKey('details')) {
normalText += 'Details: ${meta['details']}\n';
}
if (meta.containsKey('state')) {
normalText += 'State: ${meta['state']}\n';
}
if (meta.containsKey('timestamps') && meta['timestamps'] is Map) {
final ts = meta['timestamps'] as Map<String, dynamic>;
if (ts.containsKey('start') && ts['start'] is int) {
final start = DateTime.fromMillisecondsSinceEpoch(ts['start'] * 1000);
normalText += 'Started: ${start.toLocal()}\n';
}
if (ts.containsKey('end') && ts['end'] is int) {
final end = DateTime.fromMillisecondsSinceEpoch(ts['end'] * 1000);
normalText += 'Ends: ${end.toLocal()}\n';
}
}
if (meta.containsKey('party') && meta['party'] is Map) {
final party = meta['party'] as Map<String, dynamic>;
if (party.containsKey('size') && party['size'] is List && party['size'].length >= 2) {
final size = party['size'] as List;
normalText += 'Party: ${size[0]}/${size[1]}\n';
}
}
if (meta.containsKey('instance')) {
normalText += 'Instance: ${meta['instance']}\n';
}
// Add other keys if present
meta.forEach((key, value) {
if (!['details', 'state', 'timestamps', 'assets', 'party', 'secrets', 'instance'].contains(key)) {
normalText += '$key: $value\n';
}
});
if (normalText.isNotEmpty) {
if (spans.isNotEmpty) spans.add(TextSpan(text: '\n'));
spans.add(TextSpan(text: normalText.trimRight()));
}
return TextSpan(children: spans);
}
Widget buildActivityDetails(SnAccountStatus? status) {
if (status?.meta == null) return Text('No activity details available');
final meta = status!.meta!;
final List<Widget> children = [];
if (meta.containsKey('assets') && meta['assets'] is Map) {
final assets = meta['assets'] as Map<String, dynamic>;
if (assets.containsKey('large_text')) {
children.add(Text(assets['large_text']));
}
if (assets.containsKey('small_text')) {
children.add(Text(assets['small_text']));
}
}
if (meta.containsKey('details')) {
children.add(Text('Details: ${meta['details']}'));
}
if (meta.containsKey('state')) {
children.add(Text('State: ${meta['state']}'));
}
if (meta.containsKey('timestamps') && meta['timestamps'] is Map) {
final ts = meta['timestamps'] as Map<String, dynamic>;
if (ts.containsKey('start') && ts['start'] is int) {
final start = DateTime.fromMillisecondsSinceEpoch(ts['start'] * 1000);
children.add(Text('Started: ${start.toLocal()}'));
}
if (ts.containsKey('end') && ts['end'] is int) {
final end = DateTime.fromMillisecondsSinceEpoch(ts['end'] * 1000);
children.add(Text('Ends: ${end.toLocal()}'));
}
}
if (meta.containsKey('party') && meta['party'] is Map) {
final party = meta['party'] as Map<String, dynamic>;
if (party.containsKey('size') && party['size'] is List && party['size'].length >= 2) {
final size = party['size'] as List;
children.add(Text('Party: ${size[0]}/${size[1]}'));
}
}
if (meta.containsKey('instance')) {
children.add(Text('Instance: ${meta['instance']}'));
}
// Add other keys if present
children.addAll(meta.entries.where((e) => !['details', 'state', 'timestamps', 'assets', 'party', 'secrets', 'instance'].contains(e.key)).map((e) => Text('${e.key}: ${e.value}')));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: children,
);
}

View File

@@ -1,65 +0,0 @@
import 'package:flutter/material.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
/// Returns an appropriate icon widget for the given file based on its MIME type
Widget getFileIcon(
SnCloudFile file, {
required double size,
bool tinyPreview = true,
}) {
final itemType = file.mimeType?.split('/').firstOrNull;
final mimeType = file.mimeType ?? '';
final extension = file.name.split('.').lastOrNull?.toLowerCase() ?? '';
// For images, show the actual image thumbnail
if (itemType == 'image' && tinyPreview) {
return CloudImageWidget(file: file);
}
// Return icon based on MIME type or file extension
final icon = switch ((itemType, mimeType, extension)) {
('image', _, _) => Symbols.image,
('audio', _, _) => Symbols.audio_file,
('video', _, _) => Symbols.video_file,
('application', 'application/pdf', _) => Symbols.picture_as_pdf,
('application', 'application/zip', _) => Symbols.archive,
('application', 'application/x-rar-compressed', _) => Symbols.archive,
(
'application',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
_,
) ||
('application', 'application/msword', _) => Symbols.description,
(
'application',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
_,
) ||
('application', 'application/vnd.ms-excel', _) => Symbols.table_chart,
(
'application',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
_,
) ||
('application', 'application/vnd.ms-powerpoint', _) => Symbols.slideshow,
('text', _, _) => Symbols.article,
('application', _, 'js') ||
('application', _, 'dart') ||
('application', _, 'py') ||
('application', _, 'java') ||
('application', _, 'cpp') ||
('application', _, 'c') ||
('application', _, 'cs') => Symbols.code,
('application', _, 'json') ||
('application', _, 'xml') => Symbols.data_object,
(_, _, 'md') => Symbols.article,
(_, _, 'html') => Symbols.web,
(_, _, 'css') => Symbols.css,
_ => Symbols.description, // Default icon
};
return Icon(icon, size: size, fill: 1).center();
}

View File

@@ -1,71 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/posts/posts_models/post.dart';
import 'package:island/core/config.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/posts/posts_widgets/post/post_item_screenshot.dart';
import 'package:island/posts/posts_widgets/post/post_shared.dart';
import 'package:path_provider/path_provider.dart' show getTemporaryDirectory;
import 'package:screenshot/screenshot.dart';
import 'package:share_plus/share_plus.dart';
import 'package:island/core/services/analytics_service.dart';
/// Shares a post as a screenshot image
Future<void> sharePostAsScreenshot(
BuildContext context,
WidgetRef ref,
SnPost post,
) async {
if (kIsWeb) return;
final screenshotController = ScreenshotController();
showLoadingModal(context);
await screenshotController
.captureFromWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(
ref.watch(sharedPreferencesProvider),
),
repliesProvider(
post.id,
).overrideWithValue(ref.watch(repliesProvider(post.id))),
],
child: Directionality(
textDirection: TextDirection.ltr,
child: SizedBox(
width: 520,
child: PostItemScreenshot(item: post, isFullPost: true),
),
),
),
context: context,
pixelRatio: MediaQuery.of(context).devicePixelRatio,
delay: const Duration(seconds: 1),
)
.then((Uint8List? image) async {
if (image == null) return;
final directory = await getTemporaryDirectory();
final imagePath = await File('${directory.path}/image.png').create();
await imagePath.writeAsBytes(image);
if (!context.mounted) return;
hideLoadingModal(context);
final box = context.findRenderObject() as RenderBox?;
await Share.shareXFiles([
XFile(imagePath.path),
], sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size);
})
.catchError((err) {
if (context.mounted) hideLoadingModal(context);
showErrorAlert(err);
})
.whenComplete(() {
final postTypeStr = post.type == 0 ? 'regular' : 'article';
AnalyticsService().logPostShared(post.id, 'screenshot', postTypeStr);
});
}

View File

@@ -1,715 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:cross_file/cross_file.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:island/core/services/image.dart';
import 'package:video_thumbnail/video_thumbnail.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/core/network.dart';
import 'package:island/drive/drive_service.dart';
import 'package:island/core/utils/format.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/core/widgets/content/sensitive.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:super_context_menu/super_context_menu.dart';
class SensitiveMarksSelector extends StatefulWidget {
final List<int> initial;
final ValueChanged<List<int>>? onChanged;
const SensitiveMarksSelector({
super.key,
required this.initial,
this.onChanged,
});
@override
State<SensitiveMarksSelector> createState() => SensitiveMarksSelectorState();
}
class SensitiveMarksSelectorState extends State<SensitiveMarksSelector> {
late List<int> _selected;
List<int> get current => _selected;
@override
void initState() {
super.initState();
_selected = [...widget.initial];
}
void _toggle(int value) {
setState(() {
if (_selected.contains(value)) {
_selected.remove(value);
} else {
_selected.add(value);
}
});
widget.onChanged?.call([..._selected]);
}
@override
Widget build(BuildContext context) {
// Build a list of all categories in fixed order as int list indices
final categories = kSensitiveCategoriesOrdered;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Wrap(
spacing: 8,
children: [
for (var i = 0; i < categories.length; i++)
FilterChip(
label: Text(categories[i].i18nKey.tr()),
avatar: Text(categories[i].symbol),
selected: _selected.contains(i),
onSelected: (_) => _toggle(i),
),
],
),
],
);
}
}
class AttachmentPreview extends HookConsumerWidget {
final UniversalFile item;
final double? progress;
final bool isUploading;
final Function(int)? onMove;
final Function? onDelete;
final Function? onInsert;
final Function(UniversalFile)? onUpdate;
final Function? onRequestUpload;
final bool isCompact;
final String? thumbnailId;
final Function(String?)? onSetThumbnail;
final bool bordered;
const AttachmentPreview({
super.key,
required this.item,
this.progress,
this.isUploading = false,
this.onRequestUpload,
this.onMove,
this.onDelete,
this.onUpdate,
this.onInsert,
this.isCompact = false,
this.thumbnailId,
this.onSetThumbnail,
this.bordered = false,
});
// GlobalKey for selector
static final GlobalKey<SensitiveMarksSelectorState> _sensitiveSelectorKey =
GlobalKey<SensitiveMarksSelectorState>();
String _getDisplayName() {
return item.displayName ??
(item.data is XFile
? (item.data as XFile).name
: item.isOnCloud
? item.data.name
: '');
}
Future<void> _showRenameSheet(BuildContext context, WidgetRef ref) async {
final nameController = TextEditingController(text: _getDisplayName());
String? errorMessage;
await showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => SheetScaffold(
heightFactor: 0.6,
titleText: 'rename'.tr(),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
child: TextField(
controller: nameController,
decoration: InputDecoration(
labelText: 'fileName'.tr(),
border: const OutlineInputBorder(),
errorText: errorMessage,
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('cancel'.tr()),
),
const Gap(8),
TextButton(
onPressed: () async {
final newName = nameController.text.trim();
if (newName.isEmpty) {
errorMessage = 'fieldCannotBeEmpty'.tr();
return;
}
if (item.isOnCloud) {
try {
showLoadingModal(context);
final apiClient = ref.watch(apiClientProvider);
await apiClient.patch(
'/drive/files/${item.data.id}/name',
data: jsonEncode(newName),
);
final newData = item.data;
newData.name = newName;
onUpdate?.call(
item.copyWith(data: newData, displayName: newName),
);
if (context.mounted) Navigator.pop(context);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
} else {
// Local file rename
onUpdate?.call(item.copyWith(displayName: newName));
if (context.mounted) Navigator.pop(context);
}
},
child: Text('rename'.tr()),
),
],
).padding(horizontal: 16, vertical: 8),
],
),
),
);
}
Future<void> _showSensitiveDialog(BuildContext context, WidgetRef ref) async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => SheetScaffold(
heightFactor: 0.6,
titleText: 'markAsSensitive'.tr(),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
child: Column(
children: [
// Sensitive categories checklist
SensitiveMarksSelector(
key: _sensitiveSelectorKey,
initial: (item.data.sensitiveMarks ?? [])
.map((e) => e as int)
.cast<int>()
.toList(),
onChanged: (marks) {
// Update local data immediately (optimistic)
final newData = item.data;
newData.sensitiveMarks = marks;
final updatedFile = item.copyWith(data: newData);
onUpdate?.call(item.copyWith(data: updatedFile));
},
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('cancel'.tr()),
),
const Gap(8),
TextButton(
onPressed: () async {
try {
showLoadingModal(context);
final apiClient = ref.watch(apiClientProvider);
// Use the current selections from stateful selector via GlobalKey
final selectorState = _sensitiveSelectorKey.currentState;
final marks = selectorState?.current ?? <int>[];
await apiClient.put(
'/drive/files/${item.data.id}/marks',
data: jsonEncode({'sensitive_marks': marks}),
);
final newData = item.data as SnCloudFile;
final updatedFile = item.copyWith(
data: newData.copyWith(sensitiveMarks: marks),
);
onUpdate?.call(updatedFile);
if (context.mounted) Navigator.pop(context);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
},
child: Text('confirm'.tr()),
),
],
).padding(horizontal: 16, vertical: 8),
],
),
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
var ratio = item.isOnCloud
? (item.data.fileMeta?['ratio'] is num
? item.data.fileMeta!['ratio'].toDouble()
: null)
: null;
final innerContentWidget = Stack(
fit: StackFit.expand,
children: [
HookBuilder(
key: ValueKey(item.hashCode),
builder: (context) {
final fallbackIcon = switch (item.type) {
UniversalFileType.video => Symbols.video_file,
UniversalFileType.audio => Symbols.audio_file,
UniversalFileType.image => Symbols.image,
_ => Symbols.insert_drive_file,
};
final mimeType = FileUploader.getMimeType(item);
if (item.isOnCloud) {
return CloudFileWidget(item: item.data);
} else if (item.data is XFile) {
final file = item.data as XFile;
if (file.path.isEmpty) {
return FutureBuilder<Uint8List>(
future: file.readAsBytes(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return Image.memory(snapshot.data!);
}
return const Center(child: CircularProgressIndicator());
},
);
}
switch (item.type) {
case UniversalFileType.image:
return kIsWeb
? Image.network(file.path)
: Image.file(File(file.path));
case UniversalFileType.video:
if (!kIsWeb) {
final thumbnailFuture = useMemoized(
() => VideoThumbnail.thumbnailData(
video: file.path,
imageFormat: ImageFormat.JPEG,
maxWidth: 320,
quality: 50,
),
[file.path],
);
return FutureBuilder<Uint8List?>(
future: thumbnailFuture,
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return Stack(
children: [
Image.memory(snapshot.data!),
Positioned.fill(
child: Center(
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
shape: BoxShape.circle,
),
child: const Icon(
Symbols.play_arrow,
color: Colors.white,
size: 32,
),
),
),
),
],
);
}
return const Center(child: CircularProgressIndicator());
},
);
}
break;
default:
break;
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(fallbackIcon),
const Gap(6),
Text(
_getDisplayName(),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
Text(mimeType, style: TextStyle(fontSize: 10)),
const Gap(1),
FutureBuilder(
future: file.length(),
builder: (context, snapshot) {
if (snapshot.hasData) {
final size = snapshot.data as int;
return Text(formatFileSize(size)).fontSize(11);
}
return const SizedBox.shrink();
},
),
],
).padding(vertical: 32);
} else if (item is List<int> || item is Uint8List) {
switch (item.type) {
case UniversalFileType.image:
return Image.memory(item.data);
default:
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(fallbackIcon),
const Gap(6),
Text(mimeType, style: TextStyle(fontSize: 10)),
const Gap(1),
Text(formatFileSize(item.data.length)).fontSize(11),
],
);
}
}
return Placeholder();
},
),
if (isUploading && progress != null && (progress ?? 0) > 0)
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.3),
padding: EdgeInsets.symmetric(horizontal: 40, vertical: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'${(progress! * 100).toStringAsFixed(2)}%',
style: TextStyle(color: Colors.white),
),
Gap(6),
Center(
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: progress),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
builder: (context, value, child) =>
LinearProgressIndicator(value: value),
),
),
],
),
),
),
if (isUploading && (progress == null || progress == 0))
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.3),
padding: EdgeInsets.symmetric(horizontal: 40, vertical: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'processing'.tr(),
style: TextStyle(color: Colors.white),
),
Gap(6),
Center(child: LinearProgressIndicator(value: null)),
],
),
),
),
if (thumbnailId != null &&
item.isOnCloud &&
(item.data as SnCloudFile).id == thumbnailId)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.primary,
width: 3,
),
borderRadius: BorderRadius.circular(8),
),
),
),
if (thumbnailId != null &&
item.isOnCloud &&
(item.data as SnCloudFile).id == thumbnailId)
Positioned(
bottom: 8,
right: 8,
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Symbols.image,
size: 16,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
);
final contentWidget = Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
border: bordered
? Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
width: 1,
)
: null,
borderRadius: BorderRadius.circular(8),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
children: [
if (ratio != null)
AspectRatio(
aspectRatio: ratio,
child: innerContentWidget,
).center()
else
IntrinsicHeight(child: innerContentWidget).center(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Colors.black.withOpacity(0.5),
child: Material(
color: Colors.transparent,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (onDelete != null)
InkWell(
borderRadius: BorderRadius.circular(8),
child: Icon(
item.isLink ? Symbols.link_off : Symbols.delete,
size: 14,
color: Colors.white,
).padding(horizontal: 8, vertical: 6),
onTap: () {
onDelete?.call();
},
),
if (onDelete != null && onMove != null)
SizedBox(
height: 26,
child: const VerticalDivider(
width: 0.3,
color: Colors.white,
thickness: 0.3,
),
).padding(horizontal: 2),
if (onMove != null)
InkWell(
borderRadius: BorderRadius.circular(8),
child: const Icon(
Symbols.keyboard_arrow_up,
size: 14,
color: Colors.white,
).padding(horizontal: 8, vertical: 6),
onTap: () {
onMove?.call(-1);
},
),
if (onMove != null)
InkWell(
borderRadius: BorderRadius.circular(8),
child: const Icon(
Symbols.keyboard_arrow_down,
size: 14,
color: Colors.white,
).padding(horizontal: 8, vertical: 6),
onTap: () {
onMove?.call(1);
},
),
if (onInsert != null)
InkWell(
borderRadius: BorderRadius.circular(8),
child: const Icon(
Symbols.add,
size: 14,
color: Colors.white,
).padding(horizontal: 8, vertical: 6),
onTap: () {
onInsert?.call();
},
),
],
),
),
),
),
if (onRequestUpload != null)
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: item.isOnCloud
? null
: () => onRequestUpload?.call(),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Colors.black.withOpacity(0.5),
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: (item.isOnCloud)
? Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.cloud,
size: 16,
color: Colors.white,
),
if (!isCompact) const Gap(8),
if (!isCompact)
Text(
'attachmentOnCloud'.tr(),
style: TextStyle(color: Colors.white),
),
],
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.cloud_off,
size: 16,
color: Colors.white,
),
if (!isCompact) const Gap(8),
if (!isCompact)
Text(
'attachmentOnDevice'.tr(),
style: TextStyle(color: Colors.white),
),
],
),
),
),
),
],
).padding(horizontal: 12, vertical: 8),
],
),
),
);
return ContextMenuWidget(
menuProvider: (MenuRequest request) => Menu(
children: [
if (item.isOnDevice && item.type == UniversalFileType.image)
MenuAction(
title: 'crop'.tr(),
image: MenuImage.icon(Symbols.crop),
callback: () async {
final result = await cropImage(
context,
image: item.data,
replacePath: true,
);
if (result == null) return;
onUpdate?.call(item.copyWith(data: result));
},
),
if (item.isOnDevice)
MenuAction(
title: 'rename'.tr(),
image: MenuImage.icon(Symbols.edit),
callback: () async {
await _showRenameSheet(context, ref);
},
),
if (item.isOnCloud)
MenuAction(
title: 'rename'.tr(),
image: MenuImage.icon(Symbols.edit),
callback: () async {
await _showRenameSheet(context, ref);
},
),
if (item.isOnCloud)
MenuAction(
title: 'markAsSensitive'.tr(),
image: MenuImage.icon(Symbols.no_adult_content),
callback: () async {
await _showSensitiveDialog(context, ref);
},
),
if (item.isOnCloud &&
item.type == UniversalFileType.image &&
onSetThumbnail != null)
MenuAction(
title: thumbnailId == (item.data as SnCloudFile).id
? 'unsetAsThumbnail'.tr()
: 'setAsThumbnail'.tr(),
image: MenuImage.icon(Symbols.image),
callback: () {
final isCurrentlyThumbnail =
thumbnailId == (item.data as SnCloudFile).id;
if (isCurrentlyThumbnail) {
onSetThumbnail?.call(null);
} else {
onSetThumbnail?.call((item.data as SnCloudFile).id);
}
},
),
],
),
child: contentWidget,
);
}
}

View File

@@ -1,178 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/core/config.dart';
import 'package:island/core/network.dart';
import 'package:island/core/services/time.dart';
import 'package:island/talker.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:media_kit/media_kit.dart';
import 'package:styled_widget/styled_widget.dart';
class UniversalAudio extends ConsumerStatefulWidget {
final String uri;
final String filename;
final bool autoplay;
const UniversalAudio({
super.key,
required this.uri,
required this.filename,
this.autoplay = false,
});
@override
ConsumerState<UniversalAudio> createState() => _UniversalAudioState();
}
class _UniversalAudioState extends ConsumerState<UniversalAudio> {
Player? _player;
Duration _duration = Duration(seconds: 1);
Duration _duartionBuffered = Duration(seconds: 1);
Duration _position = Duration(seconds: 0);
bool _sliderWorking = false;
Duration _sliderPosition = Duration(seconds: 0);
void _openAudio() async {
final url = widget.uri;
MediaKit.ensureInitialized();
_player = Player();
_player!.stream.position.listen((value) {
_position = value;
if (!_sliderWorking) _sliderPosition = _position;
setState(() {});
});
_player!.stream.buffer.listen((value) {
_duartionBuffered = value;
setState(() {});
});
_player!.stream.duration.listen((value) {
_duration = value;
setState(() {});
});
String? uri;
final inCacheInfo = await DefaultCacheManager().getFileFromCache(url);
if (inCacheInfo == null) {
talker.info('[MediaPlayer] Miss cache: $url');
final serverUrl = ref.read(serverUrlProvider);
final token = ref.read(tokenProvider);
final authHeaders = url.startsWith(serverUrl) && token != null
? {'Authorization': 'AtField ${token.token}'}
: null;
DefaultCacheManager().downloadFile(
url,
authHeaders: authHeaders,
);
uri = url;
} else {
uri = inCacheInfo.file.path;
talker.info('[MediaPlayer] Hit cache: $url');
}
final serverUrl = ref.read(serverUrlProvider);
final token = ref.read(tokenProvider);
final Map<String, String>? httpHeaders = uri.startsWith(serverUrl) && token != null
? {'Authorization': 'AtField ${token.token}'}
: null;
_player!.open(Media(uri, httpHeaders: httpHeaders), play: widget.autoplay);
}
@override
void initState() {
super.initState();
_openAudio();
}
@override
void dispose() {
super.dispose();
_player?.dispose();
}
@override
Widget build(BuildContext context) {
if (_player == null) {
return Center(child: CircularProgressIndicator());
}
return Card(
color: Theme.of(context).colorScheme.surfaceContainerLowest,
child: Row(
children: [
IconButton.filled(
onPressed: () {
_player!.playOrPause().then((_) {
if (mounted) setState(() {});
});
},
icon:
_player!.state.playing
? const Icon(Symbols.pause, fill: 1, color: Colors.white)
: const Icon(
Symbols.play_arrow,
fill: 1,
color: Colors.white,
),
),
const Gap(20),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child:
(_player!.state.playing || _sliderWorking)
? SizedBox(
width: double.infinity,
key: const ValueKey('playing'),
child: Text(
'${_position.formatShortDuration()} / ${_duration.formatShortDuration()}',
),
)
: SizedBox(
width: double.infinity,
key: const ValueKey('filename'),
child: Text(
widget.filename.isEmpty
? 'Audio'
: widget.filename,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
Slider(
value: _sliderPosition.inMilliseconds.toDouble(),
secondaryTrackValue:
_duartionBuffered.inMilliseconds.toDouble(),
max: _duration.inMilliseconds.toDouble(),
onChangeStart: (_) {
_sliderWorking = true;
},
onChanged: (value) {
_sliderPosition = Duration(milliseconds: value.toInt());
setState(() {});
},
onChangeEnd: (value) {
_sliderPosition = Duration(milliseconds: value.toInt());
_sliderWorking = false;
_player!.seek(_sliderPosition);
},
year2023: true,
padding: EdgeInsets.zero,
),
],
),
),
],
).padding(horizontal: 24, vertical: 16),
);
}
}

View File

@@ -1,610 +0,0 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_blurhash/flutter_blurhash.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/drive/drive_models/file.dart';
import 'package:island/core/config.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/core/widgets/content/cloud_file_lightbox.dart';
import 'package:island/core/widgets/content/sensitive.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:uuid/uuid.dart';
class CloudFileList extends HookConsumerWidget {
final List<SnCloudFile> files;
final double maxHeight;
final double maxWidth;
final double? minWidth;
final bool disableZoomIn;
final bool disableConstraint;
final EdgeInsets? padding;
final bool isColumn;
const CloudFileList({
super.key,
required this.files,
this.maxHeight = 560,
this.maxWidth = double.infinity,
this.minWidth,
this.disableZoomIn = false,
this.disableConstraint = false,
this.padding,
this.isColumn = false,
});
double calculateAspectRatio() {
final ratios = <double>[];
// Collect all valid ratios
for (final file in files) {
final meta = file.fileMeta;
if (meta is Map<String, dynamic> && meta.containsKey('ratio')) {
final ratioValue = meta['ratio'];
if (ratioValue is num && ratioValue > 0) {
ratios.add(ratioValue.toDouble());
} else if (ratioValue is String) {
try {
final parsed = double.parse(ratioValue);
if (parsed > 0) ratios.add(parsed);
} catch (_) {
// Skip invalid string ratios
}
}
}
}
if (ratios.isEmpty) {
// Default to 4:3 aspect ratio when no valid ratios found
return 4 / 3;
}
if (ratios.length == 1) {
return ratios.first;
}
// Group similar ratios and find the most common one
final commonRatios = <double, int>{};
// Common aspect ratios to round to (with tolerance)
const tolerance = 0.05;
final standardRatios = [
1.0,
4 / 3,
3 / 2,
16 / 9,
5 / 3,
5 / 4,
7 / 5,
9 / 16,
2 / 3,
3 / 4,
4 / 5,
];
for (final ratio in ratios) {
// Find the closest standard ratio within tolerance
double closestRatio = ratio;
double minDiff = double.infinity;
for (final standard in standardRatios) {
final diff = (ratio - standard).abs();
if (diff < minDiff && diff <= tolerance) {
minDiff = diff;
closestRatio = standard;
}
}
// If no standard ratio is close enough, keep original
if (minDiff == double.infinity || minDiff > tolerance) {
closestRatio = ratio;
}
commonRatios[closestRatio] = (commonRatios[closestRatio] ?? 0) + 1;
}
// Find the most frequent ratio(s)
int maxCount = 0;
final mostFrequent = <double>[];
for (final entry in commonRatios.entries) {
if (entry.value > maxCount) {
maxCount = entry.value;
mostFrequent.clear();
mostFrequent.add(entry.key);
} else if (entry.value == maxCount) {
mostFrequent.add(entry.key);
}
}
// If only one most frequent ratio, return it
if (mostFrequent.length == 1) {
return mostFrequent.first;
}
// If multiple ratios have the same highest frequency, use median of them
mostFrequent.sort();
final mid = mostFrequent.length ~/ 2;
return mostFrequent.length.isEven
? (mostFrequent[mid - 1] + mostFrequent[mid]) / 2
: mostFrequent[mid];
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final heroTags = useMemoized(
() => List.generate(
files.length,
(index) => 'cloud-files#${const Uuid().v4()}',
),
[files],
);
if (files.isEmpty) return const SizedBox.shrink();
if (isColumn) {
final children = <Widget>[];
const maxFiles = 2;
final filesToShow = files.take(maxFiles).toList();
for (var i = 0; i < filesToShow.length; i++) {
final file = filesToShow[i];
final isImage = file.mimeType?.startsWith('image') ?? false;
final isAudio = file.mimeType?.startsWith('audio') ?? false;
final widgetItem = ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: _CloudFileListEntry(
file: file,
heroTag: heroTags[i],
isImage: isImage,
disableZoomIn: disableZoomIn,
onTap: () {
if (!isImage) {
return;
}
if (!disableZoomIn) {
context.pushTransparentRoute(
CloudFileLightbox(item: file, heroTag: heroTags[i]),
rootNavigator: true,
);
}
},
),
);
Widget item;
if (isAudio) {
item = SizedBox(height: 120, child: widgetItem);
} else {
item = AspectRatio(
aspectRatio: file.fileMeta?['ratio'] as double? ?? 1.0,
child: widgetItem,
);
}
children.add(item);
if (i < filesToShow.length - 1) {
children.add(const Gap(8));
}
}
if (files.length > maxFiles) {
children.add(const Gap(8));
children.add(
Text(
'filesListAdditional'.plural(files.length - filesToShow.length),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
),
);
}
return Padding(
padding: padding ?? EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
);
}
if (files.length == 1) {
final isImage = files.first.mimeType?.startsWith('image') ?? false;
final isAudio = files.first.mimeType?.startsWith('audio') ?? false;
final ratio = files.first.fileMeta?['ratio'] as num?;
final widgetItem = ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: _CloudFileListEntry(
file: files.first,
heroTag: heroTags.first,
isImage: isImage,
disableZoomIn: disableZoomIn,
onTap: () {
if (!isImage) {
return;
}
if (!disableZoomIn) {
context.pushTransparentRoute(
CloudFileLightbox(item: files.first, heroTag: heroTags.first),
rootNavigator: true,
);
}
},
),
);
return Container(
padding: padding,
constraints: BoxConstraints(
maxHeight: disableConstraint ? double.infinity : maxHeight,
minWidth: minWidth ?? 0,
maxWidth: files.length == 1 ? maxWidth : double.infinity,
),
child: (ratio == null && isImage)
? IntrinsicWidth(child: IntrinsicHeight(child: widgetItem))
: (ratio == null && isAudio)
? IntrinsicHeight(child: widgetItem)
: AspectRatio(
aspectRatio: ratio?.toDouble() ?? 1,
child: widgetItem,
),
);
}
final allImages = !files.any(
(e) => e.mimeType == null || !e.mimeType!.startsWith('image'),
);
if (allImages) {
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth),
child: AspectRatio(
aspectRatio: calculateAspectRatio(),
child: Padding(
padding: padding ?? EdgeInsets.zero,
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth.isFinite
? constraints.maxWidth
: MediaQuery.of(context).size.width;
final itemExtent = math.min(
math.min(availableWidth * 0.75, maxWidth * 0.75).toDouble(),
640.0,
);
return CarouselView(
itemSnapping: true,
itemExtent: itemExtent,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(16)),
),
children: [
for (var i = 0; i < files.length; i++)
Stack(
children: [
_CloudFileListEntry(
file: files[i],
heroTag: heroTags[i],
isImage:
files[i].mimeType?.startsWith('image') ?? false,
disableZoomIn: disableZoomIn,
),
Positioned(
bottom: 12,
left: 16,
child: Text('${i + 1}/${files.length}')
.textColor(Colors.white)
.textShadow(
color: Colors.black54,
offset: Offset(1, 1),
blurRadius: 3,
),
),
],
),
],
onTap: (i) {
if (!(files[i].mimeType?.startsWith('image') ?? false)) {
return;
}
if (!disableZoomIn) {
context.pushTransparentRoute(
CloudFileLightbox(item: files[i], heroTag: heroTags[i]),
rootNavigator: true,
);
}
},
);
},
),
),
),
);
}
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth),
child: AspectRatio(
aspectRatio: calculateAspectRatio(),
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: files.length,
padding: padding,
itemBuilder: (context, index) {
return AspectRatio(
aspectRatio: files[index].fileMeta?['ratio'] is num
? files[index].fileMeta!['ratio'].toDouble()
: 1.0,
child: Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: _CloudFileListEntry(
file: files[index],
heroTag: heroTags[index],
isImage:
files[index].mimeType?.startsWith('image') ?? false,
disableZoomIn: disableZoomIn,
onTap: () {
if (!(files[index].mimeType?.startsWith('image') ??
false)) {
return;
}
if (!disableZoomIn) {
context.pushTransparentRoute(
CloudFileLightbox(
item: files[index],
heroTag: heroTags[index],
),
rootNavigator: true,
);
}
},
),
),
Positioned(
bottom: 12,
left: 16,
child: Text('${index + 1}/${files.length}')
.textColor(Colors.white)
.textShadow(
color: Colors.black54,
offset: Offset(1, 1),
blurRadius: 3,
),
),
],
),
);
},
separatorBuilder: (_, _) => const Gap(8),
),
),
);
}
}
class _CloudFileListEntry extends HookConsumerWidget {
final SnCloudFile file;
final String heroTag;
final bool isImage;
final bool disableZoomIn;
final VoidCallback? onTap;
const _CloudFileListEntry({
required this.file,
required this.heroTag,
required this.isImage,
required this.disableZoomIn,
this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final dataSaving = ref.watch(
appSettingsProvider.select((s) => s.dataSavingMode),
);
final showMature = useState(false);
final showDataSaving = useState(!dataSaving);
final lockedByDS = dataSaving && !showDataSaving.value;
final lockedByMature = file.sensitiveMarks.isNotEmpty && !showMature.value;
final meta = file.fileMeta is Map ? file.fileMeta as Map : const {};
final fit = BoxFit.cover;
Widget bg = const SizedBox.shrink();
if (isImage) {
if (meta['blur'] is String) {
bg = BlurHash(hash: meta['blur'] as String);
} else if (!lockedByDS && !lockedByMature) {
bg = ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
child: CloudFileWidget(
fit: BoxFit.cover,
item: file,
noBlurhash: true,
useInternalGate: false,
),
);
} else {
bg = const ColoredBox(color: Colors.black26);
}
}
final bool fullyUnlocked = !lockedByDS && !lockedByMature;
Widget fg = fullyUnlocked
? (isImage
? CloudFileWidget(
item: file,
heroTag: heroTag,
noBlurhash: true,
fit: fit,
useInternalGate: false,
)
: CloudFileWidget(
item: file,
heroTag: heroTag,
fit: fit,
useInternalGate: false,
))
: const SizedBox.shrink();
Widget overlays = AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: lockedByDS
? _DataSavingOverlay(key: const ValueKey('ds'))
: (file.sensitiveMarks.isNotEmpty && !showMature.value
? _SensitiveOverlay(
key: const ValueKey('sensitive-blur'),
file: file,
)
: const SizedBox.shrink(key: ValueKey('none'))),
);
Widget hideButton = const SizedBox.shrink();
if (file.sensitiveMarks.isNotEmpty && showMature.value) {
hideButton = Positioned(
top: 3,
left: 4,
child: IconButton(
iconSize: 16,
constraints: const BoxConstraints(),
icon: const Icon(
Icons.visibility_off,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black,
blurRadius: 5.0,
offset: Offset(1.0, 1.0),
),
],
),
tooltip: 'Blur content',
onPressed: () => showMature.value = false,
),
);
}
final content = Stack(
fit: StackFit.expand,
children: [
if (isImage) Positioned.fill(child: bg),
fg,
overlays,
hideButton,
],
);
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(16)),
onTap: () {
if (lockedByDS) {
showDataSaving.value = true;
} else if (lockedByMature) {
showMature.value = true;
} else {
onTap?.call();
}
},
child: content,
);
}
}
class _SensitiveOverlay extends StatelessWidget {
final SnCloudFile file;
const _SensitiveOverlay({required this.file, super.key});
@override
Widget build(BuildContext context) {
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 64, sigmaY: 64),
child: Container(
color: Colors.transparent,
child: Center(
child: _OverlayCard(
icon: Icons.warning,
title: file.sensitiveMarks
.map((e) => SensitiveCategory.values[e].i18nKey.tr())
.join(' · '),
subtitle: 'Sensitive Content',
hint: 'Tap to Reveal',
),
),
),
);
}
}
class _DataSavingOverlay extends StatelessWidget {
const _DataSavingOverlay({super.key});
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.black38,
child: Center(
child: _OverlayCard(
icon: Symbols.image,
title: 'Data Saving Mode',
subtitle: '',
hint: 'Tap to Load',
),
),
);
}
}
class _OverlayCard extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final String hint;
const _OverlayCard({
required this.icon,
required this.title,
required this.subtitle,
required this.hint,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(12),
),
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white, size: 24),
const Gap(4),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
Text(
subtitle,
style: const TextStyle(color: Colors.white, fontSize: 13),
),
const Gap(4),
Text(hint, style: const TextStyle(color: Colors.white, fontSize: 11)),
],
),
);
}
}

View File

@@ -1,158 +0,0 @@
import 'package:dismissible_page/dismissible_page.dart';
import 'package:flutter/foundation.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/drive/drive_models/file.dart';
import 'package:island/core/config.dart';
import 'package:island/drive/drive_service.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/core/widgets/content/exif_info_overlay.dart';
import 'package:island/core/widgets/content/file_action_button.dart';
import 'package:island/core/widgets/content/file_info_sheet.dart';
import 'package:island/core/widgets/content/image_control_overlay.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:photo_view/photo_view.dart';
class CloudFileLightbox extends HookConsumerWidget {
final SnCloudFile item;
final String heroTag;
const CloudFileLightbox({
super.key,
required this.item,
required this.heroTag,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final serverUrl = ref.watch(serverUrlProvider);
final photoViewController = useMemoized(() => PhotoViewController(), []);
final rotation = useState(0);
final hasExifData = ExifInfoOverlay.precheck(item);
final showOriginal = useState(false);
final showExif = useState(hasExifData);
void saveToGallery() {
FileDownloadService(ref).saveToGallery(item);
}
void showInfoSheet() {
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder: (context) => FileInfoSheet(item: item),
);
}
return DismissiblePage(
isFullScreen: true,
backgroundColor: Colors.transparent,
direction: DismissiblePageDismissDirection.down,
onDismissed: () {
Navigator.of(context).pop();
},
child: Stack(
children: [
Positioned.fill(
child: PhotoView(
backgroundDecoration: BoxDecoration(
color: Colors.black.withOpacity(0.9),
),
controller: photoViewController,
heroAttributes: PhotoViewHeroAttributes(tag: heroTag),
imageProvider: CloudImageWidget.provider(
file: item,
serverUrl: serverUrl,
original: showOriginal.value,
),
// Apply rotation transformation
customSize: MediaQuery.of(context).size,
basePosition: Alignment.center,
filterQuality: FilterQuality.high,
),
),
// Close button and save button
Positioned(
top: MediaQuery.of(context).padding.top + 16,
right: 16,
left: 16,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
if (!kIsWeb)
FileActionButton.save(
onPressed: saveToGallery,
shadows: WhiteShadows.standard,
),
IconButton(
onPressed: () {
showOriginal.value = !showOriginal.value;
},
icon: Icon(
showOriginal.value ? Symbols.hd : Symbols.sd,
color: Colors.white,
shadows: WhiteShadows.standard,
),
),
],
),
FileActionButton.close(
onPressed: () => Navigator.of(context).pop(),
shadows: WhiteShadows.standard,
),
],
),
),
// EXIF Info Overlay (positioned below the top buttons)
if (showExif.value)
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 60,
left: 16,
right: 16,
child: ExifInfoOverlay(item: item),
),
ImageControlOverlay(
photoViewController: photoViewController,
rotation: rotation,
showOriginal: showOriginal.value,
onToggleQuality: () {
showOriginal.value = !showOriginal.value;
},
showExifInfo: showExif.value,
onToggleExif: () {
showExif.value = !showExif.value;
},
hasExifData: hasExifData,
extraButtons: [
FileActionButton.info(
onPressed: showInfoSheet,
shadows: WhiteShadows.standard,
),
if (item.url != null)
FileActionButton.more(
onPressed: () {
final router = GoRouter.of(context);
Navigator.of(context).pop(context);
Future(() {
router.pushNamed(
'fileDetail',
pathParameters: {'id': item.id},
extra: item,
);
});
},
shadows: WhiteShadows.standard,
),
],
showExtraOnLeft: true,
),
],
),
);
}
}

View File

@@ -1,309 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.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:image_picker/image_picker.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/drive/drive_service.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/core/widgets/content/attachment_preview.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class CloudFilePicker extends HookConsumerWidget {
final bool allowMultiple;
final Set<UniversalFileType> allowedTypes;
const CloudFilePicker({
super.key,
this.allowMultiple = false,
this.allowedTypes = const {
UniversalFileType.image,
UniversalFileType.video,
UniversalFileType.file,
},
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final files = useState<List<UniversalFile>>([]);
final uploadPosition = useState<int?>(null);
final uploadProgress = useState<double?>(null);
final uploadOverallProgress = useMemoized<double?>(() {
if (uploadPosition.value == null || uploadProgress.value == null) {
return null;
}
// Calculate completed files (100% each) + current file progress
final completedProgress = uploadPosition.value! * 100.0;
final currentProgress = uploadProgress.value!;
// Calculate overall progress as percentage
return (completedProgress + currentProgress) /
(files.value.length * 100.0);
}, [uploadPosition.value, uploadProgress.value, files.value.length]);
Future<void> startUpload() async {
if (files.value.isEmpty) return;
List<SnCloudFile> result = List.empty(growable: true);
uploadProgress.value = 0;
uploadPosition.value = 0;
try {
for (var idx = 0; idx < files.value.length; idx++) {
uploadPosition.value = idx;
final file = files.value[idx];
final cloudFile = await FileUploader.createCloudFile(
fileData: file,
ref: ref,
onProgress: (progress, _) {
uploadProgress.value = progress;
},
).future;
if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...');
}
result.add(cloudFile);
}
if (context.mounted) Navigator.pop(context, result);
} catch (err) {
showErrorAlert(err);
}
}
void pickFile() async {
showLoadingModal(context);
final result = await FilePicker.platform.pickFiles(
allowMultiple: allowMultiple,
);
if (result == null) {
if (context.mounted) hideLoadingModal(context);
return;
}
final newFiles = result.files.map((e) {
final xfile = e.bytes != null
? XFile.fromData(e.bytes!, name: e.name)
: XFile(e.path!);
return UniversalFile(data: xfile, type: UniversalFileType.file);
}).toList();
if (!allowMultiple) {
files.value = newFiles;
if (context.mounted) {
hideLoadingModal(context);
startUpload();
}
return;
}
files.value = [...files.value, ...newFiles];
if (context.mounted) hideLoadingModal(context);
}
void pickImage() async {
showLoadingModal(context);
final ImagePicker picker = ImagePicker();
List<XFile> results;
if (allowMultiple) {
results = await picker.pickMultiImage();
} else {
final XFile? result = await picker.pickImage(
source: ImageSource.gallery,
);
results = result != null ? [result] : [];
}
if (results.isEmpty) {
if (context.mounted) hideLoadingModal(context);
return;
}
final newFiles = results
.map(
(xfile) =>
UniversalFile(data: xfile, type: UniversalFileType.image),
)
.toList();
if (!allowMultiple) {
files.value = newFiles;
if (context.mounted) {
hideLoadingModal(context);
startUpload();
}
return;
}
files.value = [...files.value, ...newFiles];
if (context.mounted) hideLoadingModal(context);
}
void pickVideo() async {
showLoadingModal(context);
final result = await FilePicker.platform.pickFiles(
allowMultiple: allowMultiple,
type: FileType.video,
);
if (result == null || result.files.isEmpty) {
if (context.mounted) hideLoadingModal(context);
return;
}
final newFiles = result.files.map((e) {
final xfile = e.bytes != null
? XFile.fromData(e.bytes!, name: e.name)
: XFile(e.path!);
return UniversalFile(data: xfile, type: UniversalFileType.video);
}).toList();
if (!allowMultiple) {
files.value = newFiles;
if (context.mounted) {
hideLoadingModal(context);
startUpload();
}
return;
}
files.value = [...files.value, ...newFiles];
if (context.mounted) hideLoadingModal(context);
}
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.5,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
child: Row(
children: [
Text(
'pickFile'.tr(),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
),
const Spacer(),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => Navigator.pop(context),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
),
],
),
),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
child: Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (uploadOverallProgress != null)
Column(
spacing: 6,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('uploadingProgress')
.tr(
args: [
((uploadPosition.value ?? 0) + 1).toString(),
files.value.length.toString(),
],
)
.opacity(0.85),
LinearProgressIndicator(
value: uploadOverallProgress,
color: Theme.of(context).colorScheme.primary,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceVariant,
),
],
),
if (files.value.isNotEmpty)
Align(
alignment: Alignment.centerLeft,
child: ElevatedButton.icon(
onPressed: startUpload,
icon: const Icon(Symbols.play_arrow),
label: Text('uploadAll'.tr()),
),
),
if (files.value.isNotEmpty)
SizedBox(
height: 280,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: files.value.length,
itemBuilder: (context, idx) {
return AttachmentPreview(
onDelete: uploadOverallProgress != null
? null
: () {
files.value = [
...files.value.where(
(e) => e != files.value[idx],
),
];
},
item: files.value[idx],
progress: null,
);
},
separatorBuilder: (_, _) => const Gap(8),
),
),
Card(
color: Theme.of(context).colorScheme.surfaceContainer,
margin: EdgeInsets.zero,
child: Column(
children: [
if (allowedTypes.contains(UniversalFileType.image))
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
leading: const Icon(Symbols.photo),
title: Text('addPhoto'.tr()),
onTap: () => pickImage(),
),
if (allowedTypes.contains(UniversalFileType.video))
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
leading: const Icon(Symbols.video_call),
title: Text('addVideo'.tr()),
onTap: () => pickVideo(),
),
if (allowedTypes.contains(UniversalFileType.file))
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
leading: const Icon(Symbols.draft),
title: Text('addFile'.tr()),
onTap: () => pickFile(),
),
],
),
),
],
).padding(all: 24),
),
),
],
),
);
}
}

View File

@@ -1,122 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:island/polls/polls_widgets/poll/poll_submit.dart';
import 'package:island/posts/posts_models/embed.dart';
import 'package:island/core/widgets/content/embed/link.dart';
import 'package:island/wallet/wallet_widgets/wallet/fund_envelope.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
class EmbedListWidget extends StatelessWidget {
final List<dynamic> embeds;
final bool isInteractive;
final bool isFullPost;
final EdgeInsets renderingPadding;
final double? maxWidth;
const EmbedListWidget({
super.key,
required this.embeds,
this.isInteractive = true,
this.isFullPost = false,
this.renderingPadding = EdgeInsets.zero,
this.maxWidth,
});
@override
Widget build(BuildContext context) {
final linkEmbeds = embeds.where((e) => e['type'] == 'link').toList();
final otherEmbeds = embeds.where((e) => e['type'] != 'link').toList();
return Column(
children: [
if (linkEmbeds.isNotEmpty)
Container(
margin: EdgeInsets.only(
top: 8,
left: renderingPadding.horizontal,
right: renderingPadding.horizontal,
),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor.withOpacity(0.5),
),
borderRadius: BorderRadius.circular(8),
),
child: Theme(
data: Theme.of(
context,
).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
initiallyExpanded: true,
dense: true,
leading: const Icon(Symbols.link),
title: Text('embedLinks'.plural(linkEmbeds.length)),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: linkEmbeds.length == 1
? EmbedLinkWidget(
link: SnScrappedLink.fromJson(linkEmbeds.first),
)
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: linkEmbeds
.map(
(embedData) => EmbedLinkWidget(
link: SnScrappedLink.fromJson(embedData),
maxWidth:
200, // Fixed width for horizontal scroll
margin: const EdgeInsets.symmetric(
horizontal: 4,
),
),
)
.toList(),
),
),
),
],
),
),
),
...otherEmbeds.map(
(embedData) => switch (embedData['type']) {
'poll' => Card(
margin: EdgeInsets.symmetric(
horizontal: renderingPadding.horizontal,
vertical: 8,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: embedData['id'] == null
? const Text('Poll was unavailable...')
: PollSubmit(
pollId: embedData['id'],
onSubmit: (_) {},
isReadonly: !isInteractive,
isInitiallyExpanded: isFullPost,
),
),
),
'fund' =>
embedData['id'] == null
? const Text('Fund envelope was unavailable...')
: FundEnvelopeWidget(
fundId: embedData['id'],
margin: EdgeInsets.symmetric(
horizontal: renderingPadding.horizontal,
vertical: 8,
),
),
_ => Text('Unable show embed: ${embedData['type']}'),
},
),
],
);
}
}

View File

@@ -1,313 +0,0 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:island/posts/posts_models/embed.dart';
import 'package:island/core/widgets/content/image.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:url_launcher/url_launcher.dart';
class EmbedLinkWidget extends StatefulWidget {
final SnScrappedLink link;
final double? maxWidth;
final EdgeInsetsGeometry? margin;
const EmbedLinkWidget({
super.key,
required this.link,
this.maxWidth,
this.margin,
});
@override
State<EmbedLinkWidget> createState() => _EmbedLinkWidgetState();
}
class _EmbedLinkWidgetState extends State<EmbedLinkWidget> {
bool? _isSquare;
@override
void initState() {
super.initState();
_checkIfSquare();
}
Future<void> _checkIfSquare() async {
if (widget.link.imageUrl == null ||
widget.link.imageUrl!.isEmpty ||
widget.link.imageUrl == widget.link.faviconUrl) {
return;
}
try {
final image = CachedNetworkImageProvider(widget.link.imageUrl!);
final ImageStream stream = image.resolve(ImageConfiguration.empty);
final completer = Completer<ImageInfo>();
final listener = ImageStreamListener((
ImageInfo info,
bool synchronousCall,
) {
completer.complete(info);
});
stream.addListener(listener);
final info = await completer.future;
stream.removeListener(listener);
final aspectRatio = info.image.width / info.image.height;
if (mounted) {
setState(() {
_isSquare = aspectRatio >= 0.9 && aspectRatio <= 1.1;
});
}
} catch (e) {
// If error, assume not square
if (mounted) {
setState(() {
_isSquare = false;
});
}
}
}
Future<void> _launchUrl() async {
final uri = Uri.parse(widget.link.url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
String _getBaseUrl(String url) {
final uri = Uri.parse(url);
final port = uri.port;
final defaultPort = uri.scheme == 'https' ? 443 : 80;
final portString = port != defaultPort ? ':$port' : '';
return '${uri.scheme}://${uri.host}$portString';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
width: widget.maxWidth,
margin: widget.margin ?? const EdgeInsets.symmetric(vertical: 8),
child: Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: _launchUrl,
child: Row(
children: [
// Sqaure open graph image
if (_isSquare == true) ...[
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 120),
child: AspectRatio(
aspectRatio: 1,
child: UniversalImage(
uri: widget.link.imageUrl!,
fit: BoxFit.cover,
),
),
),
const Gap(8),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Preview Image
if (widget.link.imageUrl != null &&
widget.link.imageUrl!.isNotEmpty &&
widget.link.imageUrl != widget.link.faviconUrl &&
_isSquare != true)
Container(
width: double.infinity,
color: Theme.of(
context,
).colorScheme.surfaceContainerHigh,
child: IntrinsicHeight(
child: UniversalImage(
uri: widget.link.imageUrl!,
fit: BoxFit.cover,
useFallbackImage: false,
),
),
),
// Content
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Site info row
Row(
children: [
if (widget.link.faviconUrl?.isNotEmpty ??
false) ...[
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: UniversalImage(
uri:
widget.link.faviconUrl!.startsWith('//')
? 'https:${widget.link.faviconUrl!}'
: widget.link.faviconUrl!.startsWith(
'/',
)
? _getBaseUrl(widget.link.url) +
widget.link.faviconUrl!
: widget.link.faviconUrl!,
width: 16,
height: 16,
fit: BoxFit.cover,
useFallbackImage: false,
),
),
const Gap(8),
] else ...[
Icon(
Symbols.link,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const Gap(8),
],
// Site name
Expanded(
child: Text(
(widget.link.siteName?.isNotEmpty ?? false)
? widget.link.siteName!
: Uri.parse(widget.link.url).host,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// External link icon
Icon(
Symbols.open_in_new,
size: 16,
color: colorScheme.onSurfaceVariant,
),
],
),
const Gap(8),
// Title
if (widget.link.title?.isNotEmpty ?? false) ...[
Text(
widget.link.title!,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: _isSquare == true ? 1 : 2,
overflow: TextOverflow.ellipsis,
),
Gap(_isSquare == true ? 2 : 4),
],
// Description
if (widget.link.description != null &&
widget.link.description!.isNotEmpty) ...[
Text(
widget.link.description!,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: _isSquare == true ? 1 : 3,
overflow: TextOverflow.ellipsis,
),
Gap(_isSquare == true ? 4 : 8),
],
// URL
Text(
widget.link.url,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
decoration: TextDecoration.underline,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Author and publish date
if (widget.link.author != null ||
widget.link.publishedDate != null) ...[
const Gap(8),
Row(
children: [
if (widget.link.author != null) ...[
Icon(
Symbols.person,
size: 14,
color: colorScheme.onSurfaceVariant,
),
const Gap(4),
Text(
widget.link.author!,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
if (widget.link.author != null &&
widget.link.publishedDate != null)
const Gap(16),
if (widget.link.publishedDate != null) ...[
Icon(
Symbols.schedule,
size: 14,
color: colorScheme.onSurfaceVariant,
),
const Gap(4),
Text(
_formatDate(widget.link.publishedDate!),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
],
),
],
],
),
),
],
),
),
],
),
),
),
);
}
String _formatDate(DateTime date) {
try {
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return 'Today';
} else if (difference.inDays == 1) {
return 'Yesterday';
} else if (difference.inDays < 7) {
return '${difference.inDays} days ago';
} else {
return '${date.day}/${date.month}/${date.year}';
}
} catch (e) {
return date.toString();
}
}
}

View File

@@ -1,163 +0,0 @@
import 'package:flutter/material.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:material_symbols_icons/symbols.dart';
class ExifInfoOverlay extends StatelessWidget {
final SnCloudFile item;
const ExifInfoOverlay({super.key, required this.item});
static bool precheck(SnCloudFile item) {
final exifData = item.fileMeta?['exif'] as Map<String, dynamic>? ?? {};
if (exifData.isEmpty) return false;
final dateTime = exifData['ifd0-DateTime'];
final model = exifData['ifd0-Model'];
final iso = exifData['ifd2-ISOSpeedRatings'];
final fnumber = exifData['ifd2-FNumber'];
final exposureTime = exifData['ifd2-ExposureTime'];
final focalLength = exifData['ifd2-FocalLength'];
return (dateTime != null && dateTime.isNotEmpty) ||
(model != null && model.isNotEmpty) ||
iso != null ||
fnumber != null ||
exposureTime != null ||
focalLength != null;
}
bool _isPreferredValue(String key, String value) {
if ([
'ExposureTime',
'FNumber',
'FocalLength',
'ApertureValue',
'DateTime',
].contains(key)) {
return true;
}
return false;
}
String _formatExifValue(String key, String value) {
final lastOpen = value.lastIndexOf('(');
final lastClose = value.endsWith(')') ? value.length - 1 : -1;
if (lastOpen == -1 || lastClose == -1 || lastOpen > lastClose) {
return value;
}
final inside = value.substring(lastOpen + 1, lastClose);
final commaIndex = inside.indexOf(',');
if (commaIndex != -1) {
final candidate = inside.substring(0, commaIndex).trim();
if (_isPreferredValue(key, candidate)) {
return candidate;
}
}
if (lastOpen == -1) {
return value;
}
return value.substring(0, lastOpen).trimRight();
}
@override
Widget build(BuildContext context) {
final exifData = item.fileMeta?['exif'] as Map<String, dynamic>? ?? {};
if (exifData.isEmpty) return const SizedBox.shrink();
final dateTime = exifData['ifd0-DateTime'];
final model = exifData['ifd0-Model'];
final iso = exifData['ifd2-ISOSpeedRatings'];
final fnumber = exifData['ifd2-FNumber'];
final exposureTime = exifData['ifd2-ExposureTime'];
final focalLength = exifData['ifd2-FocalLength'];
final items = <Widget>[];
if (dateTime != null && dateTime.isNotEmpty) {
items.add(_buildExifItem('DateTime', dateTime, Symbols.calendar_check));
}
if (model != null && model.isNotEmpty) {
items.add(_buildExifItem('Model', model, Symbols.camera_alt));
}
if (iso != null) {
items.add(_buildExifItem('ISO', iso, Icons.iso));
}
if (fnumber != null) {
items.add(_buildExifItem('FNumber', fnumber, Symbols.camera_enhance));
}
if (exposureTime != null) {
items.add(
_buildExifItem('ExposureTime', exposureTime, Icons.shutter_speed),
);
}
if (focalLength != null) {
items.add(
_buildExifItem(
'FocalLength',
focalLength,
Symbols.photo_size_select_large,
),
);
}
if (items.isEmpty) return const SizedBox.shrink();
return Material(
color: Colors.transparent,
child: Container(
margin: const EdgeInsets.only(bottom: 16),
child: Wrap(
alignment: WrapAlignment.end,
children: items
.map(
(item) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: item,
),
)
.toList(),
),
),
);
}
Widget _buildExifItem(String key, String value, IconData icon) {
final formattedValue = _formatExifValue(key, value);
final shadow = [
Shadow(
color: Colors.black54,
blurRadius: 5.0,
offset: const Offset(1.0, 1.0),
),
];
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: Colors.white70, shadows: shadow),
const SizedBox(width: 6),
Flexible(
child: Text(
formattedValue,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
shadows: shadow,
),
),
),
],
);
}
}

View File

@@ -1,108 +0,0 @@
import 'package:flutter/material.dart';
enum FileActionType { save, info, more, close, custom }
class FileActionButton extends StatelessWidget {
final FileActionType type;
final IconData? icon;
final VoidCallback onPressed;
final Color? color;
final List<Shadow>? shadows;
final String? tooltip;
const FileActionButton({
super.key,
required this.type,
required this.onPressed,
this.icon,
this.color,
this.shadows,
this.tooltip,
});
factory FileActionButton.save({
Key? key,
required VoidCallback onPressed,
Color? color,
List<Shadow>? shadows,
}) {
return FileActionButton(
key: key,
type: FileActionType.save,
icon: Icons.save_alt,
onPressed: onPressed,
color: color ?? Colors.white,
shadows: shadows,
);
}
factory FileActionButton.info({
Key? key,
required VoidCallback onPressed,
Color? color,
List<Shadow>? shadows,
}) {
return FileActionButton(
key: key,
type: FileActionType.info,
icon: Icons.info_outline,
onPressed: onPressed,
color: color ?? Colors.white,
shadows: shadows,
);
}
factory FileActionButton.more({
Key? key,
required VoidCallback onPressed,
Color? color,
List<Shadow>? shadows,
}) {
return FileActionButton(
key: key,
type: FileActionType.more,
icon: Icons.more_horiz,
onPressed: onPressed,
color: color ?? Colors.white,
shadows: shadows,
);
}
factory FileActionButton.close({
Key? key,
required VoidCallback onPressed,
Color? color,
List<Shadow>? shadows,
}) {
return FileActionButton(
key: key,
type: FileActionType.close,
icon: Icons.close,
onPressed: onPressed,
color: color ?? Colors.white,
shadows: shadows,
);
}
@override
Widget build(BuildContext context) {
final buttonIcon = icon ?? Icons.circle;
final button = IconButton(
icon: Icon(buttonIcon, color: color, shadows: shadows),
onPressed: onPressed,
);
if (tooltip != null) {
return Tooltip(message: tooltip!, child: button);
}
return button;
}
}
class WhiteShadows {
static List<Shadow> get standard => [
Shadow(color: Colors.black54, blurRadius: 5.0, offset: Offset(1.0, 1.0)),
];
}

View File

@@ -1,291 +0,0 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/core/utils/format.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
class FileInfoSheet extends StatelessWidget {
final SnCloudFile item;
final VoidCallback? onClose;
const FileInfoSheet({super.key, required this.item, this.onClose});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final exifData = item.fileMeta?['exif'] as Map<String, dynamic>? ?? {};
return SheetScaffold(
onClose: onClose,
titleText: 'fileInfoTitle'.tr(),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text('mimeType').tr(),
Text(
item.mimeType ?? 'unknown'.tr(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
SizedBox(height: 28, child: const VerticalDivider()),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text('fileSize').tr(),
Text(
formatFileSize(item.size),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
if (item.hash != null)
SizedBox(height: 28, child: const VerticalDivider()),
if (item.hash != null)
Expanded(
child: GestureDetector(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text('fileHash').tr(),
Text(
'${item.hash!.substring(0, 6)}...',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
onLongPress: () {
Clipboard.setData(ClipboardData(text: item.hash!));
showSnackBar('fileHashCopied'.tr());
},
),
),
],
).padding(horizontal: 24, vertical: 16),
const Divider(height: 1),
ListTile(
leading: const Icon(Symbols.tag),
title: Text('ID').tr(),
subtitle: Text(
item.id,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
trailing: IconButton(
icon: const Icon(Icons.copy),
onPressed: () {
Clipboard.setData(ClipboardData(text: item.id));
showSnackBar('fileIdCopied'.tr());
},
),
),
ListTile(
leading: const Icon(Symbols.file_present),
title: Text('Name').tr(),
subtitle: Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
trailing: IconButton(
icon: const Icon(Icons.copy),
onPressed: () {
Clipboard.setData(ClipboardData(text: item.name));
showSnackBar('fileNameCopied'.tr());
},
),
),
ListTile(
leading: const Icon(Symbols.launch),
title: Text('openInBrowser').tr(),
subtitle: Text('https://solian.app/files/${item.id}'),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
onTap: () {
launchUrlString(
'https://solian.app/files/${item.id}',
mode: LaunchMode.externalApplication,
);
},
),
if (exifData.isNotEmpty) ...[
const Divider(height: 1),
Theme(
data: theme.copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(
'exifData'.tr(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...exifData.entries.map(
(entry) => ListTile(
dense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 24,
),
title: Text(
entry.key.contains('-')
? entry.key.split('-').last
: entry.key,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
).bold(),
subtitle: Text(
'${entry.value}'.isNotEmpty
? '${entry.value}'
: 'N/A',
style: theme.textTheme.bodyMedium,
),
onTap: () {
Clipboard.setData(
ClipboardData(text: '${entry.value}'),
);
showSnackBar('valueCopied'.tr());
},
),
),
],
),
],
),
),
],
if (item.fileMeta != null && item.fileMeta!.isNotEmpty) ...[
const Divider(height: 1),
Theme(
data: theme.copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(
'fileMetadata'.tr(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...item.fileMeta!.entries.map(
(entry) => ListTile(
dense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 24,
),
title: Text(
entry.key,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
).bold(),
subtitle: Text(
jsonEncode(entry.value),
style: theme.textTheme.bodyMedium,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
onTap: () {
Clipboard.setData(
ClipboardData(text: jsonEncode(entry.value)),
);
showSnackBar('valueCopied'.tr());
},
),
),
],
),
],
),
),
],
if (item.userMeta != null && item.userMeta!.isNotEmpty) ...[
const Divider(height: 1),
Theme(
data: theme.copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(
'userMetadata'.tr(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...item.userMeta!.entries.map(
(entry) => ListTile(
dense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 24,
),
title: Text(
entry.key,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
).bold(),
subtitle: Text(
jsonEncode(entry.value),
style: theme.textTheme.bodyMedium,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
onTap: () {
Clipboard.setData(
ClipboardData(text: jsonEncode(entry.value)),
);
showSnackBar('valueCopied'.tr());
},
),
),
],
),
],
),
),
],
const SizedBox(height: 16),
],
),
),
);
}
}

View File

@@ -1,312 +0,0 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/core/config.dart';
import 'package:island/core/network.dart';
import 'package:island/drive/drive_service.dart';
import 'package:island/core/utils/format.dart';
import 'package:island/core/widgets/content/audio.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/core/widgets/content/exif_info_overlay.dart';
import 'package:island/core/widgets/content/file_info_sheet.dart';
import 'package:island/core/widgets/content/image_control_overlay.dart';
import 'package:island/core/widgets/content/video.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:photo_view/photo_view.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
class PdfFileContent extends HookConsumerWidget {
final String uri;
const PdfFileContent({required this.uri, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fileFuture = useMemoized(
() => DefaultCacheManager().getSingleFile(uri),
[uri],
);
final pdfController = useMemoized(() => PdfViewerController(), []);
final shadow = [
Shadow(color: Colors.black54, blurRadius: 5.0, offset: Offset(1.0, 1.0)),
];
return FutureBuilder<File>(
future: fileFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(child: Text('Error loading PDF: ${snapshot.error}'));
} else if (snapshot.hasData) {
return Stack(
children: [
SfPdfViewer.file(snapshot.data!, controller: pdfController),
// Controls overlay
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 16,
left: 16,
right: 16,
child: Row(
children: [
IconButton(
icon: Icon(
Icons.remove,
color: Colors.white,
shadows: shadow,
),
onPressed: () {
pdfController.zoomLevel = pdfController.zoomLevel * 0.9;
},
),
IconButton(
icon: Icon(
Icons.add,
color: Colors.white,
shadows: shadow,
),
onPressed: () {
pdfController.zoomLevel = pdfController.zoomLevel * 1.1;
},
),
],
),
),
],
);
}
return const Center(child: Text('No PDF data'));
},
);
}
}
class TextFileContent extends HookConsumerWidget {
final String uri;
const TextFileContent({required this.uri, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final textFuture = useMemoized(
() => ref
.read(apiClientProvider)
.get(uri)
.then((response) => response.data as String),
[uri],
);
return FutureBuilder<String>(
future: textFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(child: Text('Error loading text: ${snapshot.error}'));
} else if (snapshot.hasData) {
return SingleChildScrollView(
padding: EdgeInsets.all(20),
child: SelectableText(
snapshot.data!,
style: const TextStyle(fontFamily: 'monospace', fontSize: 14),
),
);
}
return const Center(child: Text('No content'));
},
);
}
}
class ImageFileContent extends HookConsumerWidget {
final SnCloudFile item;
final String uri;
const ImageFileContent({required this.item, required this.uri, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final photoViewController = useMemoized(() => PhotoViewController(), []);
final rotation = useState(0);
final hasExifData = ExifInfoOverlay.precheck(item);
final showOriginal = useState(false);
final showExif = useState(hasExifData);
return Stack(
children: [
Positioned.fill(
child: Listener(
onPointerSignal: (pointerSignal) {
try {
// Handle mouse wheel zoom - cast to dynamic to access scrollDelta
final delta =
(pointerSignal as dynamic).scrollDelta.dy as double?;
if (delta != null && delta != 0) {
final currentScale = photoViewController.scale ?? 1.0;
// Adjust scale based on scroll direction (invert for natural zoom)
final newScale = delta > 0
? currentScale * 0.9
: currentScale * 1.1;
// Clamp scale to reasonable bounds
final clampedScale = newScale.clamp(0.1, 10.0);
photoViewController.scale = clampedScale;
}
} catch (e) {
// Ignore non-scroll events
}
},
child: PhotoView(
backgroundDecoration: BoxDecoration(
color: Colors.black.withOpacity(0.9),
),
controller: photoViewController,
imageProvider: CloudImageWidget.provider(
file: item,
serverUrl: ref.watch(serverUrlProvider),
original: showOriginal.value,
),
customSize: MediaQuery.of(context).size,
basePosition: Alignment.center,
filterQuality: FilterQuality.high,
),
),
),
if (showExif.value)
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 60,
left: 16,
right: 16,
child: ExifInfoOverlay(item: item),
),
ImageControlOverlay(
photoViewController: photoViewController,
rotation: rotation,
showOriginal: showOriginal.value,
onToggleQuality: () {
showOriginal.value = !showOriginal.value;
},
showExifInfo: showExif.value,
onToggleExif: () {
showExif.value = !showExif.value;
},
hasExifData: hasExifData,
),
],
);
}
}
class VideoFileContent extends HookConsumerWidget {
final SnCloudFile item;
final String uri;
const VideoFileContent({required this.item, required this.uri, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
var ratio = item.fileMeta?['ratio'] is num
? item.fileMeta!['ratio'].toDouble()
: 1.0;
if (ratio == 0) ratio = 1.0;
return Center(
child: AspectRatio(
aspectRatio: ratio,
child: UniversalVideo(uri: uri, autoplay: true),
),
);
}
}
class AudioFileContent extends HookConsumerWidget {
final SnCloudFile item;
final String uri;
const AudioFileContent({required this.item, required this.uri, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8),
),
child: UniversalAudio(uri: uri, filename: item.name),
),
);
}
}
class GenericFileContent extends HookConsumerWidget {
final SnCloudFile item;
const GenericFileContent({required this.item, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.insert_drive_file,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const Gap(16),
Text(
item.name,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const Gap(8),
Text(
formatFileSize(item.size),
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const Gap(24),
Row(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton.icon(
onPressed: () => FileDownloadService(ref).downloadFile(item),
icon: const Icon(Symbols.download),
label: Text('download').tr(),
),
const Gap(16),
OutlinedButton.icon(
onPressed: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder: (context) => FileInfoSheet(item: item),
);
},
icon: const Icon(Symbols.info),
label: Text('info').tr(),
),
],
),
],
),
);
}
}

View File

@@ -1,319 +0,0 @@
import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/core/config.dart';
import 'package:island/core/network.dart';
class UniversalImage extends HookConsumerWidget {
final String uri;
final String? blurHash;
final BoxFit fit;
final double? width;
final double? height;
final bool noCacheOptimization;
final bool isSvg;
final bool useFallbackImage;
const UniversalImage({
super.key,
required this.uri,
this.blurHash,
this.fit = BoxFit.cover,
this.width,
this.height,
this.noCacheOptimization = false,
this.isSvg = false,
this.useFallbackImage = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final loaded = useState(false);
final isCached = useState<bool?>(null);
final isSvgImage = isSvg || uri.toLowerCase().endsWith('.svg');
final serverUrl = ref.watch(serverUrlProvider);
final token = ref.watch(tokenProvider);
final Map<String, String>? httpHeaders =
uri.startsWith(serverUrl) && token != null
? {'Authorization': 'AtField ${token.token}'}
: null;
useEffect(() {
DefaultCacheManager().getFileFromCache(uri).then((fileInfo) {
isCached.value = fileInfo != null;
});
return null;
}, [uri]);
if (isSvgImage) {
return SvgPicture.network(
uri,
fit: fit,
width: width,
height: height,
placeholderBuilder: (BuildContext context) =>
Center(child: CircularProgressIndicator()),
);
}
int? cacheWidth;
int? cacheHeight;
if (width != null && height != null && !noCacheOptimization) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
cacheWidth = width != null ? (width! * devicePixelRatio).round() : null;
cacheHeight = height != null
? (height! * devicePixelRatio).round()
: null;
}
return SizedBox(
width: width,
height: height,
child: Stack(
fit: StackFit.expand,
children: [
if (blurHash != null) BlurHash(hash: blurHash!),
if (isCached.value == null)
Center(child: CircularProgressIndicator())
else if (isCached.value!)
CachedNetworkImage(
imageUrl: uri,
httpHeaders: httpHeaders,
fit: fit,
width: width,
height: height,
memCacheHeight: cacheHeight,
memCacheWidth: cacheWidth,
imageBuilder: (context, imageProvider) => Image(
image: imageProvider,
fit: fit,
width: width,
height: height,
),
errorWidget: (context, url, error) => CachedImageErrorWidget(
useFallbackImage: useFallbackImage,
uri: uri,
blurHash: blurHash,
error: error,
debug: true,
),
)
else
CachedNetworkImage(
imageUrl: uri,
httpHeaders: httpHeaders,
fit: fit,
width: width,
height: height,
memCacheHeight: cacheHeight,
memCacheWidth: cacheWidth,
progressIndicatorBuilder: (context, url, progress) {
return Center(
child: AnimatedCircularProgressIndicator(
value: progress.progress,
color: Colors.white.withOpacity(0.5),
),
);
},
imageBuilder: (context, imageProvider) {
Future(() {
if (context.mounted) loaded.value = true;
});
return AnimatedOpacity(
opacity: loaded.value ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Image(
image: imageProvider,
fit: fit,
width: width,
height: height,
),
);
},
errorWidget: (context, url, error) => CachedImageErrorWidget(
useFallbackImage: useFallbackImage,
uri: uri,
blurHash: blurHash,
error: error,
debug: true,
),
),
],
),
);
}
}
class CachedImageErrorWidget extends StatelessWidget {
final bool useFallbackImage;
final String uri;
final String? blurHash;
final dynamic error;
final bool debug;
const CachedImageErrorWidget({
super.key,
required this.useFallbackImage,
required this.uri,
this.blurHash,
this.error,
this.debug = false,
});
int? _extractStatusCode(dynamic error) {
if (error == null) return null;
final errorString = error.toString();
// Check for HttpException with status code
final httpExceptionRegex = RegExp(r'Invalid statusCode: (\d+)');
final match = httpExceptionRegex.firstMatch(errorString);
if (match != null) {
return int.tryParse(match.group(1) ?? '');
}
// Check if error has statusCode property (like DioError)
if (error.response?.statusCode != null) {
return error.response.statusCode;
}
return null;
}
@override
Widget build(BuildContext context) {
if (debug && error != null) {
debugPrint('Image load error for $uri: $error');
}
if (!useFallbackImage) {
return SizedBox.shrink();
}
final statusCode = _extractStatusCode(error);
return LayoutBuilder(
builder: (context, constraints) {
final minDimension = constraints.maxWidth < constraints.maxHeight
? constraints.maxWidth
: constraints.maxHeight;
final iconSize = math.max(
minDimension * 0.3,
28,
); // 30% of the smaller dimension
final hasEnoughSpace = minDimension > 40;
return Stack(
fit: StackFit.expand,
children: [
if (blurHash != null)
BlurHash(hash: blurHash!)
else
Image.asset(
'assets/images/media-offline.jpg',
fit: BoxFit.cover,
key: Key('-$uri'),
),
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getErrorIcon(statusCode),
color: Colors.white,
size: iconSize * 0.5,
shadows: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
if (hasEnoughSpace && statusCode != null) ...[
SizedBox(height: iconSize * 0.1),
Container(
padding: EdgeInsets.symmetric(
horizontal: iconSize * 0.15,
vertical: iconSize * 0.05,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(iconSize * 0.1),
),
child: Text(
statusCode.toString(),
style: TextStyle(
color: Colors.white,
fontSize: iconSize * 0.15,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
),
],
);
},
);
}
IconData _getErrorIcon(int? statusCode) {
switch (statusCode) {
case 403:
case 401:
return Icons.lock_rounded;
case 404:
return Icons.broken_image_rounded;
case 500:
case 502:
case 503:
return Icons.error_rounded;
default:
return Icons.broken_image_rounded;
}
}
}
class AnimatedCircularProgressIndicator extends HookWidget {
final double? value;
final Color? color;
final double strokeWidth;
final Duration duration;
const AnimatedCircularProgressIndicator({
super.key,
this.value,
this.color,
this.strokeWidth = 4.0,
this.duration = const Duration(milliseconds: 200),
});
@override
Widget build(BuildContext context) {
final animationController = useAnimationController(duration: duration);
final animation = useAnimation(
Tween<double>(begin: 0.0, end: value ?? 0.0).animate(
CurvedAnimation(parent: animationController, curve: Curves.linear),
),
);
useEffect(() {
animationController.animateTo(value ?? 0.0);
return null;
}, [value]);
return CircularProgressIndicator(
value: animation,
color: color,
strokeWidth: strokeWidth,
backgroundColor: Colors.transparent,
);
}
}

View File

@@ -1,107 +0,0 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:photo_view/photo_view.dart';
import 'package:material_symbols_icons/symbols.dart';
class ImageControlOverlay extends HookWidget {
final PhotoViewController photoViewController;
final ValueNotifier<int> rotation;
final bool showOriginal;
final VoidCallback onToggleQuality;
final List<Widget>? extraButtons;
final bool showExtraOnLeft;
final bool showExifInfo;
final VoidCallback onToggleExif;
final bool hasExifData;
const ImageControlOverlay({
super.key,
required this.photoViewController,
required this.rotation,
required this.showOriginal,
required this.onToggleQuality,
this.extraButtons,
this.showExtraOnLeft = false,
this.showExifInfo = false,
required this.onToggleExif,
this.hasExifData = true,
});
@override
Widget build(BuildContext context) {
final shadow = [
Shadow(
color: Colors.black54,
blurRadius: 5.0,
offset: const Offset(1.0, 1.0),
),
];
final controlButtons = [
IconButton(
icon: Icon(Icons.remove, color: Colors.white, shadows: shadow),
onPressed: () {
photoViewController.scale = (photoViewController.scale ?? 1) - 0.05;
},
),
IconButton(
icon: Icon(Icons.add, color: Colors.white, shadows: shadow),
onPressed: () {
photoViewController.scale = (photoViewController.scale ?? 1) + 0.05;
},
),
const Gap(8),
IconButton(
icon: Icon(Icons.rotate_left, color: Colors.white, shadows: shadow),
onPressed: () {
rotation.value = (rotation.value - 1) % 4;
photoViewController.rotation = rotation.value * -math.pi / 2;
},
),
IconButton(
icon: Icon(Icons.rotate_right, color: Colors.white, shadows: shadow),
onPressed: () {
rotation.value = (rotation.value + 1) % 4;
photoViewController.rotation = rotation.value * -math.pi / 2;
},
),
if (hasExifData) ...[
const Gap(8),
IconButton(
icon: Icon(
showExifInfo ? Icons.visibility : Icons.visibility_off,
color: Colors.white,
shadows: shadow,
),
onPressed: onToggleExif,
),
],
];
return Positioned(
bottom: MediaQuery.of(context).padding.bottom + 16,
left: 16,
right: 16,
child: Row(
children: showExtraOnLeft
? [...?extraButtons, const Spacer(), ...controlButtons]
: [
...controlButtons,
const Spacer(),
IconButton(
onPressed: onToggleQuality,
icon: Icon(
showOriginal ? Symbols.hd : Symbols.sd,
color: Colors.white,
shadows: shadow,
),
),
...?extraButtons,
],
),
);
}
}

View File

@@ -1,727 +0,0 @@
import 'package:collection/collection.dart';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_highlight/themes/a11y-dark.dart';
import 'package:flutter_highlight/themes/a11y-light.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/accounts/account/profile.dart';
import 'package:island/accounts/accounts_models/account.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/posts/posts_models/publisher.dart';
import 'package:island/core/config.dart';
import 'package:island/posts/publisher_profile.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/core/widgets/content/cloud_file_lightbox.dart';
import 'package:island/core/widgets/content/markdown_latex.dart';
import 'package:markdown/markdown.dart' as markdown;
import 'package:markdown_widget/markdown_widget.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:uuid/uuid.dart';
import 'image.dart';
class MarkdownTextContent extends HookConsumerWidget {
static const String stickerRegex = r':([-\w]*\+[-\w]*):';
final String content;
final bool isAutoWarp;
final TextStyle? textStyle;
final TextStyle? linkStyle;
final EdgeInsets? linesMargin;
final bool isSelectable;
final List<SnCloudFile>? attachments;
final List<markdown.InlineSyntax> extraInlineSyntaxList;
final List<markdown.BlockSyntax> extraBlockSyntaxList;
final List<dynamic> extraGenerators;
final bool noMentionChip;
const MarkdownTextContent({
super.key,
required this.content,
this.isAutoWarp = false,
this.textStyle,
this.linkStyle,
this.isSelectable = false,
this.linesMargin,
this.attachments,
this.extraInlineSyntaxList = const [],
this.extraBlockSyntaxList = const [],
this.extraGenerators = const [],
this.noMentionChip = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final doesEnlargeSticker = useMemoized(() {
// Check if content only contains one sticker by matching the sticker pattern
final stickerPattern = RegExp(stickerRegex);
final matches = stickerPattern.allMatches(content);
// Content should only contain one sticker and nothing else (except whitespace)
final contentWithoutStickers = content
.replaceAll(stickerPattern, '')
.trim();
return matches.length == 1 && contentWithoutStickers.isEmpty;
}, [content]);
final isDark = Theme.of(context).brightness == Brightness.dark;
final config = isDark
? MarkdownConfig.darkConfig
: MarkdownConfig.defaultConfig;
final onMentionTap = useCallback((String type, String id) {
final fullPath = '/$type/$id';
context.push(fullPath);
}, [context]);
final mentionGenerator = MentionChipGenerator(
backgroundColor: Theme.of(context).colorScheme.secondary,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
onTap: onMentionTap,
);
final highlightGenerator = HighlightGenerator(
highlightColor: Theme.of(context).colorScheme.primaryContainer,
);
final spoilerRevealed = useState(false);
final spoilerGenerator = SpoilerGenerator(
backgroundColor: Theme.of(context).colorScheme.tertiary,
foregroundColor: Theme.of(context).colorScheme.onTertiary,
outlineColor: Theme.of(context).colorScheme.outline,
revealed: spoilerRevealed.value,
onToggle: () => spoilerRevealed.value = !spoilerRevealed.value,
);
final baseUrl = ref.watch(serverUrlProvider);
final stickerGenerator = StickerGenerator(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
isEnlarged: doesEnlargeSticker,
baseUrl: baseUrl,
);
return MarkdownBlock(
data: content,
selectable: isSelectable,
config: config.copy(
configs: [
isDark
? PreConfig.darkConfig.copy(textStyle: textStyle)
: PreConfig().copy(textStyle: textStyle),
PConfig(
textStyle: (textStyle ?? Theme.of(context).textTheme.bodyMedium!),
),
HrConfig(height: 1, color: Theme.of(context).dividerColor),
PreConfig(
theme: isDark ? a11yDarkTheme : a11yLightTheme,
textStyle: GoogleFonts.robotoMono(fontSize: 14),
styleNotMatched: GoogleFonts.robotoMono(fontSize: 14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.all(Radius.circular(8.0)),
),
),
TableConfig(
wrapper: (child) => SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: child,
),
),
LinkConfig(
style:
linkStyle ??
TextStyle(color: Theme.of(context).colorScheme.primary),
onTap: (href) async {
final url = Uri.tryParse(href);
if (url != null) {
if (url.scheme == 'solian') {
final fullPath = ['/', url.host, url.path].join('');
context.push(fullPath);
return;
}
await openExternalLink(url, ref);
} else {
showSnackBar(
'brokenLink'.tr(args: [href]),
action: SnackBarAction(
label: 'copyToClipboard'.tr(),
onPressed: () {
Clipboard.setData(ClipboardData(text: href));
},
),
);
}
},
),
ImgConfig(
builder: (url, attributes) {
final uri = Uri.parse(url);
if (uri.scheme == 'solian') {
switch (uri.host) {
case 'files':
final file = attachments?.firstWhereOrNull(
(file) => file.id == uri.pathSegments[0],
);
if (file == null) {
return const SizedBox.shrink();
}
final heroTag = 'cloud-file-markdown#${const Uuid().v4()}';
return InkWell(
onTap: () {
context.pushTransparentRoute(
CloudFileLightbox(item: file, heroTag: heroTag),
rootNavigator: true,
);
},
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
child: Container(
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
),
child: CloudFileWidget(
item: file,
heroTag: heroTag,
fit: BoxFit.cover,
).clipRRect(all: 8),
),
),
);
}
}
final content = ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 360),
child: UniversalImage(
uri: uri.toString(),
fit: BoxFit.contain,
),
),
);
return content;
},
),
],
),
generator: MarkdownTextContent.buildGenerator(
isDark: isDark,
linesMargin: linesMargin,
generators: [
if (!noMentionChip) mentionGenerator,
highlightGenerator,
spoilerGenerator,
stickerGenerator,
...extraGenerators,
],
extraInlineSyntaxList: extraInlineSyntaxList,
extraBlockSyntaxList: extraBlockSyntaxList,
),
);
}
static MarkdownGenerator buildGenerator({
bool isDark = false,
EdgeInsets? linesMargin,
List<dynamic> generators = const [],
List<markdown.InlineSyntax> extraInlineSyntaxList = const [],
List<markdown.BlockSyntax> extraBlockSyntaxList = const [],
}) {
return MarkdownGenerator(
generators: [latexGenerator, ...generators],
inlineSyntaxList: [
_MentionInlineSyntax(),
_HighlightInlineSyntax(),
_SpoilerInlineSyntax(),
_StickerInlineSyntax(),
LatexSyntax(isDark),
...extraInlineSyntaxList,
],
blockSyntaxList: extraBlockSyntaxList,
linesMargin: linesMargin ?? EdgeInsets.symmetric(vertical: 4),
);
}
}
class _MentionInlineSyntax extends markdown.InlineSyntax {
_MentionInlineSyntax() : super(r'(^|[^A-Za-z0-9._%+\-])(@[-A-Za-z0-9_./]+)');
@override
bool onMatch(markdown.InlineParser parser, Match match) {
final prefix = match[1] ?? '';
final alias = match[2]!;
if (prefix.isNotEmpty) {
parser.addNode(markdown.Text(prefix));
}
final parts = alias.substring(1).split('/');
final typeShortcut = parts.length == 1 ? 'u' : parts.first;
final type = switch (typeShortcut) {
'u' => 'accounts',
'r' => 'realms',
'p' => 'publishers',
_ => '',
};
final element = markdown.Element('mention-chip', [markdown.Text(alias)])
..attributes['alias'] = alias
..attributes['type'] = type
..attributes['id'] = parts.last;
parser.addNode(element);
return true;
}
}
class _StickerInlineSyntax extends markdown.InlineSyntax {
_StickerInlineSyntax() : super(MarkdownTextContent.stickerRegex);
@override
bool onMatch(markdown.InlineParser parser, Match match) {
final placeholder = match[1]!;
final element = markdown.Element('sticker', [markdown.Text(placeholder)]);
parser.addNode(element);
return true;
}
}
class _HighlightInlineSyntax extends markdown.InlineSyntax {
_HighlightInlineSyntax() : super(r'==([^=]+)==');
@override
bool onMatch(markdown.InlineParser parser, Match match) {
final text = match[1]!;
final element = markdown.Element('highlight', [markdown.Text(text)]);
parser.addNode(element);
return true;
}
}
class _SpoilerInlineSyntax extends markdown.InlineSyntax {
_SpoilerInlineSyntax() : super(r'=!([^!]+)!=');
@override
bool onMatch(markdown.InlineParser parser, Match match) {
final text = match[1]!;
final element = markdown.Element('spoiler', [markdown.Text(text)]);
parser.addNode(element);
return true;
}
}
class MentionSpanNodeGenerator {
final Color backgroundColor;
final Color foregroundColor;
final void Function(String type, String id) onTap;
MentionSpanNodeGenerator({
required this.backgroundColor,
required this.foregroundColor,
required this.onTap,
});
SpanNode? call(
String tag,
Map<String, String> attributes,
List<SpanNode> children,
) {
if (tag == 'mention-chip') {
return MentionChipSpanNode(
attributes: attributes,
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
onTap: onTap,
);
}
return null;
}
}
class MentionChipGenerator extends SpanNodeGeneratorWithTag {
MentionChipGenerator({
required Color backgroundColor,
required Color foregroundColor,
required void Function(String type, String id) onTap,
}) : super(
tag: 'mention-chip',
generator:
(
markdown.Element element,
MarkdownConfig config,
WidgetVisitor visitor,
) {
return MentionChipSpanNode(
attributes: element.attributes,
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
onTap: onTap,
);
},
);
}
class _MentionChipContent extends HookConsumerWidget {
final String mentionType;
final String id;
final String alias;
final Color backgroundColor;
final Color foregroundColor;
final VoidCallback onTap;
const _MentionChipContent({
required this.mentionType,
required this.id,
required this.alias,
required this.backgroundColor,
required this.foregroundColor,
required this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isHovered = useState(false);
if (mentionType == 'accounts' || mentionType == 'publishers') {
final data = mentionType == 'accounts'
? ref.watch(accountProvider(id))
: ref.watch(publisherProvider(id));
return data.when(
data: (profile) {
final picture = mentionType == 'accounts'
? (profile as SnAccount).profile.picture
: (profile as SnPublisher).picture;
final icon = mentionType == 'accounts'
? Symbols.person_rounded
: Symbols.design_services_rounded;
return _buildChip(
ProfilePictureWidget(file: picture, fallbackIcon: icon, radius: 9),
id,
isHovered,
);
},
error: (_, _) => Text(
alias,
style: TextStyle(
color: backgroundColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
loading: () => Text(
alias,
style: TextStyle(
color: backgroundColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
);
}
return _buildStaticChip(mentionType, id);
}
Widget _buildChip(
Widget avatar,
String displayName,
ValueNotifier<bool> isHovered,
) {
return InkWell(
onTap: onTap,
onHover: (value) => isHovered.value = value,
borderRadius: BorderRadius.circular(32),
child: Container(
padding: const EdgeInsets.only(
left: 5,
right: 7,
top: 2.5,
bottom: 2.5,
),
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: backgroundColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(32),
),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 6,
children: [
Container(
decoration: BoxDecoration(
color: backgroundColor.withOpacity(0.5),
borderRadius: const BorderRadius.all(Radius.circular(32)),
),
child: avatar,
),
Text(
displayName,
style: TextStyle(
color: backgroundColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
Widget _buildStaticChip(String type, String id) {
final icon = switch (type) {
'chat' => Symbols.forum_rounded,
'realms' => Symbols.group_rounded,
_ => Symbols.person_rounded,
};
return _buildChip(
Icon(icon, size: 14, color: foregroundColor, fill: 1).padding(all: 2),
id,
useState(false),
);
}
}
class MentionChipSpanNode extends SpanNode {
final Map<String, String> attributes;
final Color backgroundColor;
final Color foregroundColor;
final void Function(String type, String id) onTap;
MentionChipSpanNode({
required this.attributes,
required this.backgroundColor,
required this.foregroundColor,
required this.onTap,
});
@override
InlineSpan build() {
final alias = attributes['alias'] ?? '';
final type = attributes['type'] ?? '';
final id = attributes['id'] ?? '';
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _MentionChipContent(
mentionType: type,
id: id,
alias: alias,
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
onTap: () => onTap(type, id),
),
);
}
}
class HighlightGenerator extends SpanNodeGeneratorWithTag {
HighlightGenerator({required Color highlightColor})
: super(
tag: 'highlight',
generator:
(
markdown.Element element,
MarkdownConfig config,
WidgetVisitor visitor,
) {
return HighlightSpanNode(
text: element.textContent,
highlightColor: highlightColor,
);
},
);
}
class HighlightSpanNode extends SpanNode {
final String text;
final Color highlightColor;
HighlightSpanNode({required this.text, required this.highlightColor});
@override
InlineSpan build() {
return TextSpan(
text: text,
style: TextStyle(backgroundColor: highlightColor),
);
}
}
class SpoilerGenerator extends SpanNodeGeneratorWithTag {
SpoilerGenerator({
required Color backgroundColor,
required Color foregroundColor,
required Color outlineColor,
required bool revealed,
required VoidCallback onToggle,
}) : super(
tag: 'spoiler',
generator:
(
markdown.Element element,
MarkdownConfig config,
WidgetVisitor visitor,
) {
return SpoilerSpanNode(
text: element.textContent,
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
outlineColor: outlineColor,
revealed: revealed,
onToggle: onToggle,
);
},
);
}
class SpoilerSpanNode extends SpanNode {
final String text;
final Color backgroundColor;
final Color foregroundColor;
final Color outlineColor;
final bool revealed;
final VoidCallback onToggle;
SpoilerSpanNode({
required this.text,
required this.backgroundColor,
required this.foregroundColor,
required this.outlineColor,
required this.revealed,
required this.onToggle,
});
@override
InlineSpan build() {
return WidgetSpan(
child: InkWell(
onTap: onToggle,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: revealed ? Colors.transparent : backgroundColor,
border: revealed ? Border.all(color: outlineColor, width: 1) : null,
borderRadius: BorderRadius.circular(4),
),
child: revealed
? Row(
spacing: 6,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Symbols.visibility, size: 18).padding(top: 1),
Flexible(child: Text(text)),
],
)
: Row(
spacing: 6,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.visibility_off,
color: foregroundColor,
size: 18,
),
Flexible(
child: Text(
'spoiler',
style: TextStyle(color: foregroundColor),
).tr(),
),
],
),
),
),
);
}
}
class StickerGenerator extends SpanNodeGeneratorWithTag {
StickerGenerator({
required Color backgroundColor,
required Color foregroundColor,
required bool isEnlarged,
required String baseUrl,
}) : super(
tag: 'sticker',
generator:
(
markdown.Element element,
MarkdownConfig config,
WidgetVisitor visitor,
) {
return StickerSpanNode(
placeholder: element.textContent,
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
isEnlarged: isEnlarged,
baseUrl: baseUrl,
);
},
);
}
class StickerSpanNode extends SpanNode {
final String placeholder;
final Color backgroundColor;
final Color foregroundColor;
final bool isEnlarged;
final String baseUrl;
StickerSpanNode({
required this.placeholder,
required this.backgroundColor,
required this.foregroundColor,
required this.isEnlarged,
required this.baseUrl,
});
@override
InlineSpan build() {
final size = isEnlarged ? 96.0 : 24.0;
final stickerUri = '$baseUrl/sphere/stickers/lookup/$placeholder/open';
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Container(
decoration: BoxDecoration(
color: backgroundColor.withOpacity(0.1),
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: UniversalImage(
uri: stickerUri,
width: size,
height: size,
fit: BoxFit.contain,
noCacheOptimization: true,
),
),
),
);
}
}

View File

@@ -1,80 +0,0 @@
import 'package:flutter/material.dart';
import 'package:markdown_widget/markdown_widget.dart';
import 'package:flutter_math_fork/flutter_math.dart';
import 'package:markdown/markdown.dart' as m;
SpanNodeGeneratorWithTag latexGenerator = SpanNodeGeneratorWithTag(
tag: _latexTag,
generator:
(e, config, visitor) => LatexNode(e.attributes, e.textContent, config),
);
const _latexTag = 'latex';
class LatexSyntax extends m.InlineSyntax {
final bool isDark;
LatexSyntax(this.isDark) : super(r'(\$\$[\s\S]+\$\$)|(\$.+?\$)');
@override
bool onMatch(m.InlineParser parser, Match match) {
final input = match.input;
final matchValue = input.substring(match.start, match.end);
String content = '';
bool isInline = true;
const blockSyntax = '\$\$';
const inlineSyntax = '\$';
if (matchValue.startsWith(blockSyntax) &&
matchValue.endsWith(blockSyntax) &&
(matchValue != blockSyntax)) {
content = matchValue.substring(2, matchValue.length - 2);
isInline = false;
} else if (matchValue.startsWith(inlineSyntax) &&
matchValue.endsWith(inlineSyntax) &&
matchValue != inlineSyntax) {
content = matchValue.substring(1, matchValue.length - 1);
}
m.Element el = m.Element.text(_latexTag, matchValue);
el.attributes['content'] = content;
el.attributes['isInline'] = '$isInline';
el.attributes['isDark'] = isDark.toString();
parser.addNode(el);
return true;
}
}
class LatexNode extends SpanNode {
final Map<String, String> attributes;
final String textContent;
final MarkdownConfig config;
LatexNode(this.attributes, this.textContent, this.config);
@override
InlineSpan build() {
final content = attributes['content'] ?? '';
final isInline = attributes['isInline'] == 'true';
final isDark = attributes['isDark'] == 'true';
final style = parentStyle ?? config.p.textStyle;
if (content.isEmpty) return TextSpan(style: style, text: textContent);
final latex = Math.tex(
content,
mathStyle: MathStyle.text,
textStyle: style.copyWith(color: isDark ? Colors.white : Colors.black),
textScaleFactor: 1,
onErrorFallback: (error) {
return Text(textContent, style: style.copyWith(color: Colors.red));
},
);
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child:
!isInline
? Container(
width: double.infinity,
margin: EdgeInsets.symmetric(vertical: 16),
child: Center(child: latex),
)
: latex,
);
}
}

View File

@@ -1,231 +0,0 @@
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/core/config.dart';
import 'package:island/core/network.dart';
import 'package:island/core/websocket.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
class NetworkStatusSheet extends HookConsumerWidget {
final bool autoClose;
const NetworkStatusSheet({super.key, this.autoClose = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ws = ref.watch(websocketProvider);
final wsState = ref.watch(websocketStateProvider);
final apiState = ref.watch(networkStatusProvider);
final serverUrl = ref.watch(serverUrlProvider);
final wsNotifier = ref.watch(websocketStateProvider.notifier);
final checks = [
wsState == WebSocketState.connected(),
apiState == NetworkStatus.online,
];
useEffect(() {
if (!autoClose) return;
final checks = [
wsState == WebSocketState.connected(),
apiState == NetworkStatus.online,
];
if (!checks.any((e) => !e)) {
Future.delayed(Duration(seconds: 3), () {
if (context.mounted) Navigator.of(context).pop();
});
}
return null;
}, [wsState, apiState]);
return SheetScaffold(
heightFactor: 0.6,
titleText: !checks.any((e) => !e)
? 'Connection Status'
: 'Connection Issues',
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 4,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: !checks.any((e) => !e)
? Colors.green.withOpacity(0.1)
: Colors.red.withOpacity(0.1),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
margin: const EdgeInsets.only(bottom: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('overview').tr().bold(),
Column(
spacing: 8,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!checks.any((e) => !e))
Text('Everything is operational.'),
if (!checks[0])
Text(
'WebSocket is disconnected. Realtime updates are not available. You can try tap the reconnect button below to try connect again.',
),
if (!checks[1])
...([
Text(
'API is unreachable, you can try again later. If the issue persists, please contact support. Or you can check the service status.',
),
InkWell(
onTap: () {
launchUrlString("https://status.solsynth.dev");
},
child: Text(
'Check Service Status',
).textColor(Colors.blueAccent).bold(),
),
]),
],
),
],
),
),
Row(
spacing: 8,
children: [
Text('WebSocket').bold(),
wsState.when(
connected: () => Text('connectionConnected').tr(),
connecting: () => Text('connectionReconnecting').tr(),
disconnected: () => Text('connectionDisconnected').tr(),
serverDown: () => Text('connectionServerDown').tr(),
duplicateDevice: () => Text(
'Another device has connected with the same account.',
),
error: (message) => Text('Connection error: $message'),
),
if (ws.heartbeatDelay != null)
Text('${ws.heartbeatDelay!.inMilliseconds}ms'),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: wsState.when(
connected: () => Icon(
Symbols.check_circle,
key: ValueKey(WebSocketState.connected),
color: Colors.green,
size: 16,
),
connecting: () => Icon(
Symbols.sync,
key: ValueKey(WebSocketState.connecting),
color: Colors.orange,
size: 16,
),
disconnected: () => Icon(
Symbols.wifi_off,
key: ValueKey(WebSocketState.disconnected),
color: Colors.grey,
size: 16,
),
serverDown: () => Icon(
Symbols.cloud_off,
key: ValueKey(WebSocketState.serverDown),
color: Colors.red,
size: 16,
),
duplicateDevice: () => Icon(
Symbols.devices,
key: ValueKey(WebSocketState.duplicateDevice),
color: Colors.orange,
size: 16,
),
error: (message) => Icon(
Symbols.error,
key: ValueKey(WebSocketState.error),
color: Colors.red,
size: 16,
),
),
),
],
),
Row(
spacing: 8,
children: [
Text('API').bold(),
Text(
apiState == NetworkStatus.online
? 'Online'
: apiState == NetworkStatus.notReady
? 'Not Ready'
: apiState == NetworkStatus.maintenance
? 'Under Maintenance'
: 'Offline',
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: apiState == NetworkStatus.online
? Icon(
Symbols.check_circle,
key: ValueKey(NetworkStatus.online),
color: Colors.green,
size: 16,
)
: apiState == NetworkStatus.notReady
? Icon(
Symbols.warning,
key: ValueKey(NetworkStatus.notReady),
color: Colors.orange,
size: 16,
)
: apiState == NetworkStatus.maintenance
? Icon(
Symbols.construction,
key: ValueKey(NetworkStatus.maintenance),
color: Colors.orange,
size: 16,
)
: Icon(
Symbols.cloud_off,
key: ValueKey(NetworkStatus.offline),
color: Colors.red,
size: 16,
),
),
],
),
Row(
spacing: 8,
children: [
Text('API Server').bold(),
Expanded(child: Text(serverUrl)),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
spacing: 8,
children: [
FilledButton.icon(
icon: const Icon(Symbols.wifi),
label: const Text('Reconnect'),
onPressed: () {
wsNotifier.manualReconnect();
},
),
],
),
],
),
),
);
}
}

View File

@@ -1,13 +0,0 @@
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'profile_decoration.freezed.dart';
@freezed
sealed class ProfileDecoration with _$ProfileDecoration {
const factory ProfileDecoration({
required String text,
required Color color,
Color? textColor,
}) = _ProfileDecoration;
}

View File

@@ -1,271 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// 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 'profile_decoration.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ProfileDecoration {
String get text; Color get color; Color? get textColor;
/// Create a copy of ProfileDecoration
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ProfileDecorationCopyWith<ProfileDecoration> get copyWith => _$ProfileDecorationCopyWithImpl<ProfileDecoration>(this as ProfileDecoration, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ProfileDecoration&&(identical(other.text, text) || other.text == text)&&(identical(other.color, color) || other.color == color)&&(identical(other.textColor, textColor) || other.textColor == textColor));
}
@override
int get hashCode => Object.hash(runtimeType,text,color,textColor);
@override
String toString() {
return 'ProfileDecoration(text: $text, color: $color, textColor: $textColor)';
}
}
/// @nodoc
abstract mixin class $ProfileDecorationCopyWith<$Res> {
factory $ProfileDecorationCopyWith(ProfileDecoration value, $Res Function(ProfileDecoration) _then) = _$ProfileDecorationCopyWithImpl;
@useResult
$Res call({
String text, Color color, Color? textColor
});
}
/// @nodoc
class _$ProfileDecorationCopyWithImpl<$Res>
implements $ProfileDecorationCopyWith<$Res> {
_$ProfileDecorationCopyWithImpl(this._self, this._then);
final ProfileDecoration _self;
final $Res Function(ProfileDecoration) _then;
/// Create a copy of ProfileDecoration
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? text = null,Object? color = null,Object? textColor = freezed,}) {
return _then(_self.copyWith(
text: null == text ? _self.text : text // ignore: cast_nullable_to_non_nullable
as String,color: null == color ? _self.color : color // ignore: cast_nullable_to_non_nullable
as Color,textColor: freezed == textColor ? _self.textColor : textColor // ignore: cast_nullable_to_non_nullable
as Color?,
));
}
}
/// Adds pattern-matching-related methods to [ProfileDecoration].
extension ProfileDecorationPatterns on ProfileDecoration {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ProfileDecoration value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ProfileDecoration() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ProfileDecoration value) $default,){
final _that = this;
switch (_that) {
case _ProfileDecoration():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ProfileDecoration value)? $default,){
final _that = this;
switch (_that) {
case _ProfileDecoration() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String text, Color color, Color? textColor)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ProfileDecoration() when $default != null:
return $default(_that.text,_that.color,_that.textColor);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String text, Color color, Color? textColor) $default,) {final _that = this;
switch (_that) {
case _ProfileDecoration():
return $default(_that.text,_that.color,_that.textColor);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String text, Color color, Color? textColor)? $default,) {final _that = this;
switch (_that) {
case _ProfileDecoration() when $default != null:
return $default(_that.text,_that.color,_that.textColor);case _:
return null;
}
}
}
/// @nodoc
class _ProfileDecoration implements ProfileDecoration {
const _ProfileDecoration({required this.text, required this.color, this.textColor});
@override final String text;
@override final Color color;
@override final Color? textColor;
/// Create a copy of ProfileDecoration
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ProfileDecorationCopyWith<_ProfileDecoration> get copyWith => __$ProfileDecorationCopyWithImpl<_ProfileDecoration>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ProfileDecoration&&(identical(other.text, text) || other.text == text)&&(identical(other.color, color) || other.color == color)&&(identical(other.textColor, textColor) || other.textColor == textColor));
}
@override
int get hashCode => Object.hash(runtimeType,text,color,textColor);
@override
String toString() {
return 'ProfileDecoration(text: $text, color: $color, textColor: $textColor)';
}
}
/// @nodoc
abstract mixin class _$ProfileDecorationCopyWith<$Res> implements $ProfileDecorationCopyWith<$Res> {
factory _$ProfileDecorationCopyWith(_ProfileDecoration value, $Res Function(_ProfileDecoration) _then) = __$ProfileDecorationCopyWithImpl;
@override @useResult
$Res call({
String text, Color color, Color? textColor
});
}
/// @nodoc
class __$ProfileDecorationCopyWithImpl<$Res>
implements _$ProfileDecorationCopyWith<$Res> {
__$ProfileDecorationCopyWithImpl(this._self, this._then);
final _ProfileDecoration _self;
final $Res Function(_ProfileDecoration) _then;
/// Create a copy of ProfileDecoration
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? text = null,Object? color = null,Object? textColor = freezed,}) {
return _then(_ProfileDecoration(
text: null == text ? _self.text : text // ignore: cast_nullable_to_non_nullable
as String,color: null == color ? _self.color : color // ignore: cast_nullable_to_non_nullable
as Color,textColor: freezed == textColor ? _self.textColor : textColor // ignore: cast_nullable_to_non_nullable
as Color?,
));
}
}
// dart format on

View File

@@ -1,71 +0,0 @@
// Copyright (c) Solsynth
// Sensitive content categories for content warnings, in fixed order.
enum SensitiveCategory {
language,
sexualContent,
violence,
profanity,
hateSpeech,
racism,
adultContent,
drugAbuse,
alcoholAbuse,
gambling,
selfHarm,
childAbuse,
other,
}
extension SensitiveCategoryI18n on SensitiveCategory {
/// i18n key to look up localized label
String get i18nKey => switch (this) {
SensitiveCategory.language => 'sensitiveCategories.language',
SensitiveCategory.sexualContent => 'sensitiveCategories.sexualContent',
SensitiveCategory.violence => 'sensitiveCategories.violence',
SensitiveCategory.profanity => 'sensitiveCategories.profanity',
SensitiveCategory.hateSpeech => 'sensitiveCategories.hateSpeech',
SensitiveCategory.racism => 'sensitiveCategories.racism',
SensitiveCategory.adultContent => 'sensitiveCategories.adultContent',
SensitiveCategory.drugAbuse => 'sensitiveCategories.drugAbuse',
SensitiveCategory.alcoholAbuse => 'sensitiveCategories.alcoholAbuse',
SensitiveCategory.gambling => 'sensitiveCategories.gambling',
SensitiveCategory.selfHarm => 'sensitiveCategories.selfHarm',
SensitiveCategory.childAbuse => 'sensitiveCategories.childAbuse',
SensitiveCategory.other => 'sensitiveCategories.other',
};
/// Optional symbol you can use alongside the label in UI
String get symbol => switch (this) {
SensitiveCategory.language => '🌐',
SensitiveCategory.sexualContent => '🔞',
SensitiveCategory.violence => '⚠️',
SensitiveCategory.profanity => '🗯️',
SensitiveCategory.hateSpeech => '🚫',
SensitiveCategory.racism => '',
SensitiveCategory.adultContent => '🍑',
SensitiveCategory.drugAbuse => '💊',
SensitiveCategory.alcoholAbuse => '🍺',
SensitiveCategory.gambling => '🎲',
SensitiveCategory.selfHarm => '🆘',
SensitiveCategory.childAbuse => '🛑',
SensitiveCategory.other => '',
};
}
/// Ordered list for UI consumption, matching enum declaration order.
const List<SensitiveCategory> kSensitiveCategoriesOrdered = [
SensitiveCategory.language,
SensitiveCategory.sexualContent,
SensitiveCategory.violence,
SensitiveCategory.profanity,
SensitiveCategory.hateSpeech,
SensitiveCategory.racism,
SensitiveCategory.adultContent,
SensitiveCategory.drugAbuse,
SensitiveCategory.alcoholAbuse,
SensitiveCategory.gambling,
SensitiveCategory.selfHarm,
SensitiveCategory.childAbuse,
SensitiveCategory.other,
];

View File

@@ -1,74 +0,0 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
class SheetScaffold extends StatelessWidget {
final Widget? title;
final String? titleText;
final List<Widget> actions;
final Widget child;
final double heightFactor;
final double? height;
final VoidCallback? onClose;
const SheetScaffold({
super.key,
this.title,
this.titleText,
required this.child,
this.actions = const [],
this.heightFactor = 0.8,
this.height,
this.onClose,
});
@override
Widget build(BuildContext context) {
assert(title != null || titleText != null);
var titleWidget =
title ??
Text(
titleText!,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
return Container(
padding: MediaQuery.of(context).viewInsets,
constraints: BoxConstraints(
maxHeight: height ?? MediaQuery.of(context).size.height * heightFactor,
),
child: Column(
children: [
Padding(
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
child: Row(
children: [
Expanded(child: titleWidget),
const Spacer(),
...actions,
IconButton(
icon: Icon(
Symbols.close,
color: Theme.of(context).colorScheme.onSurface,
),
onPressed:
() =>
onClose != null
? onClose?.call()
: Navigator.pop(context),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
),
],
),
),
const Divider(height: 1),
Expanded(child: child),
],
),
);
}
}

View File

@@ -1 +0,0 @@
export 'video.native.dart' if (dart.library.html) 'video.web.dart';

View File

@@ -1,73 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/core/config.dart';
import 'package:island/core/network.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
class UniversalVideo extends ConsumerStatefulWidget {
final String uri;
final double aspectRatio;
final bool autoplay;
const UniversalVideo({
super.key,
required this.uri,
this.aspectRatio = 16 / 9,
this.autoplay = false,
});
@override
ConsumerState<UniversalVideo> createState() => _UniversalVideoState();
}
class _UniversalVideoState extends ConsumerState<UniversalVideo> {
Player? _player;
VideoController? _videoController;
void _openVideo() async {
MediaKit.ensureInitialized();
_player = Player();
_videoController = VideoController(_player!);
final serverUrl = ref.read(serverUrlProvider);
final token = ref.read(tokenProvider);
final Map<String, String>? httpHeaders =
widget.uri.startsWith(serverUrl) && token != null
? {'Authorization': 'AtField ${token.token}'}
: null;
_player!.open(Media(widget.uri, httpHeaders: httpHeaders), play: widget.autoplay);
}
@override
void initState() {
super.initState();
_openVideo();
}
@override
void dispose() {
super.dispose();
_player?.dispose();
}
@override
Widget build(BuildContext context) {
if (_videoController == null) {
return Center(child: CircularProgressIndicator());
}
return Video(
controller: _videoController!,
aspectRatio: widget.aspectRatio != 1 ? widget.aspectRatio : null,
fit: BoxFit.contain,
controls:
!kIsWeb && (Platform.isAndroid || Platform.isIOS)
? MaterialVideoControls
: MaterialDesktopVideoControls,
);
}
}

View File

@@ -1,28 +0,0 @@
import 'package:web/web.dart' as web;
import 'package:flutter/material.dart';
class UniversalVideo extends StatelessWidget {
final String uri;
final double? aspectRatio;
final bool autoplay;
const UniversalVideo({
super.key,
required this.uri,
this.aspectRatio,
this.autoplay = false,
});
@override
Widget build(BuildContext context) {
return HtmlElementView.fromTagName(
tagName: 'video',
onElementCreated: (element) {
element as web.HTMLVideoElement;
element.src = uri;
element.style.width = '100%';
element.style.height = '100%';
element.controls = true;
},
);
}
}

View File

@@ -1,243 +0,0 @@
# Payment Overlay Widget
A reusable payment verification overlay that supports both 6-digit PIN input and biometric authentication for secure payment processing.
## Features
- **6-digit PIN Input**: Secure numeric PIN entry with automatic focus management
- **Biometric Authentication**: Support for fingerprint and face recognition
- **Order Summary**: Display payment details including amount, description, and remarks
- **Integrated API Calls**: Automatically handles payment processing via `/orders/{orderId}/pay`
- **Error Handling**: Comprehensive error handling with user-friendly messages
- **Loading States**: Visual feedback during payment processing
- **Responsive Design**: Adapts to different screen sizes and orientations
- **Customizable**: Flexible callbacks and styling options
- **Accessibility**: Screen reader support and proper focus management
- **Localization**: Full i18n support with easy_localization
## Usage
```dart
import 'package:flutter/material.dart';
import 'package:solian/models/wallet.dart';
import 'package:solian/widgets/payment/payment_overlay.dart';
// Create an order
final order = SnWalletOrder(
id: 'order_123',
amount: 2500, // $25.00 in cents
currency: 'USD',
description: 'Premium Subscription',
remarks: 'Monthly billing',
status: 'pending',
);
// Show payment overlay
PaymentOverlay.show(
context: context,
order: order,
onPaymentSuccess: (completedOrder) {
// Handle successful payment
print('Payment completed: ${completedOrder.id}');
// Navigate to success page or update UI
},
onPaymentError: (error) {
// Handle payment error
print('Payment failed: $error');
// Show error message to user
},
onCancel: () {
Navigator.of(context).pop();
print('Payment cancelled');
},
enableBiometric: true,
);
```
### Advanced Usage with Loading States
```dart
bool isLoading = false;
PaymentOverlay.show(
context: context,
order: order,
enableBiometric: true,
isLoading: isLoading,
onPinSubmit: (String pin) async {
setState(() => isLoading = true);
try {
await processPaymentWithPin(pin);
Navigator.of(context).pop();
} catch (e) {
showErrorDialog(e.toString());
} finally {
setState(() => isLoading = false);
}
},
onBiometricAuth: () async {
setState(() => isLoading = true);
try {
final authenticated = await authenticateWithBiometrics();
if (authenticated) {
await processPaymentWithBiometrics();
Navigator.of(context).pop();
}
} catch (e) {
showErrorDialog(e.toString());
} finally {
setState(() => isLoading = false);
}
},
);
```
## Parameters
### PaymentOverlay.show()
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `context` | `BuildContext` | ✅ | The build context for showing the overlay |
| `order` | `SnWalletOrder` | ✅ | The order to be paid |
| `onPaymentSuccess` | `Function(SnWalletOrder)?` | ❌ | Callback when payment succeeds with completed order |
| `onPaymentError` | `Function(String)?` | ❌ | Callback when payment fails with error message |
| `onCancel` | `VoidCallback?` | ❌ | Callback when payment is cancelled |
| `enableBiometric` | `bool` | ❌ | Whether to show biometric option (default: true) |
## API Integration
The PaymentOverlay automatically handles payment processing by calling the `/orders/{orderId}/pay` endpoint with the following request body:
### PIN Payment
```json
{
"pin": "123456"
}
```
### Biometric Payment
```json
{
"biometric": true
}
```
### Response
The API should return the completed `SnWalletOrder` object:
```json
{
"id": "order_123",
"amount": 2500,
"currency": "USD",
"description": "Premium Subscription",
"status": "completed",
"processorReference": "txn_abc123",
// ... other order fields
}
```
### Error Handling
The widget handles common HTTP status codes:
- `401`: Invalid PIN or biometric authentication failed
- `400`: Bad request with custom error message
- Other errors: Generic payment failed message
### Implementation Example
```dart
import 'package:local_auth/local_auth.dart';
class BiometricService {
final LocalAuthentication _auth = LocalAuthentication();
Future<bool> isAvailable() async {
final isAvailable = await _auth.canCheckBiometrics;
final isDeviceSupported = await _auth.isDeviceSupported();
return isAvailable && isDeviceSupported;
}
Future<bool> authenticate() async {
try {
final bool didAuthenticate = await _auth.authenticate(
localizedReason: 'Please authenticate to complete payment',
options: const AuthenticationOptions(
biometricOnly: true,
stickyAuth: true,
),
);
return didAuthenticate;
} catch (e) {
print('Biometric authentication error: $e');
return false;
}
}
}
```
## Localization
Add these keys to your localization files:
```json
{
"paymentVerification": "Payment Verification",
"paymentSummary": "Payment Summary",
"amount": "Amount",
"description": "Description",
"pinCode": "PIN Code",
"biometric": "Biometric",
"enterPinToConfirmPayment": "Enter your 6-digit PIN to confirm payment",
"clearPin": "Clear PIN",
"useBiometricToConfirm": "Use biometric authentication to confirm payment",
"touchSensorToAuthenticate": "Touch the sensor to authenticate",
"authenticating": "Authenticating...",
"authenticateNow": "Authenticate Now",
"confirm": "Confirm",
"cancel": "Cancel",
"paymentFailed": "Payment failed. Please try again.",
"invalidPin": "Invalid PIN. Please try again.",
"biometricAuthFailed": "Biometric authentication failed. Please try again.",
"paymentSuccess": "Payment completed successfully!",
"paymentError": "Payment failed: {error}"
}
```
## Styling
The widget automatically adapts to your app's theme. It uses:
- `Theme.of(context).colorScheme.primary` for primary elements
- `Theme.of(context).colorScheme.surface` for backgrounds
- `Theme.of(context).textTheme` for typography
## Security Considerations
1. **PIN Handling**: The PIN is passed as a string to your callback. Ensure you handle it securely and don't log it.
2. **Biometric Authentication**: Always verify biometric authentication on your backend.
3. **Network Security**: Use HTTPS for all payment-related API calls.
4. **Data Validation**: Validate all payment data on your backend before processing.
## Example Integration
See `payment_overlay_example.dart` for a complete working example that demonstrates:
- How to show the overlay
- Handling PIN and biometric authentication
- Processing payments
- Error handling
- Loading states
## Dependencies
- `flutter/material.dart` - Material Design components
- `flutter/services.dart` - Input formatters and system services
- `flutter_riverpod/flutter_riverpod.dart` - State management and dependency injection
- `gap/gap.dart` - Spacing widgets
- `material_symbols_icons/symbols.dart` - Material Symbols icons
- `easy_localization/easy_localization.dart` - Internationalization
- `dio/dio.dart` - HTTP client for API calls
- `solian/models/wallet.dart` - Wallet order model
- `solian/widgets/common/sheet_scaffold.dart` - Sheet scaffold widget
- `solian/pods/network.dart` - API client provider

View File

@@ -1,477 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:island/wallet/wallet_models/wallet.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:island/core/network.dart';
import 'package:dio/dio.dart';
import 'package:local_auth/local_auth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter/services.dart';
import 'package:styled_widget/styled_widget.dart';
class PaymentOverlay extends HookConsumerWidget {
final SnWalletOrder order;
final Function(SnWalletOrder completedOrder)? onPaymentSuccess;
final Function(String error)? onPaymentError;
final VoidCallback? onCancel;
final bool enableBiometric;
const PaymentOverlay({
super.key,
required this.order,
this.onPaymentSuccess,
this.onPaymentError,
this.onCancel,
this.enableBiometric = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SheetScaffold(
titleText: 'Solarpay',
heightFactor: 0.7,
child: _PaymentContent(
order: order,
onPaymentSuccess: onPaymentSuccess,
onPaymentError: onPaymentError,
onCancel: onCancel,
enableBiometric: enableBiometric,
),
),
),
);
}
static Future<SnWalletOrder?> show({
required BuildContext context,
required SnWalletOrder order,
bool enableBiometric = true,
}) {
return showModalBottomSheet<SnWalletOrder>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
useSafeArea: true,
builder: (context) => PaymentOverlay(
order: order,
enableBiometric: enableBiometric,
onPaymentSuccess: (completedOrder) {
Navigator.of(context).pop(completedOrder);
},
onPaymentError: (err) {
Navigator.of(context).pop();
showErrorAlert(err);
},
onCancel: () {
Navigator.of(context).pop();
},
),
);
}
}
class _PaymentContent extends ConsumerStatefulWidget {
final SnWalletOrder order;
final Function(SnWalletOrder)? onPaymentSuccess;
final Function(String)? onPaymentError;
final VoidCallback? onCancel;
final bool enableBiometric;
const _PaymentContent({
required this.order,
this.onPaymentSuccess,
this.onPaymentError,
this.onCancel,
this.enableBiometric = true,
});
@override
ConsumerState<_PaymentContent> createState() => _PaymentContentState();
}
class _PaymentContentState extends ConsumerState<_PaymentContent> {
static const String _pinStorageKey = 'app_pin_code';
static final _secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
final LocalAuthentication _localAuth = LocalAuthentication();
String _pin = '';
bool _isPinMode = true;
bool _hasBiometricSupport = false;
bool _hasStoredPin = false;
@override
void initState() {
super.initState();
_initializeBiometric();
}
@override
void dispose() {
super.dispose();
}
Future<void> _initializeBiometric() async {
try {
// Check if biometric is available
final isAvailable = await _localAuth.isDeviceSupported();
final canCheckBiometrics = await _localAuth.canCheckBiometrics;
_hasBiometricSupport = isAvailable && canCheckBiometrics;
// Check if PIN is stored
final storedPin = await _secureStorage.read(key: _pinStorageKey);
_hasStoredPin = storedPin != null && storedPin.isNotEmpty;
// Set initial mode based on stored PIN and biometric support
if (_hasStoredPin && _hasBiometricSupport && widget.enableBiometric) {
_isPinMode = false;
} else {
_isPinMode = true;
}
if (mounted) {
setState(() {});
}
} catch (e) {
// Fallback to PIN mode if biometric setup fails
_isPinMode = true;
if (mounted) {
setState(() {});
}
}
}
void _onPinSubmit(String pin) {
_pin = pin;
if (pin.length == 6) {
_processPaymentWithPin(pin);
}
}
Future<void> _processPaymentWithPin(String pin) async {
showLoadingModal(context);
try {
// Store PIN securely for future biometric authentication
if (_hasBiometricSupport && widget.enableBiometric && !_hasStoredPin) {
await _secureStorage.write(key: _pinStorageKey, value: pin);
_hasStoredPin = true;
}
await _makePaymentRequest(pin);
} catch (err) {
widget.onPaymentError?.call(err.toString());
_pin = '';
} finally {
if (mounted) {
hideLoadingModal(context);
}
}
}
Future<void> _authenticateWithBiometric() async {
showLoadingModal(context);
try {
// Perform biometric authentication
final bool didAuthenticate = await _localAuth.authenticate(
localizedReason: 'biometricPrompt'.tr(),
biometricOnly: true,
);
if (didAuthenticate) {
// Retrieve stored PIN and process payment
final storedPin = await _secureStorage.read(key: _pinStorageKey);
if (storedPin != null && storedPin.isNotEmpty) {
await _makePaymentRequest(storedPin);
} else {
// Fallback to PIN mode if no stored PIN
_fallbackToPinMode('noStoredPin'.tr());
}
} else {
// Biometric authentication failed, fallback to PIN mode
_fallbackToPinMode('biometricAuthFailed'.tr());
}
} catch (err) {
// Handle biometric authentication errors
String errorMessage = 'biometricAuthFailed'.tr();
if (err is PlatformException) {
switch (err.code) {
case 'NotAvailable':
errorMessage = 'biometricNotAvailable'.tr();
break;
case 'NotEnrolled':
errorMessage = 'biometricNotEnrolled'.tr();
break;
case 'LockedOut':
case 'PermanentlyLockedOut':
errorMessage = 'biometricLockedOut'.tr();
break;
default:
errorMessage = 'biometricAuthFailed'.tr();
}
}
_fallbackToPinMode(errorMessage);
} finally {
if (mounted) {
hideLoadingModal(context);
}
}
}
/// Unified method for making payment requests with PIN
Future<void> _makePaymentRequest(String pin) async {
try {
final client = ref.read(apiClientProvider);
final response = await client.post(
'/wallet/orders/${widget.order.id}/pay',
data: {'pin_code': pin},
);
final completedOrder = SnWalletOrder.fromJson(response.data);
widget.onPaymentSuccess?.call(completedOrder);
} catch (err) {
String errorMessage = 'paymentFailed'.tr();
if (err is DioException) {
if (err.response?.statusCode == 403 ||
err.response?.statusCode == 401) {
// PIN is invalid
errorMessage = 'invalidPin'.tr();
// If this was a biometric attempt with stored PIN, remove the stored PIN
if (!_isPinMode) {
await _secureStorage.delete(key: _pinStorageKey);
_hasStoredPin = false;
_fallbackToPinMode(errorMessage);
return;
}
} else if (err.response?.statusCode == 400) {
errorMessage = err.response?.data?['error'] ?? errorMessage;
} else {
rethrow;
}
}
throw errorMessage;
}
}
void _fallbackToPinMode(String? message) {
setState(() {
_isPinMode = true;
});
if (message != null && message.isNotEmpty) {
showSnackBar(message);
}
}
String _formatCurrency(int amount, String currency) {
final value = amount;
return '${value.toStringAsFixed(2)} $currency';
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Order Summary
_buildOrderSummary(),
const Gap(32),
// Authentication Content
Expanded(
child: _isPinMode ? _buildPinInput() : _buildBiometricAuth(),
),
// Action Buttons
const Gap(24),
_buildActionButtons(),
],
),
);
}
Widget _buildOrderSummary() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.receipt,
color: Theme.of(context).colorScheme.primary,
),
const Gap(8),
Text(
'paymentSummary'.tr(),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
),
],
),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'amount'.tr(),
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
_formatCurrency(widget.order.amount, widget.order.currency),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
),
],
),
if (widget.order.remarks != null) ...[
const Gap(8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'description'.tr(),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
const Spacer(),
Expanded(
flex: 2,
child: Text(
widget.order.remarks!,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.end,
),
),
],
),
],
],
),
);
}
Widget _buildPinInput() {
return Column(
children: [
Text(
'enterPinToConfirmPayment'.tr(),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
textAlign: TextAlign.center,
),
const Gap(24),
OtpTextField(
numberOfFields: 6,
borderColor: Theme.of(context).colorScheme.outline,
focusedBorderColor: Theme.of(context).colorScheme.primary,
showFieldAsBox: true,
obscureText: true,
keyboardType: TextInputType.number,
fieldWidth: 48,
fieldHeight: 56,
borderRadius: BorderRadius.circular(8),
borderWidth: 1,
textStyle: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600),
onSubmit: _onPinSubmit,
onCodeChanged: (String code) {
_pin = code;
setState(() {});
},
),
],
);
}
Widget _buildBiometricAuth() {
return SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Symbols.fingerprint, size: 48),
const Gap(16),
Text(
'useBiometricToConfirm'.tr(),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
textAlign: TextAlign.center,
),
Text(
'The biometric data will only be processed on your device',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 11,
),
textAlign: TextAlign.center,
).opacity(0.75),
const Gap(28),
ElevatedButton.icon(
onPressed: _authenticateWithBiometric,
icon: const Icon(Symbols.fingerprint),
label: Text('authenticateNow'.tr()),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
TextButton(
onPressed: () => _fallbackToPinMode(null),
child: Text('usePinInstead'.tr()),
),
],
).center(),
);
}
Widget _buildActionButtons() {
return Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: widget.onCancel,
child: Text('cancel'.tr()),
),
),
if (_isPinMode && _pin.length == 6) ...[
const Gap(12),
Expanded(
child: ElevatedButton(
onPressed: () => _processPaymentWithPin(_pin),
child: Text('confirm'.tr()),
),
),
],
],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,65 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
class UploadMenuItemData {
final IconData icon;
final String textKey;
final VoidCallback onPressed;
const UploadMenuItemData(this.icon, this.textKey, this.onPressed);
}
class UploadMenu extends StatelessWidget {
final List<UploadMenuItemData> items;
final bool isCompact;
final Color? iconColor;
const UploadMenu({
super.key,
required this.items,
this.isCompact = false,
this.iconColor,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return MenuAnchor(
builder:
(context, controller, child) => IconButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
tooltip: 'uploadFile'.tr(),
icon: const Icon(Symbols.file_upload),
color: iconColor ?? colorScheme.primary,
visualDensity:
isCompact
? const VisualDensity(horizontal: -4, vertical: -2)
: null,
),
menuChildren:
items
.map(
(item) => MenuItemButton(
onPressed: item.onPressed,
leadingIcon: Icon(item.icon),
style: ButtonStyle(
visualDensity: VisualDensity.compact,
padding: WidgetStatePropertyAll(
EdgeInsets.only(left: 12, right: 16, top: 20, bottom: 20),
),
),
child: Text(item.textKey.tr()),
),
)
.toList(),
);
}
}