Compare commits

...

17 Commits

Author SHA1 Message Date
457d1bac60 🚀 Launch 3.2.0+133 2025-09-24 19:30:36 +08:00
02ec11845b Seprate uploading action in chat 2025-09-24 16:53:32 +08:00
612f1bf004 File uploader 2025-09-24 16:45:24 +08:00
fd80b713ad 🐛 Fix something 2025-09-24 16:16:21 +08:00
508805368c File manage filter 2025-09-24 16:09:40 +08:00
98eb28a4ec File manage list 2025-09-24 15:56:56 +08:00
d1a2f59dd1 💄 Optimize account page 2025-09-24 14:49:06 +08:00
bb9adb963a 💄 Redesign leveling card 2025-09-24 14:29:50 +08:00
83e40cd860 ♻️ Merge the social credits to the leveling page 2025-09-24 13:59:01 +08:00
c06fb12f6a 🐛 Fix styling issue 2025-09-24 12:41:09 +08:00
6600cf4df8 🍱 Update reactions images 2025-09-24 00:33:28 +08:00
4293daaa2f Introduce cuite reaction 2025-09-23 23:39:58 +08:00
866674ddde 🐛 Fix something 2025-09-23 22:55:53 +08:00
27d478ba4f 💄 Optimize the embed view experience 2025-09-23 21:02:14 +08:00
cccade763f 💄 Rename IRC chat UI 2025-09-23 20:28:46 +08:00
f760b85186 💄 Optimize message flashing 2025-09-23 20:27:18 +08:00
e68c5f4f92 💄 Optimize irc styles 2025-09-23 20:12:39 +08:00
40 changed files with 3089 additions and 1169 deletions

View File

@@ -336,6 +336,18 @@
"levelingProgress": "Leveling Progress",
"levelingProgressExperience": "{} EXP",
"levelingProgressLevel": "Level {}",
"levelingStage1": "Novice",
"levelingStage2": "Apprentice",
"levelingStage3": "Journeyman",
"levelingStage4": "Adept",
"levelingStage5": "Expert",
"levelingStage6": "Master",
"levelingStage7": "Grandmaster",
"levelingStage8": "Legend",
"levelingStage9": "Myth",
"levelingStage10": "Immortal",
"levelingStage11": "Divine",
"levelingStage12": "Transcendent",
"fileUploadingProgress": "Uploading file #{}: {}%",
"removeChatMember": "Remove Chat Room Member",
"removeChatMemberHint": "Are you sure to remove this member from the room?",
@@ -896,6 +908,15 @@
"attachmentOnDevice": "On-device",
"attachmentOnCloud": "On-cloud",
"attachments": "Attachments",
"uploadAttachment": "Upload Attachment",
"attachmentPreview": "Attachment Preview",
"selectPool": "Select Pool",
"choosePool": "Choose a pool",
"errorLoadingPools": "Error loading pools",
"quotaCostInfo": "This upload will cost {} quota points",
"uploadConstraints": "Upload Constraints",
"fileSizeExceeded": "File size exceeds the maximum limit of {}",
"fileTypeNotAccepted": "File type is not accepted by this pool",
"publisherCollabInvitation": "Collabration invitations",
"publisherCollabInvitationCount": {
"zero": "No invitation",
@@ -1012,6 +1033,11 @@
"expandPoll": "Expand Poll",
"collapsePoll": "Collapse Poll",
"embedView": "Embed View",
"auto": "Auto",
"manual": "Manual",
"iframeCode": "Iframe Code",
"iframeCodeHint": "<iframe src=\"...\" width=\"...\" height=\"...\">",
"parseIframe": "Parse Iframe",
"embedUri": "Embed URI",
"aspectRatio": "Aspect Ratio",
"renderer": "Renderer",
@@ -1023,5 +1049,18 @@
"noEmbed": "No embed yet",
"save": "Save",
"webView": "Web View",
"messageActions": "Message Actions"
"messageActions": "Message Actions",
"viewEmbedLoadHint": "Tap to load",
"files": "Files",
"confirmDeleteFile": "Are you sure you want to delete this file?",
"deleteFile": "Delete File",
"failedToDeleteFile": "Failed to delete file",
"drive": "Drive",
"allPools": "All Pools",
"includeRecycled": "Include Recycled",
"confirmDeleteRecycledFiles": "Are you sure you want to delete all recycled files?",
"deleteRecycledFiles": "Delete Recycled Files",
"recycledFilesDeleted": "Recycled files deleted successfully",
"failedToDeleteRecycledFiles": "Failed to delete recycled files",
"upload": "Upload"
}

View File

@@ -283,6 +283,18 @@
"levelingProgress": "等级进度",
"levelingProgressExperience": "{} 经验值",
"levelingProgressLevel": "等级 {}",
"levelingStage1": "新手",
"levelingStage2": "学徒",
"levelingStage3": "熟练工",
"levelingStage4": "行家",
"levelingStage5": "专家",
"levelingStage6": "大师",
"levelingStage7": "宗师",
"levelingStage8": "传奇",
"levelingStage9": "神话",
"levelingStage10": "不朽",
"levelingStage11": "神圣",
"levelingStage12": "超凡",
"fileUploadingProgress": "正在上传文件 #{}: {}%",
"removeChatMember": "移除聊天室成员",
"removeChatMemberHint": "确定要将此成员从聊天室中移除吗?",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 623 KiB

View File

@@ -148,7 +148,7 @@ class AppDatabase extends _$AppDatabase {
..where((m) => m.roomId.equals(roomId));
if (query.isNotEmpty) {
final searchTerm = '%${query}%';
final searchTerm = '%$query%';
selectStatement =
selectStatement..where(
(m) =>

View File

@@ -395,7 +395,12 @@ final rpcServerStateProvider =
final label = data['args']['activity']['details'] ?? 'Unknown';
final appId = socket.clientId;
try {
await setRemoteActivityStatus(ref, label, appId);
await setRemoteActivityStatus(
ref,
label,
appId,
data['args']['activity'],
);
} catch (e) {
developer.log(
'Failed to set remote activity status: $e',
@@ -435,6 +440,7 @@ Future<void> setRemoteActivityStatus(
Ref ref,
String label,
String appId,
Map<String, dynamic> meta,
) async {
final apiClient = ref.read(apiClientProvider);
await apiClient.post(
@@ -445,6 +451,7 @@ Future<void> setRemoteActivityStatus(
'is_automated': true,
'label': label,
'app_identifier': appId,
'meta': meta,
},
);
}

View File

@@ -16,7 +16,7 @@ import "package:island/widgets/alert.dart";
import "package:riverpod_annotation/riverpod_annotation.dart";
import "package:uuid/uuid.dart";
import "package:island/screens/chat/chat.dart";
import "package:island/pods/room_providers.dart";
import "package:island/pods/chat_rooms.dart";
part 'messages_notifier.g.dart';

View File

@@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/screens/about.dart';
import 'package:island/screens/account/credits.dart';
import 'package:island/screens/developers/app_detail.dart';
import 'package:island/screens/developers/bot_detail.dart';
import 'package:island/screens/developers/edit_app.dart';
@@ -19,6 +18,7 @@ import 'package:island/screens/developers/edit_project.dart';
import 'package:island/screens/developers/new_project.dart';
import 'package:island/screens/developers/project_detail.dart';
import 'package:island/screens/discovery/articles.dart';
import 'package:island/screens/files/file_list.dart';
import 'package:island/screens/posts/post_categories_list.dart';
import 'package:island/screens/posts/post_category_detail.dart';
import 'package:island/screens/posts/post_search.dart';
@@ -656,9 +656,9 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state) => const WalletScreen(),
),
GoRoute(
name: 'socialCredits',
path: '/account/credits',
builder: (context, state) => const SocialCreditsScreen(),
name: 'files',
path: '/account/files',
builder: (context, state) => const FileListScreen(),
),
GoRoute(
name: 'relationships',

View File

@@ -141,20 +141,22 @@ class AccountScreen extends HookConsumerWidget {
],
),
).padding(horizontal: 8),
GestureDetector(
child: LevelingProgressCard(
level: user.value!.profile.level,
experience: user.value!.profile.experience,
progress: user.value!.profile.levelingProgress,
),
LevelingProgressCard(
isCompact: true,
level: user.value!.profile.level,
experience: user.value!.profile.experience,
progress: user.value!.profile.levelingProgress,
onTap: () {
context.pushNamed('leveling');
},
).padding(horizontal: 12),
const SizedBox.shrink(),
Row(
spacing: 8,
children: [
Expanded(
child: Card(
margin: EdgeInsets.zero,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Column(
@@ -181,6 +183,7 @@ class AccountScreen extends HookConsumerWidget {
),
Expanded(
child: Card(
margin: EdgeInsets.zero,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Column(
@@ -206,7 +209,73 @@ class AccountScreen extends HookConsumerWidget {
).height(140),
),
],
).padding(horizontal: 8),
).padding(horizontal: 12),
const SizedBox.shrink(),
Row(
spacing: 8,
children: [
Expanded(
child: Card(
margin: EdgeInsets.zero,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Symbols.settings, size: 28).padding(bottom: 8),
Text('appSettings').tr().fontSize(16).bold(),
],
).padding(horizontal: 16, vertical: 12),
onTap: () {
context.pushNamed('settings');
},
),
).height(120),
),
Expanded(
child: Card(
margin: EdgeInsets.zero,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Symbols.person_edit,
size: 28,
).padding(bottom: 8),
Text('updateYourProfile').tr().fontSize(16).bold(),
],
).padding(horizontal: 16, vertical: 12),
onTap: () {
context.pushNamed('profileUpdate');
},
),
).height(120),
),
Expanded(
child: Card(
margin: EdgeInsets.zero,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Symbols.manage_accounts,
size: 28,
).padding(bottom: 8),
Text('accountSettings').tr().fontSize(16).bold(),
],
).padding(horizontal: 16, vertical: 12),
onTap: () {
context.pushNamed('accountSettings');
},
),
).height(120),
),
],
).padding(horizontal: 12),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.notifications),
@@ -235,6 +304,16 @@ class AccountScreen extends HookConsumerWidget {
context.pushNamed('wallet');
},
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.files),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('files').tr(),
onTap: () {
context.pushNamed('files');
},
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.people),
@@ -265,16 +344,6 @@ class AccountScreen extends HookConsumerWidget {
context.pushNamed('webFeedMarketplace');
},
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.star),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('credits').tr(),
onTap: () {
context.pushNamed('socialCredits');
},
),
ListTile(
minTileHeight: 48,
title: Text('abuseReport').tr(),
@@ -284,37 +353,6 @@ class AccountScreen extends HookConsumerWidget {
onTap: () => context.pushNamed('reportList'),
),
const Divider(height: 1).padding(vertical: 8),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.settings),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('appSettings').tr(),
onTap: () {
context.pushNamed('settings');
},
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.person_edit),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('updateYourProfile').tr(),
onTap: () {
context.pushNamed('profileUpdate');
},
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.manage_accounts),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('accountSettings').tr(),
onTap: () {
context.pushNamed('accountSettings');
},
),
const Divider(height: 1).padding(vertical: 8),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.info),
@@ -333,6 +371,8 @@ class AccountScreen extends HookConsumerWidget {
title: Text('debugOptions').tr(),
onTap: () {
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: (context) => DebugSheet(),
);

View File

@@ -4,7 +4,6 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@@ -59,94 +58,93 @@ class SocialCreditHistoryNotifier extends _$SocialCreditHistoryNotifier
}
}
class SocialCreditsScreen extends HookConsumerWidget {
const SocialCreditsScreen({super.key});
class SocialCreditsTab extends HookConsumerWidget {
const SocialCreditsTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final socialCredits = ref.watch(socialCreditsProvider);
return AppScaffold(
appBar: AppBar(title: Text('socialCredits').tr()),
body: Column(
children: [
Card(
margin: EdgeInsets.only(left: 16, right: 16, top: 8),
child: socialCredits
.when(
data:
(credits) => Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
credits < 100
? 'socialCreditsLevelPoor'.tr()
: credits < 150
? 'socialCreditsLevelNormal'.tr()
: credits < 200
? 'socialCreditsLevelGood'.tr()
: 'socialCreditsLevelExcellent'.tr(),
).tr().bold().fontSize(20),
Text(
'${credits.toStringAsFixed(2)} pts',
).fontSize(14),
const Gap(8),
LinearProgressIndicator(value: credits / 200),
],
return Column(
children: [
const Gap(8),
Card(
margin: const EdgeInsets.only(left: 16, right: 16, top: 8),
child: socialCredits
.when(
data:
(credits) => Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
credits < 100
? 'socialCreditsLevelPoor'.tr()
: credits < 150
? 'socialCreditsLevelNormal'.tr()
: credits < 200
? 'socialCreditsLevelGood'.tr()
: 'socialCreditsLevelExcellent'.tr(),
).tr().bold().fontSize(20),
Text(
'${credits.toStringAsFixed(2)} pts',
).fontSize(14),
const Gap(8),
LinearProgressIndicator(value: credits / 200),
],
),
Positioned(
right: 0,
top: 0,
child: IconButton(
onPressed: () {},
icon: const Icon(Symbols.info),
tooltip: 'socialCreditsDescription'.tr(),
),
Positioned(
right: 0,
top: 0,
child: IconButton(
onPressed: () {},
icon: const Icon(Symbols.info),
tooltip: 'socialCreditsDescription'.tr(),
),
),
],
),
],
),
error: (_, _) => Text('Error loading credits'),
loading: () => const LinearProgressIndicator(),
)
.padding(horizontal: 20, vertical: 16),
),
Expanded(
child: PagingHelperView(
provider: socialCreditHistoryNotifierProvider,
futureRefreshable: socialCreditHistoryNotifierProvider.future,
notifierRefreshable: socialCreditHistoryNotifierProvider.notifier,
contentBuilder:
(data, widgetCount, endItemView) => ListView.builder(
padding: EdgeInsets.zero,
itemCount: widgetCount,
itemBuilder: (context, index) {
if (index == widgetCount - 1) {
return endItemView;
}
final record = data.items[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
),
error: (_, _) => Text('Error loading credits'),
loading: () => const LinearProgressIndicator(),
)
.padding(horizontal: 20, vertical: 16),
),
Expanded(
child: PagingHelperView(
provider: socialCreditHistoryNotifierProvider,
futureRefreshable: socialCreditHistoryNotifierProvider.future,
notifierRefreshable: socialCreditHistoryNotifierProvider.notifier,
contentBuilder:
(data, widgetCount, endItemView) => ListView.builder(
padding: EdgeInsets.zero,
itemCount: widgetCount,
itemBuilder: (context, index) {
if (index == widgetCount - 1) {
return endItemView;
}
final record = data.items[index];
return ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text(record.reason),
subtitle: Text(
DateFormat.yMMMd().format(record.createdAt),
title: Text(record.reason),
subtitle: Text(
DateFormat.yMMMd().format(record.createdAt),
),
trailing: Text(
record.delta > 0
? '+${record.delta}'
: '${record.delta}',
style: TextStyle(
color: record.delta > 0 ? Colors.green : Colors.red,
),
trailing: Text(
record.delta > 0
? '+${record.delta}'
: '${record.delta}',
style: TextStyle(
color: record.delta > 0 ? Colors.green : Colors.red,
),
),
);
},
),
),
),
);
},
),
),
],
),
),
],
);
}
}

