🎨 Continued to rearrange core folders content
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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']}'),
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
];
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export 'video.native.dart' if (dart.library.html) 'video.web.dart';
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user