View File

@@ -4,12 +4,12 @@ import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart';
import 'package:island/models/wallet.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/account/credits.dart';
import 'package:island/services/responsive.dart';
import 'package:island/services/time.dart';
import 'package:island/widgets/account/leveling_progress.dart';
@@ -89,7 +89,7 @@ class LevelingScreen extends HookConsumerWidget {
}
return DefaultTabController(
length: 2,
length: 3,
child: AppScaffold(
appBar: AppBar(
title: Text('levelingProgress'.tr()),
@@ -104,6 +104,15 @@ class LevelingScreen extends HookConsumerWidget {
),
),
),
Tab(
child: Text(
'socialCredits'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
Tab(
child: Text(
'stellarProgram'.tr(),
@@ -119,6 +128,7 @@ class LevelingScreen extends HookConsumerWidget {
body: TabBarView(
children: [
_buildLevelingTab(context, ref, user.value!),
const SocialCreditsTab(),
_buildStellarProgramTab(context, ref),
],
),
@@ -164,10 +174,33 @@ class LevelingScreen extends HookConsumerWidget {
),
const SliverGap(16),
// Stairs visualization with fixed height and horizontal scroll
SliverToBoxAdapter(child: _buildLevelStairs(context, currentLevel)),
const SliverGap(24),
SliverToBoxAdapter(
child: Card(
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
LinearProgressIndicator(
value: currentLevel / 120,
minHeight: 10,
stopIndicatorRadius: 0,
trackGap: 0,
color: Theme.of(context).colorScheme.primary,
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(32),
),
const Gap(8),
Text(
'${'levelingProgressLevel'.tr(args: [currentLevel.toString()])} / 120',
textAlign: TextAlign.right,
style: Theme.of(context).textTheme.bodySmall,
),
],
).padding(horizontal: 16, top: 16, bottom: 12),
),
),
const SliverGap(16),
// Leveling History
SliverToBoxAdapter(
child: Text(
@@ -254,126 +287,6 @@ class LevelingScreen extends HookConsumerWidget {
);
}
Widget _buildLevelStairs(BuildContext context, int currentLevel) {
const totalLevels = 14;
const stairHeight = 20.0;
const stairWidth = 50.0;
const containerHeight = 280.0;
return Container(
height: containerHeight,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: (totalLevels * (stairWidth + 8)) + 40,
height: containerHeight,
child: CustomPaint(
painter: LevelStairsPainter(
currentLevel: currentLevel,
totalLevels: totalLevels,
primaryColor: Theme.of(context).colorScheme.primary,
surfaceColor: Theme.of(context).colorScheme.surfaceContainerHigh,
onSurfaceColor: Theme.of(context).colorScheme.onSurface,
stairHeight: stairHeight,
stairWidth: stairWidth,
),
child: Stack(
children: List.generate(totalLevels, (index) {
final level = index + 1;
final isCompleted = level <= currentLevel;
final isCurrent = level == currentLevel;
// Calculate position from bottom
final bottomPosition = 0.0;
final leftPosition = 20.0 + (index * (stairWidth + 8));
// Make higher levels progressively taller
final progressiveHeight =
40.0 + (index * 15.0); // Base height + progressive increase
return Positioned(
left: leftPosition,
bottom: bottomPosition,
child: Container(
width: stairWidth,
height: progressiveHeight,
decoration: BoxDecoration(
color:
isCompleted
? Theme.of(context).colorScheme.primary
: Theme.of(
context,
).colorScheme.surfaceContainerHigh,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(6),
topRight: Radius.circular(6),
),
border:
isCurrent
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
boxShadow:
isCurrent
? [
BoxShadow(
color: Theme.of(
context,
).colorScheme.primary.withOpacity(0.3),
blurRadius: 6,
spreadRadius: 1,
),
]
: null,
),
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(
children: [
Text(
level.toString(),
style: GoogleFonts.robotoMono(
fontSize: 14,
fontWeight: FontWeight.bold,
color:
isCompleted
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurface,
),
),
if (isCurrent) ...[
const Gap(4),
Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onPrimary,
shape: BoxShape.circle,
),
),
],
],
),
),
),
);
}),
),
),
),
),
);
}
Widget _buildMembershipSection(
BuildContext context,
WidgetRef ref,

View File

@@ -1,5 +1,7 @@
import "dart:async";
import "dart:convert";
import "dart:typed_data";
import "package:cross_file/cross_file.dart";
import "package:easy_localization/easy_localization.dart";
import "package:file_picker/file_picker.dart";
import "package:flutter/material.dart";
@@ -10,16 +12,24 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:island/database/message.dart";
import "package:island/models/chat.dart";
import "package:island/models/file.dart";
import "package:island/models/file_pool.dart";
import "package:island/pods/config.dart";
import "package:island/pods/file_pool.dart";
import "package:island/pods/messages_notifier.dart";
import "package:island/pods/network.dart";
import "package:island/pods/websocket.dart";
import "package:island/services/file.dart";
import "package:island/screens/chat/chat.dart";
import "package:island/services/responsive.dart";
import "package:island/widgets/alert.dart";
import "package:island/widgets/app_scaffold.dart";
import "package:island/widgets/attachment_uploader.dart";
import "package:island/widgets/chat/call_overlay.dart";
import "package:island/widgets/chat/message_item.dart";
import "package:island/widgets/content/attachment_preview.dart";
import "package:island/widgets/content/cloud_files.dart";
import "package:island/widgets/content/sheet.dart";
import "package:island/widgets/post/compose_shared.dart";
import "package:island/widgets/response.dart";
import "package:material_symbols_icons/material_symbols_icons.dart";
import "package:styled_widget/styled_widget.dart";
@@ -464,6 +474,70 @@ class ChatRoomScreen extends HookConsumerWidget {
const messageKeyPrefix = 'message-';
Future<void> uploadAttachment(int index) async {
final attachment = attachments.value[index];
if (attachment.isOnCloud) return;
final config = await showModalBottomSheet<AttachmentUploadConfig>(
context: context,
isScrollControlled: true,
builder:
(context) => ChatAttachmentUploaderSheet(
ref: ref,
attachments: attachments.value,
index: index,
),
);
if (config == null) return;
final baseUrl = ref.watch(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider));
if (token == null) throw ArgumentError('Token is null');
try {
// Use 'chat-upload' as temporary key for progress
attachmentProgress.value = {
...attachmentProgress.value,
'chat-upload': {index: 0},
};
final cloudFile =
await putFileToCloud(
fileData: attachment,
atk: token,
baseUrl: baseUrl,
poolId: config.poolId,
filename: attachment.data.name ?? 'Chat media',
mimetype:
attachment.data.mimeType ??
ComposeLogic.getMimeTypeFromFileType(attachment.type),
mode:
attachment.type == UniversalFileType.file
? FileUploadMode.generic
: FileUploadMode.mediaSafe,
onProgress: (progress, _) {
attachmentProgress.value = {
...attachmentProgress.value,
'chat-upload': {index: progress},
};
},
).future;
if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...');
}
final clone = List.of(attachments.value);
clone[index] = UniversalFile(data: cloudFile, type: attachment.type);
attachments.value = clone;
} catch (err) {
showErrorAlert(err.toString());
} finally {
attachmentProgress.value = {...attachmentProgress.value}
..remove('chat-upload');
}
}
Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
SuperListView.builder(
listController: listController,
@@ -779,9 +853,7 @@ class ChatRoomScreen extends HookConsumerWidget {
}
},
attachments: attachments.value,
onUploadAttachment: (_) {
// not going to do anything, only upload when send the message
},
onUploadAttachment: uploadAttachment,
onDeleteAttachment: (index) async {
final attachment = attachments.value[index];
if (attachment.isOnCloud) {
@@ -806,6 +878,7 @@ class ChatRoomScreen extends HookConsumerWidget {
onAttachmentsChanged: (newAttachments) {
attachments.value = newAttachments;
},
attachmentProgress: attachmentProgress.value,
),
],
),
@@ -825,3 +898,344 @@ class ChatRoomScreen extends HookConsumerWidget {
);
}
}
class ChatAttachmentUploaderSheet extends StatefulWidget {
final WidgetRef ref;
final List<UniversalFile> attachments;
final int index;
const ChatAttachmentUploaderSheet({
super.key,
required this.ref,
required this.attachments,
required this.index,
});
@override
State<ChatAttachmentUploaderSheet> createState() =>
_ChatAttachmentUploaderSheetState();
}
class _ChatAttachmentUploaderSheetState
extends State<ChatAttachmentUploaderSheet> {
String? selectedPoolId;
@override
Widget build(BuildContext context) {
final attachment = widget.attachments[widget.index];
return SheetScaffold(
titleText: 'uploadAttachment'.tr(),
child: FutureBuilder<List<SnFilePool>>(
future: widget.ref.read(poolsProvider.future),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('errorLoadingPools'.tr()));
}
final pools = snapshot.data!.filterValid();
selectedPoolId ??= resolveDefaultPoolId(widget.ref, pools);
return Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
value: selectedPoolId,
items:
pools.map((pool) {
return DropdownMenuItem<String>(
value: pool.id,
child: Text(pool.name),
);
}).toList(),
onChanged: (value) {
setState(() {
selectedPoolId = value;
});
},
decoration: InputDecoration(
labelText: 'selectPool'.tr(),
border: const OutlineInputBorder(),
hintText: 'choosePool'.tr(),
),
),
const Gap(16),
FutureBuilder<int?>(
future: _getFileSize(attachment),
builder: (context, sizeSnapshot) {
if (!sizeSnapshot.hasData) {
return const SizedBox.shrink();
}
final fileSize = sizeSnapshot.data!;
final selectedPool = pools.firstWhere(
(p) => p.id == selectedPoolId,
);
// Check file size limit
final maxFileSize =
selectedPool.policyConfig?['max_file_size']
as int?;
final fileSizeExceeded =
maxFileSize != null && fileSize > maxFileSize;
// Check accepted types
final acceptTypes =
selectedPool.policyConfig?['accept_types']
as List?;
final mimeType =
attachment.data.mimeType ??
ComposeLogic.getMimeTypeFromFileType(
attachment.type,
);
final typeAccepted =
acceptTypes == null ||
acceptTypes.isEmpty ||
acceptTypes.any(
(type) => mimeType.startsWith(type),
);
final hasIssues = fileSizeExceeded || !typeAccepted;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hasIssues) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color:
Theme.of(
context,
).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.warning,
size: 18,
color:
Theme.of(
context,
).colorScheme.error,
),
const Gap(8),
Text(
'uploadConstraints'.tr(),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(
color:
Theme.of(
context,
).colorScheme.error,
fontWeight: FontWeight.w600,
),
),
],
),
if (fileSizeExceeded) ...[
const Gap(4),
Text(
'fileSizeExceeded'.tr(
args: [
_formatFileSize(maxFileSize),
],
),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(
context,
).colorScheme.error,
),
),
],
if (!typeAccepted) ...[
const Gap(4),
Text(
'fileTypeNotAccepted'.tr(),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(
context,
).colorScheme.error,
),
),
],
],
),
),
const Gap(12),
],
Row(
spacing: 6,
children: [
const Icon(
Symbols.account_balance_wallet,
size: 18,
),
Expanded(
child: Text(
'quotaCostInfo'.tr(
args: [
_formatQuotaCost(
fileSize,
selectedPool,
),
],
),
style:
Theme.of(
context,
).textTheme.bodyMedium,
).fontSize(13),
),
],
).padding(horizontal: 4),
],
);
},
),
const Gap(4),
Row(
spacing: 6,
children: [
const Icon(Symbols.info, size: 18),
Text(
'attachmentPreview'.tr(),
style: Theme.of(context).textTheme.titleMedium,
).fontSize(13),
],
).padding(horizontal: 4),
const Gap(8),
AttachmentPreview(item: attachment, isCompact: true),
],
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () => Navigator.pop(context),
icon: const Icon(Symbols.close),
label: Text('cancel').tr(),
),
const Gap(8),
TextButton.icon(
onPressed: () => _confirmUpload(),
icon: const Icon(Symbols.upload),
label: Text('upload').tr(),
),
],
),
),
],
);
},
),
);
}
Future<AttachmentUploadConfig?> _getUploadConfig() async {
final attachment = widget.attachments[widget.index];
final fileSize = await _getFileSize(attachment);
if (fileSize == null) return null;
// Get the selected pool to check constraints
final pools = await widget.ref.read(poolsProvider.future);
final selectedPool = pools.filterValid().firstWhere(
(p) => p.id == selectedPoolId,
);
// Check constraints
final maxFileSize = selectedPool.policyConfig?['max_file_size'] as int?;
final fileSizeExceeded = maxFileSize != null && fileSize > maxFileSize;
final acceptTypes = selectedPool.policyConfig?['accept_types'] as List?;
final mimeType =
attachment.data.mimeType ??
ComposeLogic.getMimeTypeFromFileType(attachment.type);
final typeAccepted =
acceptTypes == null ||
acceptTypes.isEmpty ||
acceptTypes.any((type) => mimeType.startsWith(type));
final hasConstraints = fileSizeExceeded || !typeAccepted;
return AttachmentUploadConfig(
poolId: selectedPoolId!,
hasConstraints: hasConstraints,
);
}
Future<void> _confirmUpload() async {
final config = await _getUploadConfig();
if (config != null && mounted) {
Navigator.pop(context, config);
}
}
Future<int?> _getFileSize(UniversalFile attachment) async {
if (attachment.data is XFile) {
try {
return await (attachment.data as XFile).length();
} catch (e) {
return null;
}
} else if (attachment.data is SnCloudFile) {
return (attachment.data as SnCloudFile).size;
} else if (attachment.data is List<int>) {
return (attachment.data as List<int>).length;
} else if (attachment.data is Uint8List) {
return (attachment.data as Uint8List).length;
}
return null;
}
String _formatNumber(int number) {
if (number >= 1000000) {
return '${(number / 1000000).toStringAsFixed(1)}M';
} else if (number >= 1000) {
return '${(number / 1000).toStringAsFixed(1)}K';
} else {
return number.toString();
}
}
String _formatFileSize(int bytes) {
if (bytes >= 1073741824) {
return '${(bytes / 1073741824).toStringAsFixed(1)} GB';
} else if (bytes >= 1048576) {
return '${(bytes / 1048576).toStringAsFixed(1)} MB';
} else if (bytes >= 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
} else {
return '$bytes bytes';
}
}
String _formatQuotaCost(int fileSize, SnFilePool pool) {
final costMultiplier = pool.billingConfig?['cost_multiplier'] ?? 1.0;
final quotaCost = ((fileSize / 1024 / 1024) * costMultiplier).round();
return _formatNumber(quotaCost);
}
}

View File

@@ -0,0 +1,556 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:fl_chart/fl_chart.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/models/file.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/file_pool.dart';
import 'package:island/utils/format.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/file_info_sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:styled_widget/styled_widget.dart';
part 'file_list.g.dart';
@riverpod
class CloudFileListNotifier extends _$CloudFileListNotifier
with CursorPagingNotifierMixin<SnCloudFile> {
String? _poolId;
bool _includeRecycled = false;
void setFilters(String? poolId, bool includeRecycled) {
_poolId = poolId;
_includeRecycled = includeRecycled;
ref.invalidateSelf();
}
@override
Future<CursorPagingData<SnCloudFile>> build() => fetch(cursor: null);
@override
Future<CursorPagingData<SnCloudFile>> fetch({required String? cursor}) async {
final client = ref.read(apiClientProvider);
final offset = cursor == null ? 0 : int.parse(cursor);
final take = 20;
final queryParameters = <String, dynamic>{'offset': offset, 'take': take};
// Add filter parameters
if (_poolId != null) {
queryParameters['pool'] = _poolId!;
}
if (_includeRecycled) {
queryParameters['recycled'] = 'true';
}
final response = await client.get(
'/drive/files/me',
queryParameters: queryParameters,
);
final List<SnCloudFile> items =
(response.data as List)
.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
.toList();
final total = int.parse(response.headers.value('X-Total') ?? '0');
final hasMore = offset + items.length < total;
final nextCursor = hasMore ? (offset + items.length).toString() : null;
return CursorPagingData(
items: items,
hasMore: hasMore,
nextCursor: nextCursor,
);
}
}
@riverpod
Future<Map<String, dynamic>?> billingUsage(Ref ref) async {
final client = ref.read(apiClientProvider);
final response = await client.get('/drive/billing/usage');
return response.data;
}
@riverpod
Future<Map<String, dynamic>?> billingQuota(Ref ref) async {
final client = ref.read(apiClientProvider);
final response = await client.get('/drive/billing/quota');
return response.data;
}
class FileListScreen extends HookConsumerWidget {
const FileListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Filter state
final selectedPool = useState<String?>(null);
final includeRecycled = useState(false);
final usageAsync = ref.watch(billingUsageProvider);
final quotaAsync = ref.watch(billingQuotaProvider);
// Update notifier filters when state changes
useEffect(() {
final notifier = ref.read(cloudFileListNotifierProvider.notifier);
notifier.setFilters(selectedPool.value, includeRecycled.value);
return null;
}, [selectedPool.value, includeRecycled.value]);
return AppScaffold(
appBar: AppBar(title: Text('Files'), leading: const PageBackButton()),
body: usageAsync.when(
data:
(usage) => quotaAsync.when(
data:
(quota) => _buildQuotaUI(
usage,
quota,
ref,
selectedPool,
includeRecycled,
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error loading quota')),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error loading usage')),
),
);
}
Widget _buildQuotaUI(
Map<String, dynamic>? usage,
Map<String, dynamic>? quota,
WidgetRef ref,
ValueNotifier<String?> selectedPool,
ValueNotifier<bool> includeRecycled,
) {
if (usage == null) return const SizedBox.shrink();
return CustomScrollView(
slivers: [
const SliverGap(8),
SliverToBoxAdapter(
child: Column(
children: [
Row(
children: [
Expanded(
child: _buildStatCard(
'All Uploads',
'${((usage['total_usage_bytes'] as num) / (1024 * 1024 * 1024)).toStringAsFixed(3)} GiB',
),
),
Expanded(
child: _buildStatCard(
'All Files',
'${usage['total_file_count']}',
),
),
],
),
Row(
children: [
Expanded(
child: _buildStatCard(
'Quota',
'${usage['total_quota']} MiB',
),
),
Expanded(
child: _buildStatCard(
'Used Quota',
'${((usage['used_quota'] as num) / (usage['total_quota'] as num) * 100).toStringAsFixed(2)}%',
progress:
(usage['used_quota'] as num) /
(usage['total_quota'] as num),
),
),
],
),
],
).padding(horizontal: 8),
),
SliverToBoxAdapter(
child: Row(
children: [
Expanded(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const Text('Pool Usage'),
SizedBox(
height: 200,
child: PieChart(_buildPoolChartData(usage)),
),
],
),
),
),
),
Expanded(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const Text('Verbose Quota'),
SizedBox(
height: 200,
child: PieChart(_buildQuotaChartData(quota)),
),
],
),
),
),
),
],
).padding(horizontal: 8),
),
const SliverGap(8),
SliverToBoxAdapter(
child: _buildFilters(ref, selectedPool, includeRecycled),
),
const SliverGap(8),
PagingHelperSliverView(
provider: cloudFileListNotifierProvider,
futureRefreshable: cloudFileListNotifierProvider.future,
notifierRefreshable: cloudFileListNotifierProvider.notifier,
contentBuilder:
(data, widgetCount, endItemView) => SliverList.builder(
itemCount: widgetCount,
itemBuilder: (context, index) {
if (index == widgetCount - 1) {
return endItemView;
}
final item = data.items[index];
final itemType = item.mimeType?.split('/').firstOrNull;
return ListTile(
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: SizedBox(
height: 48,
width: 48,
child: switch (itemType) {
'image' => CloudImageWidget(file: item),
'audio' =>
const Icon(Symbols.audio_file, fill: 1).center(),
'video' =>
const Icon(Symbols.video_file, fill: 1).center(),
_ =>
const Icon(Symbols.body_system, fill: 1).center(),
},
),
),
title:
item.name.isEmpty
? Text('untitled').tr().italic()
: Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(formatFileSize(item.size)),
onTap: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder: (context) => FileInfoSheet(item: item),
);
},
trailing: IconButton(
icon: const Icon(Symbols.delete),
onPressed: () async {
final confirmed = await showConfirmAlert(
'confirmDeleteFile'.tr(),
'deleteFile'.tr(),
);
if (!confirmed) return;
if (context.mounted) showLoadingModal(context);
try {
final client = ref.read(apiClientProvider);
await client.delete('/drive/files/${item.id}');
ref.invalidate(cloudFileListNotifierProvider);
} catch (e) {
showSnackBar('failedToDeleteFile'.tr());
} finally {
if (context.mounted) hideLoadingModal(context);
}
},
),
);
},
),
),
],
);
}
PieChartData _buildPoolChartData(Map<String, dynamic> usage) {
final pools = usage['pool_usages'] as List<dynamic>;
final colors = [
Colors.blue,
Colors.green,
Colors.orange,
Colors.red,
Colors.purple,
];
return PieChartData(
sections:
pools.asMap().entries.map((entry) {
final pool = entry.value as Map<String, dynamic>;
final title = pool['pool_name'] as String;
final truncatedTitle =
title.length > 8 ? '${title.substring(0, 8)}...' : title;
return PieChartSectionData(
value: (pool['usage_bytes'] as num).toDouble(),
title: truncatedTitle,
color: colors[entry.key % colors.length],
radius: 60,
titleStyle: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
);
}).toList(),
);
}
PieChartData _buildQuotaChartData(Map<String, dynamic>? quota) {
if (quota == null) return PieChartData(sections: []);
return PieChartData(
sections: [
PieChartSectionData(
value: (quota['based_quota'] as num).toDouble(),
title: 'Base',
color: Colors.green,
radius: 60,
titleStyle: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
PieChartSectionData(
value: (quota['extra_quota'] as num).toDouble(),
title: 'Extra',
color: Colors.orange,
radius: 60,
titleStyle: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
);
}
Widget _buildFilters(
WidgetRef ref,
ValueNotifier<String?> selectedPool,
ValueNotifier<bool> includeRecycled,
) {
final poolsAsync = ref.watch(poolsProvider);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'filters'.tr(),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const Gap(16),
LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth > 600;
return isWide
? Row(
children: [
Expanded(
flex: 2,
child: poolsAsync.when(
data:
(pools) => DropdownButtonFormField<String?>(
value: selectedPool.value,
decoration: InputDecoration(
labelText: 'Pool',
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem<String?>(
value: null,
child: Text('allPools'.tr()),
),
...pools.map(
(pool) => DropdownMenuItem<String?>(
value: pool.id,
child: Text(pool.name),
),
),
],
onChanged:
(value) => selectedPool.value = value,
),
loading: () => const CircularProgressIndicator(),
error: (e, _) => const Text('Error loading pools'),
),
),
const Gap(8),
Expanded(
child: Row(
children: [
Text('includeRecycled'.tr()),
const Gap(8),
Switch(
value: includeRecycled.value,
onChanged:
(value) => includeRecycled.value = value,
padding: EdgeInsets.zero,
),
],
),
),
const Gap(16),
IconButton(
icon: const Icon(Symbols.delete_sweep),
tooltip: 'deleteRecycledFiles'.tr(),
onPressed:
includeRecycled.value
? () => _deleteRecycledFiles(ref)
: null,
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
poolsAsync.when(
data:
(pools) => DropdownButtonFormField<String?>(
value: selectedPool.value,
decoration: const InputDecoration(
labelText: 'Pool',
border: OutlineInputBorder(),
),
items: [
DropdownMenuItem<String?>(
value: null,
child: Text('allPools'.tr()),
),
...pools.map(
(pool) => DropdownMenuItem<String?>(
value: pool.id,
child: Text(pool.name),
),
),
],
onChanged:
(value) => selectedPool.value = value,
),
loading: () => const CircularProgressIndicator(),
error: (e, _) => const Text('Error loading pools'),
),
const Gap(16),
Row(
children: [
Text('includeRecycled'.tr()),
const Gap(8),
Switch(
value: includeRecycled.value,
onChanged:
(value) => includeRecycled.value = value,
),
const Spacer(),
IconButton(
icon: const Icon(Symbols.delete_sweep),
tooltip: 'deleteRecycledFiles'.tr(),
onPressed:
includeRecycled.value
? () => _deleteRecycledFiles(ref)
: null,
),
],
),
],
);
},
),
],
),
),
).padding(horizontal: 8);
}
Future<void> _deleteRecycledFiles(WidgetRef ref) async {
final confirmed = await showConfirmAlert(
'confirmDeleteRecycledFiles'.tr(),
'deleteRecycledFiles'.tr(),
);
if (!confirmed) return;
if (ref.context.mounted) showLoadingModal(ref.context);
try {
final client = ref.read(apiClientProvider);
await client.delete('/drive/files/recycled');
ref.invalidate(cloudFileListNotifierProvider);
showSnackBar('recycledFilesDeleted'.tr());
} catch (e) {
showSnackBar('failedToDeleteRecycledFiles'.tr());
} finally {
if (ref.context.mounted) hideLoadingModal(ref.context);
}
}
Widget _buildStatCard(String label, String value, {double? progress}) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(label, style: const TextStyle(fontSize: 14)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
if (progress != null) ...[
const SizedBox(height: 8),
SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(value: progress),
),
],
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,69 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'file_list.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$billingUsageHash() => r'270ec8499378ee0c038aa44ad1c2e3ad9025740a';
/// See also [billingUsage].
@ProviderFor(billingUsage)
final billingUsageProvider =
AutoDisposeFutureProvider<Map<String, dynamic>?>.internal(
billingUsage,
name: r'billingUsageProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$billingUsageHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef BillingUsageRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>;
String _$billingQuotaHash() => r'0696b500fa8bb1270641bcacf262be58caff9b38';
/// See also [billingQuota].
@ProviderFor(billingQuota)
final billingQuotaProvider =
AutoDisposeFutureProvider<Map<String, dynamic>?>.internal(
billingQuota,
name: r'billingQuotaProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$billingQuotaHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef BillingQuotaRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>;
String _$cloudFileListNotifierHash() =>
r'e2c8a076a9e635c7b43a87d00f78775427ba6334';
/// See also [CloudFileListNotifier].
@ProviderFor(CloudFileListNotifier)
final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
CloudFileListNotifier,
CursorPagingData<SnCloudFile>
>.internal(
CloudFileListNotifier.new,
name: r'cloudFileListNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$cloudFileListNotifierHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$CloudFileListNotifier =
AutoDisposeAsyncNotifier<CursorPagingData<SnCloudFile>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -10,6 +10,7 @@ import 'package:island/screens/creators/publishers.dart';
import 'package:island/screens/posts/compose_article.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/attachment_uploader.dart';
import 'package:island/widgets/content/attachment_preview.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/post/compose_shared.dart';
@@ -225,8 +226,26 @@ class PostComposeScreen extends HookConsumerWidget {
return AttachmentPreview(
item: state.attachments.value[idx],
progress: progressMap[idx],
onRequestUpload:
() => ComposeLogic.uploadAttachment(ref, state, idx),
onRequestUpload: () async {
final config = await showModalBottomSheet<AttachmentUploadConfig>(
context: context,
isScrollControlled: true,
builder:
(context) => AttachmentUploaderSheet(
ref: ref,
state: state,
index: idx,
),
);
if (config != null) {
await ComposeLogic.uploadAttachment(
ref,
state,
idx,
poolId: config.poolId,
);
}
},
onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
onUpdate:
(value) => ComposeLogic.updateAttachment(state, value, idx),
@@ -253,8 +272,27 @@ class PostComposeScreen extends HookConsumerWidget {
return AttachmentPreview(
item: state.attachments.value[idx],
progress: progressMap[idx],
onRequestUpload:
() => ComposeLogic.uploadAttachment(ref, state, idx),
onRequestUpload: () async {
final config =
await showModalBottomSheet<AttachmentUploadConfig>(
context: context,
isScrollControlled: true,
builder:
(context) => AttachmentUploaderSheet(
ref: ref,
state: state,
index: idx,
),
);
if (config != null) {
await ComposeLogic.uploadAttachment(
ref,
state,
idx,
poolId: config.poolId,
);
}
},
onDelete:
() => ComposeLogic.deleteAttachment(ref, state, idx),
onUpdate:

View File

@@ -11,6 +11,7 @@ import 'package:island/models/post.dart';
import 'package:island/screens/creators/publishers.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/attachment_uploader.dart';
import 'package:island/screens/posts/post_detail.dart';
import 'package:island/widgets/content/attachment_preview.dart';
import 'package:island/widgets/content/cloud_files.dart';
@@ -345,12 +346,30 @@ class ArticleComposeScreen extends HookConsumerWidget {
isCompact: true,
item: attachments[idx],
progress: progressMap[idx],
onRequestUpload:
() => ComposeLogic.uploadAttachment(
onRequestUpload: () async {
final config =
await showModalBottomSheet<
AttachmentUploadConfig
>(
context: context,
isScrollControlled: true,
builder:
(context) =>
AttachmentUploaderSheet(
ref: ref,
state: state,
index: idx,
),
);
if (config != null) {
await ComposeLogic.uploadAttachment(
ref,
state,
idx,
),
poolId: config.poolId,
);
}
},
onUpdate:
(value) =>
ComposeLogic.updateAttachment(

View File

@@ -21,7 +21,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/pool_provider.dart';
import 'package:island/pods/file_pool.dart';
import 'package:island/models/file_pool.dart';
class SettingsScreen extends HookConsumerWidget {
@@ -146,12 +146,12 @@ class SettingsScreen extends HookConsumerWidget {
child: Text('Bubble').fontSize(14),
),
DropdownMenuItem<String>(
value: 'discord',
child: Text('Discord').fontSize(14),
value: 'column',
child: Text('Column').fontSize(14),
),
DropdownMenuItem<String>(
value: 'irc',
child: Text('IRC').fontSize(14),
value: 'compact',
child: Text('Compact').fontSize(14),
),
],
value: settings.messageDisplayStyle,

View File

@@ -290,8 +290,9 @@ class AccountSessionSheet extends HookConsumerWidget {
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted)
if (context.mounted) {
hideLoadingModal(context);
}
}
}
return confirm;

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:styled_widget/styled_widget.dart';
@@ -8,50 +7,152 @@ class LevelingProgressCard extends StatelessWidget {
final int level;
final int experience;
final double progress;
final VoidCallback? onTap;
final bool isCompact;
const LevelingProgressCard({
super.key,
required this.level,
required this.experience,
required this.progress,
this.onTap,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
return Card(
// Calculate level stage (1-12, each stage covers 10 levels)
int stage = ((level - 1) ~/ 10) + 1;
stage = stage.clamp(1, 12); // Ensure stage is within 1-12
// Define colors for each stage
const List<Color> stageColors = [
Colors.green,
Colors.blue,
Colors.teal,
Colors.cyan,
Colors.indigo,
Colors.lime,
Colors.yellow,
Colors.amber,
Colors.orange,
Colors.deepOrange,
Colors.pink,
Colors.red,
];
Color stageColor = stageColors[stage - 1];
// Compact mode adjustments
final double levelFontSize = isCompact ? 14 : 18;
final double stageFontSize = isCompact ? 13 : 14;
final double experienceFontSize = isCompact ? 12 : 14;
final double progressHeight = isCompact ? 6 : 10;
final double horizontalPadding = isCompact ? 16 : 20;
final double verticalPadding = isCompact ? 12 : 16;
final double gapSize = isCompact ? 4 : 8;
final double rowSpacing = 12;
final cardContent = Card(
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.baseline,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'levelingProgressLevel'.tr(args: [level.toString()]),
style: GoogleFonts.robotoMono(),
).fontSize(13).bold(),
Text(
'levelingProgressExperience'.tr(args: [experience.toString()]),
style: GoogleFonts.robotoMono(),
).fontSize(13),
],
),
const Gap(8),
Tooltip(
message: '${(progress).toStringAsFixed(1)}%',
child: LinearProgressIndicator(
minHeight: 4,
value: progress / 100,
color: Theme.of(context).colorScheme.primary,
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
gradient: LinearGradient(
colors: [
stageColor.withOpacity(0.1),
Theme.of(context).colorScheme.surface,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
],
).padding(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
spacing: rowSpacing,
crossAxisAlignment: CrossAxisAlignment.baseline,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: Text(
'levelingProgressLevel'.tr(args: [level.toString()]),
style: TextStyle(
color: stageColor,
fontWeight: FontWeight.bold,
fontSize: levelFontSize,
),
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'levelingStage$stage'.tr(),
style: TextStyle(
color: stageColor.withOpacity(0.7),
fontWeight: FontWeight.w500,
fontSize: stageFontSize,
),
),
if (onTap != null) ...[
const Gap(4),
Icon(
Icons.arrow_forward_ios,
size: isCompact ? 10 : 12,
color: stageColor.withOpacity(0.7),
),
],
],
),
],
),
Gap(gapSize),
Row(
spacing: rowSpacing,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Tooltip(
message: '${progress.toStringAsFixed(1)}%',
child: LinearProgressIndicator(
minHeight: progressHeight,
value: progress,
borderRadius: BorderRadius.circular(32),
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerLow.withOpacity(0.75),
color: stageColor,
stopIndicatorRadius: 0,
trackGap: 0,
),
),
),
Text(
'levelingProgressExperience'.tr(
args: [experience.toString()],
),
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.8),
fontSize: experienceFontSize,
),
),
],
),
],
).padding(horizontal: horizontalPadding, vertical: verticalPadding),
),
),
);
return cardContent;
}
}

View File

@@ -0,0 +1,365 @@
import 'dart:typed_data';
import 'package:cross_file/cross_file.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/models/file_pool.dart';
import 'package:island/pods/file_pool.dart';
import 'package:island/widgets/content/attachment_preview.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/compose_shared.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class AttachmentUploadConfig {
final String poolId;
final bool hasConstraints;
const AttachmentUploadConfig({
required this.poolId,
required this.hasConstraints,
});
}
class AttachmentUploaderSheet extends StatefulWidget {
final WidgetRef ref;
final ComposeState state;
final int index;
const AttachmentUploaderSheet({
super.key,
required this.ref,
required this.state,
required this.index,
});
@override
State<AttachmentUploaderSheet> createState() =>
_AttachmentUploaderSheetState();
}
class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
String? selectedPoolId;
@override
Widget build(BuildContext context) {
final attachment = widget.state.attachments.value[widget.index];
return SheetScaffold(
titleText: 'uploadAttachment'.tr(),
child: FutureBuilder<List<SnFilePool>>(
future: widget.ref.read(poolsProvider.future),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('errorLoadingPools'.tr()));
}
final pools = snapshot.data!.filterValid();
selectedPoolId ??= resolveDefaultPoolId(widget.ref, pools);
return Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
value: selectedPoolId,
items:
pools.map((pool) {
return DropdownMenuItem<String>(
value: pool.id,
child: Text(pool.name),
);
}).toList(),
onChanged: (value) {
setState(() {
selectedPoolId = value;
});
},
decoration: InputDecoration(
labelText: 'selectPool'.tr(),
border: const OutlineInputBorder(),
hintText: 'choosePool'.tr(),
),
),
const Gap(16),
FutureBuilder<int?>(
future: _getFileSize(attachment),
builder: (context, sizeSnapshot) {
if (!sizeSnapshot.hasData) {
return const SizedBox.shrink();
}
final fileSize = sizeSnapshot.data!;
final selectedPool = pools.firstWhere(
(p) => p.id == selectedPoolId,
);
// Check file size limit
final maxFileSize =
selectedPool.policyConfig?['max_file_size']
as int?;
final fileSizeExceeded =
maxFileSize != null && fileSize > maxFileSize;
// Check accepted types
final acceptTypes =
selectedPool.policyConfig?['accept_types']
as List?;
final mimeType =
attachment.data.mimeType ??
ComposeLogic.getMimeTypeFromFileType(
attachment.type,
);
final typeAccepted =
acceptTypes == null ||
acceptTypes.isEmpty ||
acceptTypes.any(
(type) => mimeType.startsWith(type),
);
final hasIssues = fileSizeExceeded || !typeAccepted;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hasIssues) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color:
Theme.of(
context,
).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.warning,
size: 18,
color:
Theme.of(
context,
).colorScheme.error,
),
const Gap(8),
Text(
'uploadConstraints'.tr(),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(
color:
Theme.of(
context,
).colorScheme.error,
fontWeight: FontWeight.w600,
),
),
],
),
if (fileSizeExceeded) ...[
const Gap(4),
Text(
'fileSizeExceeded'.tr(
args: [
_formatFileSize(maxFileSize),
],
),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(
context,
).colorScheme.error,
),
),
],
if (!typeAccepted) ...[
const Gap(4),
Text(
'fileTypeNotAccepted'.tr(),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(
context,
).colorScheme.error,
),
),
],
],
),
),
const Gap(12),
],
Row(
spacing: 6,
children: [
const Icon(
Symbols.account_balance_wallet,
size: 18,
),
Expanded(
child: Text(
'quotaCostInfo'.tr(
args: [
_formatQuotaCost(
fileSize,
selectedPool,
),
],
),
style:
Theme.of(
context,
).textTheme.bodyMedium,
).fontSize(13),
),
],
).padding(horizontal: 4),
],
);
},
),
const Gap(4),
Row(
spacing: 6,
children: [
const Icon(Symbols.info, size: 18),
Text(
'attachmentPreview'.tr(),
style: Theme.of(context).textTheme.titleMedium,
).fontSize(13),
],
).padding(horizontal: 4),
const Gap(8),
AttachmentPreview(item: attachment, isCompact: true),
],
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () => Navigator.pop(context),
icon: const Icon(Symbols.close),
label: Text('cancel').tr(),
),
const Gap(8),
TextButton.icon(
onPressed: () => _confirmUpload(),
icon: const Icon(Symbols.upload),
label: Text('upload').tr(),
),
],
),
),
],
);
},
),
);
}
Future<AttachmentUploadConfig?> _getUploadConfig() async {
final attachment = widget.state.attachments.value[widget.index];
final fileSize = await _getFileSize(attachment);
if (fileSize == null) return null;
// Get the selected pool to check constraints
final pools = await widget.ref.read(poolsProvider.future);
final selectedPool = pools.filterValid().firstWhere(
(p) => p.id == selectedPoolId,
);
// Check constraints
final maxFileSize = selectedPool.policyConfig?['max_file_size'] as int?;
final fileSizeExceeded = maxFileSize != null && fileSize > maxFileSize;
final acceptTypes = selectedPool.policyConfig?['accept_types'] as List?;
final mimeType =
attachment.data.mimeType ??
ComposeLogic.getMimeTypeFromFileType(attachment.type);
final typeAccepted =
acceptTypes == null ||
acceptTypes.isEmpty ||
acceptTypes.any((type) => mimeType.startsWith(type));
final hasConstraints = fileSizeExceeded || !typeAccepted;
return AttachmentUploadConfig(
poolId: selectedPoolId!,
hasConstraints: hasConstraints,
);
}
Future<void> _confirmUpload() async {
final config = await _getUploadConfig();
if (config != null && mounted) {
Navigator.pop(context, config);
}
}
Future<int?> _getFileSize(UniversalFile attachment) async {
if (attachment.data is XFile) {
try {
return await (attachment.data as XFile).length();
} catch (e) {
return null;
}
} else if (attachment.data is SnCloudFile) {
return (attachment.data as SnCloudFile).size;
} else if (attachment.data is List<int>) {
return (attachment.data as List<int>).length;
} else if (attachment.data is Uint8List) {
return (attachment.data as Uint8List).length;
}
return null;
}
String _formatNumber(int number) {
if (number >= 1000000) {
return '${(number / 1000000).toStringAsFixed(1)}M';
} else if (number >= 1000) {
return '${(number / 1000).toStringAsFixed(1)}K';
} else {
return number.toString();
}
}
String _formatFileSize(int bytes) {
if (bytes >= 1073741824) {
return '${(bytes / 1073741824).toStringAsFixed(1)} GB';
} else if (bytes >= 1048576) {
return '${(bytes / 1048576).toStringAsFixed(1)} MB';
} else if (bytes >= 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
} else {
return '$bytes bytes';
}
}
String _formatQuotaCost(int fileSize, SnFilePool pool) {
final costMultiplier = pool.billingConfig?['cost_multiplier'] ?? 1.0;
final quotaCost = ((fileSize / 1024 / 1024) * costMultiplier).round();
return _formatNumber(quotaCost);
}
}

View File

@@ -32,6 +32,7 @@ class ChatInput extends HookConsumerWidget {
final Function(int) onDeleteAttachment;
final Function(int, int) onMoveAttachment;
final Function(List<UniversalFile>) onAttachmentsChanged;
final Map<String, Map<int, double>> attachmentProgress;
const ChatInput({
super.key,
@@ -48,6 +49,7 @@ class ChatInput extends HookConsumerWidget {
required this.onDeleteAttachment,
required this.onMoveAttachment,
required this.onAttachmentsChanged,
required this.attachmentProgress,
});
@override
@@ -123,6 +125,7 @@ class ChatInput extends HookConsumerWidget {
width: 280,
child: AttachmentPreview(
item: attachments[idx],
progress: attachmentProgress['chat-upload']?[idx],
onRequestUpload: () => onUploadAttachment(idx),
onDelete: () => onDeleteAttachment(idx),
onUpdate: (value) {

View File

@@ -25,7 +25,7 @@ class MessageContent extends StatelessWidget {
children: [
Icon(
Symbols.delete,
size: 14,
size: 16,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withOpacity(0.6),
@@ -34,6 +34,7 @@ class MessageContent extends StatelessWidget {
Text(
item.content ?? 'Deleted a message',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: 13,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withOpacity(0.6),
@@ -59,7 +60,7 @@ class MessageContent extends StatelessWidget {
children: [
Icon(
Symbols.edit,
size: 14,
size: 16,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withOpacity(0.6),
@@ -71,7 +72,7 @@ class MessageContent extends StatelessWidget {
newText: item.content ?? 'Edited a message',
defaultTextStyle: Theme.of(
context,
).textTheme.bodySmall!.copyWith(
).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
addedTextStyle: TextStyle(

View File

@@ -54,6 +54,8 @@ class MessageItem extends HookConsumerWidget {
required this.onJump,
});
static const kFlashDuration = 300;
@override
Widget build(BuildContext context, WidgetRef ref) {
final remoteMessage = message.toRemoteMessage();
@@ -106,113 +108,211 @@ class MessageItem extends HookConsumerWidget {
showModalBottomSheet(
context: context,
builder:
(context) => SheetScaffold(
titleText: 'messageActions'.tr(),
child: SingleChildScrollView(
child: Column(
children: [
if (isCurrentUser)
ListTile(
leading: Icon(Symbols.edit),
title: Text('edit'.tr()),
onTap: () {
onAction!.call(MessageItemAction.edit);
Navigator.pop(context);
},
),
if (isCurrentUser)
ListTile(
leading: Icon(Symbols.delete),
title: Text('delete'.tr()),
onTap: () {
onAction!.call(MessageItemAction.delete);
Navigator.pop(context);
},
),
if (isCurrentUser) Divider(),
ListTile(
leading: Icon(Symbols.reply),
title: Text('reply'.tr()),
onTap: () {
onAction!.call(MessageItemAction.reply);
Navigator.pop(context);
},
),
ListTile(
leading: Icon(Symbols.forward),
title: Text('forward'.tr()),
onTap: () {
onAction!.call(MessageItemAction.forward);
Navigator.pop(context);
},
),
if (translatableLanguage) Divider(),
if (translatableLanguage)
ListTile(
leading: Icon(Symbols.translate),
title: Text(
translatedText.value == null
? 'translate'.tr()
: translating.value
? 'translating'.tr()
: 'translated'.tr(),
),
onTap: () {
translate();
Navigator.pop(context);
},
),
if (isMobile) Divider(),
if (isMobile)
ListTile(
leading: Icon(Symbols.copy_all),
title: Text('copyMessage'.tr()),
onTap: () {
Clipboard.setData(
ClipboardData(text: remoteMessage.content ?? ''),
);
Navigator.pop(context);
},
),
],
),
),
(context) => MessageActionSheet(
isCurrentUser: isCurrentUser,
onAction: onAction,
translatableLanguage: translatableLanguage,
translating: translating.value,
translatedText: translatedText.value,
translate: translate,
isMobile: isMobile,
remoteMessage: remoteMessage,
),
);
}
return GestureDetector(
final flashing = ref.watch(
flashingMessagesProvider.select((set) => set.contains(message.id)),
);
final isFlashing = useState(false);
final flashTimer = useState<Timer?>(null);
useEffect(() {
if (flashing) {
if (flashTimer.value != null) return null;
isFlashing.value = true;
flashTimer.value = Timer.periodic(
const Duration(milliseconds: kFlashDuration),
(timer) {
isFlashing.value = !isFlashing.value;
if (timer.tick >= 6) {
// 6 ticks: 1, 0, 1, 0, 1, 0
timer.cancel();
flashTimer.value = null;
isFlashing.value = false;
ref
.read(flashingMessagesProvider.notifier)
.update((set) => set.difference({message.id}));
}
},
);
} else {
flashTimer.value?.cancel();
flashTimer.value = null;
isFlashing.value = false;
}
return () {
flashTimer.value?.cancel();
};
}, [flashing]);
final flashColor =
isFlashing.value
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.8)
: Colors.transparent;
return InkWell(
onLongPress: showActionMenu,
onSecondaryTap: showActionMenu,
child: switch (settings.messageDisplayStyle) {
'irc' => MessageItemDisplayIRC(
message: message,
isCurrentUser: isCurrentUser,
progress: progress,
showAvatar: showAvatar,
onJump: onJump,
translatedText: translatedText.value,
translating: translating.value,
),
'discord' => MessageItemDisplayDiscord(
message: message,
isCurrentUser: isCurrentUser,
progress: progress,
showAvatar: showAvatar,
onJump: onJump,
translatedText: translatedText.value,
translating: translating.value,
),
_ => MessageItemDisplayBubble(
message: message,
isCurrentUser: isCurrentUser,
progress: progress,
showAvatar: showAvatar,
onJump: onJump,
translatedText: translatedText.value,
translating: translating.value,
),
onTap: () {
// Jump to related message
if (['messages.update', 'messages.delete'].contains(message.type) &&
message.meta['message_id'] is String &&
message.meta['message_id'] != null) {
onJump(message.meta['message_id']);
}
},
child: AnimatedContainer(
curve: Curves.easeInOut,
duration: const Duration(milliseconds: kFlashDuration),
decoration: BoxDecoration(color: flashColor),
child: switch (settings.messageDisplayStyle) {
'compact' => MessageItemDisplayIRC(
message: message,
isCurrentUser: isCurrentUser,
progress: progress,
showAvatar: showAvatar,
onJump: onJump,
translatedText: translatedText.value,
translating: translating.value,
),
'column' => MessageItemDisplayDiscord(
message: message,
isCurrentUser: isCurrentUser,
progress: progress,
showAvatar: showAvatar,
onJump: onJump,
translatedText: translatedText.value,
translating: translating.value,
),
_ => MessageItemDisplayBubble(
message: message,
isCurrentUser: isCurrentUser,
progress: progress,
showAvatar: showAvatar,
onJump: onJump,
translatedText: translatedText.value,
translating: translating.value,
),
},
),
);
}
}
class MessageActionSheet extends StatelessWidget {
final bool isCurrentUser;
final Function(String action)? onAction;
final bool translatableLanguage;
final bool translating;
final String? translatedText;
final VoidCallback translate;
final bool isMobile;
final dynamic remoteMessage;
const MessageActionSheet({
super.key,
required this.isCurrentUser,
required this.onAction,
required this.translatableLanguage,
required this.translating,
required this.translatedText,
required this.translate,
required this.isMobile,
required this.remoteMessage,
});
@override
Widget build(BuildContext context) {
return SheetScaffold(
titleText: 'messageActions'.tr(),
child: SingleChildScrollView(
child: Column(
children: [
const Gap(4),
if (isCurrentUser)
ListTile(
leading: Icon(Symbols.edit),
title: Text('edit'.tr()),
minTileHeight: 48,
onTap: () {
onAction!.call(MessageItemAction.edit);
Navigator.pop(context);
},
),
if (isCurrentUser)
ListTile(
leading: Icon(Symbols.delete),
title: Text('delete'.tr()),
minTileHeight: 48,
onTap: () {
onAction!.call(MessageItemAction.delete);
Navigator.pop(context);
},
),
if (isCurrentUser) const Divider(height: 8),
ListTile(
leading: Icon(Symbols.reply),
title: Text('reply'.tr()),
minTileHeight: 48,
onTap: () {
onAction!.call(MessageItemAction.reply);
Navigator.pop(context);
},
),
ListTile(
leading: Icon(Symbols.forward),
title: Text('forward'.tr()),
minTileHeight: 48,
onTap: () {
onAction!.call(MessageItemAction.forward);
Navigator.pop(context);
},
),
if (translatableLanguage) const Divider(height: 8),
if (translatableLanguage)
ListTile(
leading: Icon(Symbols.translate),
minTileHeight: 48,
title: Text(
translatedText == null
? 'translate'.tr()
: translating
? 'translating'.tr()
: 'translated'.tr(),
),
onTap: () {
translate();
Navigator.pop(context);
},
),
if (isMobile) const Divider(height: 8),
if (isMobile)
ListTile(
leading: Icon(Symbols.copy_all),
title: Text('copyMessage'.tr()),
minTileHeight: 48,
onTap: () {
Clipboard.setData(
ClipboardData(text: remoteMessage.content ?? ''),
);
Navigator.pop(context);
},
),
],
),
),
);
}
}
@@ -243,54 +343,10 @@ class MessageItemDisplayBubble extends HookConsumerWidget {
isCurrentUser
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSurfaceVariant;
final containerColor =
isCurrentUser
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.5)
: Theme.of(context).colorScheme.surfaceContainer;
final hasBackground =
ref.watch(backgroundImageFileProvider).valueOrNull != null;
final flashing = ref.watch(
flashingMessagesProvider.select((set) => set.contains(message.id)),
);
final isFlashing = useState(false);
final flashTimer = useState<Timer?>(null);
useEffect(() {
if (flashing) {
if (flashTimer.value != null) return null;
isFlashing.value = true;
flashTimer.value = Timer.periodic(const Duration(milliseconds: 200), (
timer,
) {
isFlashing.value = !isFlashing.value;
if (timer.tick >= 4) {
// 4 ticks: true, false, true, false
timer.cancel();
flashTimer.value = null;
isFlashing.value = false;
ref
.read(flashingMessagesProvider.notifier)
.update((set) => set.difference({message.id}));
}
});
} else {
flashTimer.value?.cancel();
flashTimer.value = null;
isFlashing.value = false;
}
return () {
flashTimer.value?.cancel();
};
}, [flashing]);
final flashColor =
isFlashing.value
? Theme.of(context).colorScheme.primary.withOpacity(0.8)
: containerColor;
final remoteMessage = message.toRemoteMessage();
final sender = remoteMessage.sender;
@@ -321,109 +377,98 @@ class MessageItemDisplayBubble extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: flashColor,
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (remoteMessage.repliedMessageId != null)
MessageQuoteWidget(
message: message,
textColor: textColor,
isReply: true,
).padding(vertical: 4),
if (remoteMessage.forwardedMessageId != null)
MessageQuoteWidget(
message: message,
textColor: textColor,
isReply: false,
).padding(vertical: 4),
if (MessageContent.hasContent(remoteMessage))
MessageContent(
item: remoteMessage,
translatedText: translatedText,
),
if (remoteMessage.attachments.isNotEmpty)
LayoutBuilder(
builder: (context, constraints) {
return CloudFileList(
files: remoteMessage.attachments,
maxWidth: constraints.maxWidth,
padding: EdgeInsets.symmetric(vertical: 4),
);
},
),
if (remoteMessage.meta['embeds'] != null)
...((remoteMessage.meta['embeds'] as List<dynamic>)
.map((embed) => convertMapKeysToSnakeCase(embed))
.where((embed) => embed['type'] == 'link')
.map((embed) => SnScrappedLink.fromJson(embed))
.map(
(link) => LayoutBuilder(
builder: (context, constraints) {
return EmbedLinkWidget(
link: link,
maxWidth: math.min(
constraints.maxWidth,
480,
),
margin: const EdgeInsets.symmetric(
vertical: 4,
),
);
},
),
)
.toList()),
if (progress != null && progress!.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
if ((remoteMessage.content?.isNotEmpty ?? false))
const Gap(0),
for (var entry in progress!.entries)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'fileUploadingProgress'.tr(
args: [
(entry.key + 1).toString(),
entry.value.toStringAsFixed(1),
],
),
style: TextStyle(
fontSize: 12,
color: textColor.withOpacity(0.8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (remoteMessage.repliedMessageId != null)
MessageQuoteWidget(
message: message,
textColor: textColor,
isReply: true,
).padding(vertical: 4),
if (remoteMessage.forwardedMessageId != null)
MessageQuoteWidget(
message: message,
textColor: textColor,
isReply: false,
).padding(vertical: 4),
if (MessageContent.hasContent(remoteMessage))
MessageContent(
item: remoteMessage,
translatedText: translatedText,
),
if (remoteMessage.attachments.isNotEmpty)
LayoutBuilder(
builder: (context, constraints) {
return CloudFileList(
files: remoteMessage.attachments,
maxWidth: constraints.maxWidth,
padding: EdgeInsets.symmetric(vertical: 4),
);
},
),
if (remoteMessage.meta['embeds'] != null)
...((remoteMessage.meta['embeds'] as List<dynamic>)
.map((embed) => convertMapKeysToSnakeCase(embed))
.where((embed) => embed['type'] == 'link')
.map((embed) => SnScrappedLink.fromJson(embed))
.map(
(link) => LayoutBuilder(
builder: (context, constraints) {
return EmbedLinkWidget(
link: link,
maxWidth: math.min(
constraints.maxWidth,
480,
),
const Gap(4),
LinearProgressIndicator(
value: entry.value / 100,
backgroundColor:
Theme.of(
context,
).colorScheme.surfaceVariant,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
margin: const EdgeInsets.symmetric(
vertical: 4,
),
],
),
);
},
),
)
.toList()),
if (progress != null && progress!.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
if ((remoteMessage.content?.isNotEmpty ?? false))
const Gap(0),
],
),
],
),
for (var entry in progress!.entries)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'fileUploadingProgress'.tr(
args: [
(entry.key + 1).toString(),
entry.value.toStringAsFixed(1),
],
),
style: TextStyle(
fontSize: 12,
color: textColor.withOpacity(0.8),
),
),
const Gap(4),
LinearProgressIndicator(
value: entry.value / 100,
backgroundColor:
Theme.of(
context,
).colorScheme.surfaceVariant,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
],
),
const Gap(0),
],
),
],
),
),
MessageIndicators(
@@ -467,24 +512,125 @@ class MessageItemDisplayIRC extends HookConsumerWidget {
final sender = remoteMessage.sender;
final textColor = Theme.of(context).colorScheme.onSurfaceVariant;
final isMultiline =
message.type == 'text' ||
message.repliedMessageId != null ||
message.forwardedMessageId != null;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: Row(
crossAxisAlignment:
isMultiline ? CrossAxisAlignment.start : CrossAxisAlignment.center,
children: [
Text(
DateFormat('HH:mm').format(message.createdAt),
style: TextStyle(color: textColor.withOpacity(0.7), fontSize: 12),
).padding(top: isMultiline ? 2 : 0),
AccountPfcGestureDetector(
uname: sender.account.name,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ProfilePictureWidget(
file: sender.account.profile.picture,
radius: 8,
).padding(horizontal: 6, top: isMultiline ? 2 : 0),
Text(
sender.account.nick,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
const SizedBox(width: 8),
Text(
'<${sender.account.nick}>',
style: TextStyle(color: Colors.blue),
),
const SizedBox(width: 8),
const Gap(8),
Expanded(
child: Text(
translatedText ?? remoteMessage.content ?? '',
style: TextStyle(color: textColor),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (remoteMessage.repliedMessageId != null)
MessageQuoteWidget(
message: message,
textColor: textColor,
isReply: true,
).padding(vertical: 4),
if (remoteMessage.forwardedMessageId != null)
MessageQuoteWidget(
message: message,
textColor: textColor,
isReply: false,
).padding(vertical: 4),
if (MessageContent.hasContent(remoteMessage))
MessageContent(
item: remoteMessage,
translatedText: translatedText,
),
if (remoteMessage.attachments.isNotEmpty)
LayoutBuilder(
builder: (context, constraints) {
return CloudFileList(
files: remoteMessage.attachments,
maxWidth: constraints.maxWidth,
padding: EdgeInsets.symmetric(vertical: 4),
);
},
),
if (remoteMessage.meta['embeds'] != null)
...((remoteMessage.meta['embeds'] as List<dynamic>)
.map((embed) => convertMapKeysToSnakeCase(embed))
.where((embed) => embed['type'] == 'link')
.map((embed) => SnScrappedLink.fromJson(embed))
.map(
(link) => LayoutBuilder(
builder: (context, constraints) {
return EmbedLinkWidget(
link: link,
maxWidth: math.min(constraints.maxWidth, 480),
margin: const EdgeInsets.symmetric(vertical: 4),
);
},
),
)
.toList()),
if (progress != null && progress!.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
if ((remoteMessage.content?.isNotEmpty ?? false))
const SizedBox.shrink(),
for (var entry in progress!.entries)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'fileUploadingProgress'.tr(
args: [
(entry.key + 1).toString(),
entry.value.toStringAsFixed(1),
],
),
style: TextStyle(
fontSize: 12,
color: textColor.withOpacity(0.8),
),
),
const Gap(4),
LinearProgressIndicator(
value: entry.value / 100,
backgroundColor:
Theme.of(context).colorScheme.surfaceVariant,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
],
),
],
),
],
),
),
],
@@ -635,7 +781,6 @@ class MessageItemDisplayDiscord extends HookConsumerWidget {
),
],
),
const Gap(0),
],
),
],

View File

@@ -18,7 +18,7 @@ import "package:material_symbols_icons/material_symbols_icons.dart";
import "package:styled_widget/styled_widget.dart";
import "package:super_sliver_list/super_sliver_list.dart";
import "package:material_symbols_icons/symbols.dart";
import "package:riverpod_annotation/riverpod_annotation.dart";
import "package:island/screens/chat/chat.dart";
class PublicRoomPreview extends HookConsumerWidget {

View File

@@ -470,7 +470,8 @@ class AttachmentPreview extends HookConsumerWidget {
if (onRequestUpload != null)
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => onRequestUpload?.call(),
onTap:
item.isOnCloud ? null : () => onRequestUpload?.call(),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(

View File

@@ -1,15 +1,12 @@
import 'dart:convert';
import 'dart:io';
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:file_saver/file_saver.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:gal/gal.dart';
@@ -17,11 +14,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/utils/format.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/file_info_sheet.dart';
import 'package:island/widgets/content/sensitive.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' show extension;
import 'package:path_provider/path_provider.dart';
@@ -361,284 +357,11 @@ class CloudFileZoomIn extends HookConsumerWidget {
}
void showInfoSheet() {
final theme = Theme.of(context);
final exifData = item.fileMeta?['exif'] as Map<String, dynamic>? ?? {};
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: 'File Information',
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('File hash copied to clipboard');
},
),
),
],
).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('File ID copied to clipboard');
},
),
),
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('File name copied to clipboard');
},
),
),
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('Value copied to clipboard');
},
),
),
],
),
],
),
),
],
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(
'File Metadata',
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('Value copied to clipboard');
},
),
),
],
),
],
),
),
],
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(
'User Metadata',
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('Value copied to clipboard');
},
),
),
],
),
],
),
),
],
const SizedBox(height: 16),
],
),
),
),
builder: (context) => FileInfoSheet(item: item),
);
}

View File

@@ -0,0 +1,280 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:island/models/file.dart';
import 'package:island/utils/format.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class FileInfoSheet extends StatelessWidget {
final SnCloudFile item;
const FileInfoSheet({super.key, required this.item});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final exifData = item.fileMeta?['exif'] as Map<String, dynamic>? ?? {};
return SheetScaffold(
titleText: 'File Information',
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('File hash copied to clipboard');
},
),
),
],
).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('File ID copied to clipboard');
},
),
),
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('File name copied to clipboard');
},
),
),
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('Value copied to clipboard');
},
),
),
],
),
],
),
),
],
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(
'File Metadata',
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('Value copied to clipboard');
},
),
),
],
),
],
),
),
],
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(
'User Metadata',
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('Value copied to clipboard');
},
),
),
],
),
],
),
),
],
const SizedBox(height: 16),
],
),
),
);
}
}

View File

@@ -64,6 +64,7 @@ class DebugSheet extends HookConsumerWidget {
return SheetScaffold(
titleText: 'Debug',
heightFactor: 0.6,
child: Column(
children: [
ListTile(

View File

@@ -30,10 +30,13 @@ class ComposeEmbedSheet extends HookConsumerWidget {
final selectedRenderer = useState<PostEmbedViewRenderer>(
PostEmbedViewRenderer.webView,
);
final tabController = useTabController(initialLength: 2);
final iframeController = useTextEditingController();
void clearForm() {
uriController.clear();
aspectRatioController.clear();
iframeController.clear();
selectedRenderer.value = PostEmbedViewRenderer.webView;
}
@@ -77,6 +80,57 @@ class ComposeEmbedSheet extends HookConsumerWidget {
}
}
void parseIframe() {
final iframe = iframeController.text.trim();
if (iframe.isEmpty) return;
final srcMatch = RegExp(r'src="([^"]*)"').firstMatch(iframe);
final widthMatch = RegExp(r'width="([^"]*)"').firstMatch(iframe);
final heightMatch = RegExp(r'height="([^"]*)"').firstMatch(iframe);
if (srcMatch != null) {
uriController.text = srcMatch.group(1)!;
}
if (widthMatch != null && heightMatch != null) {
final w = double.tryParse(widthMatch.group(1)!);
final h = double.tryParse(heightMatch.group(1)!);
if (w != null && h != null && h != 0) {
aspectRatioController.text = (w / h).toStringAsFixed(3);
}
}
tabController.animateTo(1);
}
void deleteEmbed(BuildContext context) {
showDialog(
context: context,
builder:
(dialogContext) => AlertDialog(
title: Text('deleteEmbed').tr(),
content: Text('deleteEmbedConfirm').tr(),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text('cancel').tr(),
),
TextButton(
onPressed: () {
ComposeLogic.deleteEmbedView(state);
clearForm();
Navigator.of(dialogContext).pop();
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: Text('delete').tr(),
),
],
),
);
}
return SheetScaffold(
titleText: 'embedView'.tr(),
heightFactor: 0.7,
@@ -85,7 +139,7 @@ class ComposeEmbedSheet extends HookConsumerWidget {
// Header with save button when editing
if (currentEmbedView != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Row(
children: [
@@ -97,187 +151,207 @@ class ComposeEmbedSheet extends HookConsumerWidget {
),
TextButton(
onPressed: saveEmbedView,
style: ButtonStyle(visualDensity: VisualDensity.compact),
child: Text('save'.tr()),
),
],
),
),
// Tab bar
TabBar(
controller: tabController,
tabs: [Tab(text: 'auto'.tr()), Tab(text: 'manual'.tr())],
),
// Content area
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Form fields
TextField(
controller: uriController,
decoration: InputDecoration(
labelText: 'embedUri'.tr(),
hintText: 'https://example.com',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
child: TabBarView(
controller: tabController,
children: [
// Auto tab
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: iframeController,
decoration: InputDecoration(
labelText: 'iframeCode'.tr(),
hintText: 'iframeCodeHint'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
maxLines: 5,
),
),
keyboardType: TextInputType.url,
),
const Gap(16),
TextField(
controller: aspectRatioController,
decoration: InputDecoration(
labelText: 'aspectRatio'.tr(),
hintText: '16/9 = 1.777',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
const Gap(16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: parseIframe,
icon: const Icon(Symbols.auto_fix),
label: Text('parseIframe'.tr()),
),
),
),
keyboardType: TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')),
],
),
const Gap(16),
DropdownButtonFormField2<PostEmbedViewRenderer>(
value: selectedRenderer.value,
decoration: InputDecoration(
labelText: 'renderer'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
items:
PostEmbedViewRenderer.values.map((renderer) {
return DropdownMenuItem(
value: renderer,
child: Text(renderer.name).tr(),
);
}).toList(),
onChanged: (value) {
if (value != null) {
selectedRenderer.value = value;
}
},
),
// Current embed view display (when exists)
if (currentEmbedView != null) ...[
const Gap(32),
Text(
'currentEmbed'.tr(),
style: theme.textTheme.titleMedium,
).padding(horizontal: 4),
const Gap(8),
Card(
margin: EdgeInsets.zero,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 12,
top: 4,
),
// Manual tab
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Form fields
TextField(
controller: uriController,
decoration: InputDecoration(
labelText: 'embedUri'.tr(),
hintText: 'https://example.com',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
keyboardType: TextInputType.url,
),
const Gap(16),
TextField(
controller: aspectRatioController,
decoration: InputDecoration(
labelText: 'aspectRatio'.tr(),
hintText: '16/9 = 1.777',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
keyboardType: TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d*\.?\d*$'),
),
],
),
const Gap(16),
DropdownButtonFormField2<PostEmbedViewRenderer>(
value: selectedRenderer.value,
decoration: InputDecoration(
labelText: 'renderer'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
selectedItemBuilder: (context) {
return PostEmbedViewRenderer.values.map((renderer) {
return Text(renderer.name).tr();
}).toList();
},
menuItemStyleData: MenuItemStyleData(
padding: EdgeInsets.zero,
),
items:
PostEmbedViewRenderer.values.map((renderer) {
return DropdownMenuItem(
value: renderer,
child: Text(
renderer.name,
).tr().padding(horizontal: 20),
);
}).toList(),
onChanged: (value) {
if (value != null) {
selectedRenderer.value = value;
}
},
),
// Current embed view display (when exists)
if (currentEmbedView != null) ...[
const Gap(32),
Text(
'currentEmbed'.tr(),
style: theme.textTheme.titleMedium,
).padding(horizontal: 4),
const Gap(8),
Card(
margin: EdgeInsets.zero,
color:
Theme.of(
context,
).colorScheme.surfaceContainerHigh,
child: Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 12,
top: 4,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
currentEmbedView.renderer ==
PostEmbedViewRenderer.webView
? Symbols.web
: Symbols.web,
color: colorScheme.primary,
Row(
children: [
Icon(
currentEmbedView.renderer ==
PostEmbedViewRenderer.webView
? Symbols.web
: Symbols.web,
color: colorScheme.primary,
),
const Gap(12),
Expanded(
child: Text(
currentEmbedView.uri,
style: theme.textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Symbols.delete),
onPressed: () => deleteEmbed(context),
tooltip: 'delete'.tr(),
color: colorScheme.error,
),
],
),
const Gap(12),
Expanded(
child: Text(
currentEmbedView.uri,
style: theme.textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
Text(
'aspectRatio'.tr(),
style: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
IconButton(
icon: const Icon(Symbols.delete),
onPressed: () {
showDialog(
context: context,
builder:
(dialogContext) => AlertDialog(
title: Text('deleteEmbed').tr(),
content:
Text('deleteEmbedConfirm').tr(),
actions: [
TextButton(
onPressed:
() =>
Navigator.of(
dialogContext,
).pop(),
child: Text('cancel'.tr()),
),
TextButton(
onPressed: () {
ComposeLogic.deleteEmbedView(
state,
);
clearForm();
Navigator.of(
dialogContext,
).pop();
},
style: TextButton.styleFrom(
foregroundColor:
colorScheme.error,
),
child: Text('delete').tr(),
),
],
),
);
},
tooltip: 'delete'.tr(),
color: colorScheme.error,
const Gap(4),
Text(
currentEmbedView.aspectRatio != null
? currentEmbedView.aspectRatio!
.toStringAsFixed(2)
: 'notSet'.tr(),
style: theme.textTheme.bodyMedium,
),
],
),
const Gap(12),
Text(
'aspectRatio'.tr(),
style: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const Gap(4),
Text(
currentEmbedView.aspectRatio != null
? currentEmbedView.aspectRatio!
.toStringAsFixed(2)
: 'notSet'.tr(),
style: theme.textTheme.bodyMedium,
),
],
),
),
),
),
] else ...[
// Save button for new embed
const Gap(16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: saveEmbedView,
icon: const Icon(Symbols.add),
label: Text('addEmbed'.tr()),
),
),
],
],
),
] else ...[
const Gap(16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: saveEmbedView,
icon: const Icon(Symbols.add),
label: Text('addEmbed'.tr()),
),
),
],
],
),
),
],
),
),
],

View File

@@ -20,7 +20,7 @@ import 'package:island/widgets/alert.dart';
import 'package:island/widgets/post/compose_link_attachments.dart';
import 'package:island/widgets/post/compose_poll.dart';
import 'package:island/widgets/post/compose_recorder.dart';
import 'package:island/pods/pool_provider.dart';
import 'package:island/pods/file_pool.dart';
import 'package:pasteboard/pasteboard.dart';
import 'package:textfield_tags/textfield_tags.dart';
import 'dart:async';
@@ -672,7 +672,7 @@ class ComposeLogic {
try {
state.submitting.value = true;
// Upload any local attachments first
// pload any local attachments first
await Future.wait(
state.attachments.value
.asMap()

View File

@@ -82,75 +82,83 @@ class ComposeToolbar extends HookConsumerWidget {
constraints: const BoxConstraints(maxWidth: 560),
child: Row(
children: [
IconButton(
onPressed: pickPhotoMedia,
tooltip: 'addPhoto'.tr(),
icon: const Icon(Symbols.add_a_photo),
color: colorScheme.primary,
),
IconButton(
onPressed: pickVideoMedia,
tooltip: 'addVideo'.tr(),
icon: const Icon(Symbols.videocam),
color: colorScheme.primary,
),
IconButton(
onPressed: addAudio,
tooltip: 'addAudio'.tr(),
icon: const Icon(Symbols.mic),
color: colorScheme.primary,
),
IconButton(
onPressed: pickGeneralFile,
tooltip: 'uploadFile'.tr(),
icon: const Icon(Symbols.file_upload),
color: colorScheme.primary,
),
IconButton(
onPressed: linkAttachment,
icon: const Icon(Symbols.attach_file),
tooltip: 'linkAttachment'.tr(),
color: colorScheme.primary,
),
// Poll button with visual state when a poll is linked
ListenableBuilder(
listenable: state.pollId,
builder: (context, _) {
return IconButton(
onPressed: pickPoll,
icon: const Icon(Symbols.how_to_vote),
tooltip: 'poll'.tr(),
color: colorScheme.primary,
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
state.pollId.value != null
? colorScheme.primary.withOpacity(0.15)
: null,
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
IconButton(
onPressed: pickPhotoMedia,
tooltip: 'addPhoto'.tr(),
icon: const Icon(Symbols.add_a_photo),
color: colorScheme.primary,
),
),
);
},
),
// Embed button with visual state when embed is present
ListenableBuilder(
listenable: state.embedView,
builder: (context, _) {
return IconButton(
onPressed: showEmbedSheet,
icon: const Icon(Symbols.web),
tooltip: 'embedView'.tr(),
color: colorScheme.primary,
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
state.embedView.value != null
? colorScheme.primary.withOpacity(0.15)
: null,
IconButton(
onPressed: pickVideoMedia,
tooltip: 'addVideo'.tr(),
icon: const Icon(Symbols.videocam),
color: colorScheme.primary,
),
),
);
},
IconButton(
onPressed: addAudio,
tooltip: 'addAudio'.tr(),
icon: const Icon(Symbols.mic),
color: colorScheme.primary,
),
IconButton(
onPressed: pickGeneralFile,
tooltip: 'uploadFile'.tr(),
icon: const Icon(Symbols.file_upload),
color: colorScheme.primary,
),
IconButton(
onPressed: linkAttachment,
icon: const Icon(Symbols.attach_file),
tooltip: 'linkAttachment'.tr(),
color: colorScheme.primary,
),
// Poll button with visual state when a poll is linked
ListenableBuilder(
listenable: state.pollId,
builder: (context, _) {
return IconButton(
onPressed: pickPoll,
icon: const Icon(Symbols.how_to_vote),
tooltip: 'poll'.tr(),
color: colorScheme.primary,
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
state.pollId.value != null
? colorScheme.primary.withOpacity(0.15)
: null,
),
),
);
},
),
// Embed button with visual state when embed is present
ListenableBuilder(
listenable: state.embedView,
builder: (context, _) {
return IconButton(
onPressed: showEmbedSheet,
icon: const Icon(Symbols.iframe),
tooltip: 'embedView'.tr(),
color: colorScheme.primary,
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
state.embedView.value != null
? colorScheme.primary.withOpacity(0.15)
: null,
),
),
);
},
),
],
),
),
),
const Spacer(),
if (originalPost == null && state.isEmpty)
IconButton(
icon: const Icon(Symbols.draft),

View File

@@ -1,3 +1,6 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
@@ -58,8 +61,8 @@ class EmbedViewRenderer extends HookConsumerWidget {
children: [
Icon(
embedView.renderer == PostEmbedViewRenderer.webView
? Symbols.web
: Symbols.web,
? Symbols.globe
: Symbols.iframe,
size: 16,
color: colorScheme.primary,
),
@@ -74,13 +77,13 @@ class EmbedViewRenderer extends HookConsumerWidget {
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: Icon(
InkWell(
child: Icon(
Symbols.open_in_new,
size: 16,
color: colorScheme.onSurfaceVariant,
),
onPressed: () async {
onTap: () async {
final uri = Uri.parse(embedView.uri);
if (await canLaunchUrl(uri)) {
await launchUrl(
@@ -89,10 +92,6 @@ class EmbedViewRenderer extends HookConsumerWidget {
);
}
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
visualDensity: VisualDensity.compact,
tooltip: 'Open in browser',
),
],
),
@@ -106,6 +105,20 @@ class EmbedViewRenderer extends HookConsumerWidget {
? Stack(
children: [
InAppWebView(
gestureRecognizers: {
Factory<VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(),
),
Factory<HorizontalDragGestureRecognizer>(
() => HorizontalDragGestureRecognizer(),
),
Factory<ScaleGestureRecognizer>(
() => ScaleGestureRecognizer(),
),
Factory<TapGestureRecognizer>(
() => TapGestureRecognizer(),
),
},
initialUrlRequest: URLRequest(
url: WebUri(embedView.uri),
),
@@ -256,14 +269,14 @@ class EmbedViewRenderer extends HookConsumerWidget {
children: [
Icon(
Symbols.play_arrow,
fill: 1,
size: 48,
color: colorScheme.onSurfaceVariant.withOpacity(
0.6,
),
),
const SizedBox(height: 8),
Text(
'Tap to load content',
'viewEmbedLoadHint'.tr(),
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant
.withOpacity(0.6),

View File

@@ -31,6 +31,36 @@ import 'package:share_plus/share_plus.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:super_context_menu/super_context_menu.dart';
const kAvailableStickers = {
'angry',
'clap',
'confuse',
'pray',
'thumb_up',
'party',
};
bool _getReactionImageAvailable(String symbol) {
return kAvailableStickers.contains(symbol);
}
Widget _buildReactionIcon(String symbol, double size, {double iconSize = 24}) {
if (_getReactionImageAvailable(symbol)) {
return Image.asset(
'assets/images/stickers/$symbol.png',
width: size,
height: size,
fit: BoxFit.contain,
alignment: Alignment.bottomCenter,
);
} else {
return Text(
kReactionTemplates[symbol]?.icon ?? '',
style: TextStyle(fontSize: iconSize),
);
}
}
class PostActionableItem extends HookConsumerWidget {
final SnPost item;
final EdgeInsets? padding;
@@ -490,57 +520,66 @@ class PostItem extends HookConsumerWidget {
trailing:
isCompact
? null
: IconButton(
icon:
mostReaction == null
? const Icon(Symbols.add_reaction)
: Badge(
label: Center(
child: Text(
'x${item.reactionsCount[mostReaction]}',
style: const TextStyle(fontSize: 11),
textAlign: TextAlign.center,
: SizedBox(
width: 36,
height: 36,
child: IconButton(
icon:
mostReaction == null
? const Icon(Symbols.add_reaction)
: Badge(
label: Center(
child: Text(
'x${item.reactionsCount[mostReaction]}',
style: const TextStyle(fontSize: 11),
textAlign: TextAlign.center,
),
),
offset: const Offset(4, 20),
backgroundColor: Theme.of(
context,
).colorScheme.primary.withOpacity(0.75),
textColor:
Theme.of(context).colorScheme.onPrimary,
child: _buildReactionIcon(
mostReaction,
32,
).padding(
bottom:
_getReactionImageAvailable(mostReaction)
? 2
: 0,
),
),
offset: const Offset(4, 20),
backgroundColor: Theme.of(
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
(item.reactionsMade[mostReaction] ?? false)
? Theme.of(
context,
).colorScheme.primary.withOpacity(0.75),
textColor:
Theme.of(context).colorScheme.onPrimary,
child: Text(
kReactionTemplates[mostReaction]?.icon ?? '',
style: const TextStyle(fontSize: 20),
),
),
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
(item.reactionsMade[mostReaction] ?? false)
? Theme.of(
context,
).colorScheme.primary.withOpacity(0.5)
: null,
).colorScheme.primary.withOpacity(0.5)
: null,
),
),
onPressed: () {
showModalBottomSheet(
context: context,
useRootNavigator: true,
builder: (BuildContext context) {
return _PostReactionSheet(
reactionsCount: item.reactionsCount,
reactionsMade: item.reactionsMade,
onReact: (symbol, attitude) {
reactPost(symbol, attitude);
},
);
},
);
},
padding: EdgeInsets.zero,
visualDensity: const VisualDensity(
horizontal: -3,
vertical: -3,
),
),
onPressed: () {
showModalBottomSheet(
context: context,
useRootNavigator: true,
builder: (BuildContext context) {
return _PostReactionSheet(
reactionsCount: item.reactionsCount,
reactionsMade: item.reactionsMade,
onReact: (symbol, attitude) {
reactPost(symbol, attitude);
},
);
},
);
},
padding: EdgeInsets.zero,
visualDensity: const VisualDensity(
horizontal: -3,
vertical: -3,
),
),
),
@@ -611,7 +650,7 @@ class PostReactionList extends HookConsumerWidget {
}
return SizedBox(
height: 28,
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
padding: padding ?? EdgeInsets.zero,
@@ -649,7 +688,7 @@ class PostReactionList extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.only(right: 8),
child: ActionChip(
avatar: Text(kReactionTemplates[symbol]?.icon ?? '?'),
avatar: _buildReactionIcon(symbol, 24),
label: Row(
spacing: 4,
children: [
@@ -786,37 +825,96 @@ class _PostReactionSheet extends StatelessWidget {
itemBuilder: (context, index) {
final symbol = allReactions[index];
final count = reactionsCount[symbol] ?? 0;
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
color:
(reactionsMade[symbol] ?? false)
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerLowest,
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)),
onTap: () {
onReact(symbol, attitude);
Navigator.pop(context);
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
kReactionTemplates[symbol]?.icon ?? '',
textAlign: TextAlign.center,
).fontSize(24),
Text(
ReactInfo.getTranslationKey(symbol),
textAlign: TextAlign.center,
).tr(),
if (count > 0)
Text(
'x$count',
textAlign: TextAlign.center,
).bold().padding(bottom: 4)
else
const Gap(20),
],
final hasImage = _getReactionImageAvailable(symbol);
return Badge(
label: Text('x$count'),
isLabelVisible: count > 0,
textColor: Theme.of(context).colorScheme.onPrimary,
backgroundColor: Theme.of(context).colorScheme.primary,
offset: Offset(0, 0),
child: Card(
margin: const EdgeInsets.symmetric(vertical: 4),
color: Theme.of(context).colorScheme.surfaceContainerLowest,
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)),
onTap: () {
onReact(symbol, attitude);
Navigator.pop(context);
},
child: Container(
decoration:
hasImage
? BoxDecoration(
borderRadius: BorderRadius.circular(8),
image: DecorationImage(
image: AssetImage(
'assets/images/stickers/$symbol.png',
),
fit: BoxFit.cover,
colorFilter:
(reactionsMade[symbol] ?? false)
? ColorFilter.mode(
Theme.of(context)
.colorScheme
.primaryContainer
.withOpacity(0.7),
BlendMode.srcATop,
)
: null,
),
)
: null,
child: Stack(
fit: StackFit.expand,
children: [
if (hasImage)
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withOpacity(0.7),
Colors.transparent,
],
stops: [0.0, 0.3],
),
),
),
Column(
mainAxisAlignment:
hasImage
? MainAxisAlignment.end
: MainAxisAlignment.center,
children: [
if (!hasImage) _buildReactionIcon(symbol, 36),
Text(
ReactInfo.getTranslationKey(symbol),
textAlign: TextAlign.center,
style: TextStyle(
color: hasImage ? Colors.white : null,
shadows:
hasImage
? [
const Shadow(
blurRadius: 4,
offset: Offset(0.5, 0.5),
color: Colors.black,
),
]
: null,
),
).tr(),
if (hasImage) const Gap(4),
],
),
],
),
),
),
),
);

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 3.2.0+132
version: 3.2.0+133
environment:
sdk: ^3.7.2
@@ -189,6 +189,7 @@ flutter:
- assets/i18n/
- assets/images/
- assets/images/oidc/
- assets/images/stickers/
- assets/icons/
# An image asset can refer to one or more resolution-specific "variants", see