Compare commits
17 Commits
b0f3b6b5c3
...
3.2.0+133
Author | SHA1 | Date | |
---|---|---|---|
457d1bac60
|
|||
02ec11845b
|
|||
612f1bf004
|
|||
fd80b713ad
|
|||
508805368c
|
|||
98eb28a4ec
|
|||
d1a2f59dd1
|
|||
bb9adb963a
|
|||
83e40cd860
|
|||
c06fb12f6a
|
|||
6600cf4df8
|
|||
4293daaa2f
|
|||
866674ddde
|
|||
27d478ba4f
|
|||
cccade763f
|
|||
f760b85186
|
|||
e68c5f4f92
|
@@ -336,6 +336,18 @@
|
|||||||
"levelingProgress": "Leveling Progress",
|
"levelingProgress": "Leveling Progress",
|
||||||
"levelingProgressExperience": "{} EXP",
|
"levelingProgressExperience": "{} EXP",
|
||||||
"levelingProgressLevel": "Level {}",
|
"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 #{}: {}%",
|
"fileUploadingProgress": "Uploading file #{}: {}%",
|
||||||
"removeChatMember": "Remove Chat Room Member",
|
"removeChatMember": "Remove Chat Room Member",
|
||||||
"removeChatMemberHint": "Are you sure to remove this member from the room?",
|
"removeChatMemberHint": "Are you sure to remove this member from the room?",
|
||||||
@@ -896,6 +908,15 @@
|
|||||||
"attachmentOnDevice": "On-device",
|
"attachmentOnDevice": "On-device",
|
||||||
"attachmentOnCloud": "On-cloud",
|
"attachmentOnCloud": "On-cloud",
|
||||||
"attachments": "Attachments",
|
"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",
|
"publisherCollabInvitation": "Collabration invitations",
|
||||||
"publisherCollabInvitationCount": {
|
"publisherCollabInvitationCount": {
|
||||||
"zero": "No invitation",
|
"zero": "No invitation",
|
||||||
@@ -1012,6 +1033,11 @@
|
|||||||
"expandPoll": "Expand Poll",
|
"expandPoll": "Expand Poll",
|
||||||
"collapsePoll": "Collapse Poll",
|
"collapsePoll": "Collapse Poll",
|
||||||
"embedView": "Embed View",
|
"embedView": "Embed View",
|
||||||
|
"auto": "Auto",
|
||||||
|
"manual": "Manual",
|
||||||
|
"iframeCode": "Iframe Code",
|
||||||
|
"iframeCodeHint": "<iframe src=\"...\" width=\"...\" height=\"...\">",
|
||||||
|
"parseIframe": "Parse Iframe",
|
||||||
"embedUri": "Embed URI",
|
"embedUri": "Embed URI",
|
||||||
"aspectRatio": "Aspect Ratio",
|
"aspectRatio": "Aspect Ratio",
|
||||||
"renderer": "Renderer",
|
"renderer": "Renderer",
|
||||||
@@ -1023,5 +1049,18 @@
|
|||||||
"noEmbed": "No embed yet",
|
"noEmbed": "No embed yet",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"webView": "Web View",
|
"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"
|
||||||
}
|
}
|
||||||
|
@@ -283,6 +283,18 @@
|
|||||||
"levelingProgress": "等级进度",
|
"levelingProgress": "等级进度",
|
||||||
"levelingProgressExperience": "{} 经验值",
|
"levelingProgressExperience": "{} 经验值",
|
||||||
"levelingProgressLevel": "等级 {}",
|
"levelingProgressLevel": "等级 {}",
|
||||||
|
"levelingStage1": "新手",
|
||||||
|
"levelingStage2": "学徒",
|
||||||
|
"levelingStage3": "熟练工",
|
||||||
|
"levelingStage4": "行家",
|
||||||
|
"levelingStage5": "专家",
|
||||||
|
"levelingStage6": "大师",
|
||||||
|
"levelingStage7": "宗师",
|
||||||
|
"levelingStage8": "传奇",
|
||||||
|
"levelingStage9": "神话",
|
||||||
|
"levelingStage10": "不朽",
|
||||||
|
"levelingStage11": "神圣",
|
||||||
|
"levelingStage12": "超凡",
|
||||||
"fileUploadingProgress": "正在上传文件 #{}: {}%",
|
"fileUploadingProgress": "正在上传文件 #{}: {}%",
|
||||||
"removeChatMember": "移除聊天室成员",
|
"removeChatMember": "移除聊天室成员",
|
||||||
"removeChatMemberHint": "确定要将此成员从聊天室中移除吗?",
|
"removeChatMemberHint": "确定要将此成员从聊天室中移除吗?",
|
||||||
|
BIN
assets/images/stickers/angry.png
Normal file
BIN
assets/images/stickers/angry.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.0 MiB |
BIN
assets/images/stickers/clap.png
Normal file
BIN
assets/images/stickers/clap.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.0 MiB |
BIN
assets/images/stickers/confuse.png
Normal file
BIN
assets/images/stickers/confuse.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 668 KiB |
BIN
assets/images/stickers/party.png
Normal file
BIN
assets/images/stickers/party.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 MiB |
BIN
assets/images/stickers/pray.png
Normal file
BIN
assets/images/stickers/pray.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 666 KiB |
BIN
assets/images/stickers/thumb_up.png
Normal file
BIN
assets/images/stickers/thumb_up.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 623 KiB |
@@ -148,7 +148,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
..where((m) => m.roomId.equals(roomId));
|
..where((m) => m.roomId.equals(roomId));
|
||||||
|
|
||||||
if (query.isNotEmpty) {
|
if (query.isNotEmpty) {
|
||||||
final searchTerm = '%${query}%';
|
final searchTerm = '%$query%';
|
||||||
selectStatement =
|
selectStatement =
|
||||||
selectStatement..where(
|
selectStatement..where(
|
||||||
(m) =>
|
(m) =>
|
||||||
|
@@ -395,7 +395,12 @@ final rpcServerStateProvider =
|
|||||||
final label = data['args']['activity']['details'] ?? 'Unknown';
|
final label = data['args']['activity']['details'] ?? 'Unknown';
|
||||||
final appId = socket.clientId;
|
final appId = socket.clientId;
|
||||||
try {
|
try {
|
||||||
await setRemoteActivityStatus(ref, label, appId);
|
await setRemoteActivityStatus(
|
||||||
|
ref,
|
||||||
|
label,
|
||||||
|
appId,
|
||||||
|
data['args']['activity'],
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
developer.log(
|
developer.log(
|
||||||
'Failed to set remote activity status: $e',
|
'Failed to set remote activity status: $e',
|
||||||
@@ -435,6 +440,7 @@ Future<void> setRemoteActivityStatus(
|
|||||||
Ref ref,
|
Ref ref,
|
||||||
String label,
|
String label,
|
||||||
String appId,
|
String appId,
|
||||||
|
Map<String, dynamic> meta,
|
||||||
) async {
|
) async {
|
||||||
final apiClient = ref.read(apiClientProvider);
|
final apiClient = ref.read(apiClientProvider);
|
||||||
await apiClient.post(
|
await apiClient.post(
|
||||||
@@ -445,6 +451,7 @@ Future<void> setRemoteActivityStatus(
|
|||||||
'is_automated': true,
|
'is_automated': true,
|
||||||
'label': label,
|
'label': label,
|
||||||
'app_identifier': appId,
|
'app_identifier': appId,
|
||||||
|
'meta': meta,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -16,7 +16,7 @@ import "package:island/widgets/alert.dart";
|
|||||||
import "package:riverpod_annotation/riverpod_annotation.dart";
|
import "package:riverpod_annotation/riverpod_annotation.dart";
|
||||||
import "package:uuid/uuid.dart";
|
import "package:uuid/uuid.dart";
|
||||||
import "package:island/screens/chat/chat.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';
|
part 'messages_notifier.g.dart';
|
||||||
|
|
||||||
|
@@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/screens/about.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/app_detail.dart';
|
||||||
import 'package:island/screens/developers/bot_detail.dart';
|
import 'package:island/screens/developers/bot_detail.dart';
|
||||||
import 'package:island/screens/developers/edit_app.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/new_project.dart';
|
||||||
import 'package:island/screens/developers/project_detail.dart';
|
import 'package:island/screens/developers/project_detail.dart';
|
||||||
import 'package:island/screens/discovery/articles.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_categories_list.dart';
|
||||||
import 'package:island/screens/posts/post_category_detail.dart';
|
import 'package:island/screens/posts/post_category_detail.dart';
|
||||||
import 'package:island/screens/posts/post_search.dart';
|
import 'package:island/screens/posts/post_search.dart';
|
||||||
@@ -656,9 +656,9 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
builder: (context, state) => const WalletScreen(),
|
builder: (context, state) => const WalletScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
name: 'socialCredits',
|
name: 'files',
|
||||||
path: '/account/credits',
|
path: '/account/files',
|
||||||
builder: (context, state) => const SocialCreditsScreen(),
|
builder: (context, state) => const FileListScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
name: 'relationships',
|
name: 'relationships',
|
||||||
|
@@ -141,20 +141,22 @@ class AccountScreen extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
).padding(horizontal: 8),
|
).padding(horizontal: 8),
|
||||||
GestureDetector(
|
LevelingProgressCard(
|
||||||
child: LevelingProgressCard(
|
isCompact: true,
|
||||||
level: user.value!.profile.level,
|
level: user.value!.profile.level,
|
||||||
experience: user.value!.profile.experience,
|
experience: user.value!.profile.experience,
|
||||||
progress: user.value!.profile.levelingProgress,
|
progress: user.value!.profile.levelingProgress,
|
||||||
),
|
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.pushNamed('leveling');
|
context.pushNamed('leveling');
|
||||||
},
|
},
|
||||||
).padding(horizontal: 12),
|
).padding(horizontal: 12),
|
||||||
|
const SizedBox.shrink(),
|
||||||
Row(
|
Row(
|
||||||
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Card(
|
child: Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -181,6 +183,7 @@ class AccountScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Card(
|
child: Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -206,7 +209,73 @@ class AccountScreen extends HookConsumerWidget {
|
|||||||
).height(140),
|
).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(
|
ListTile(
|
||||||
minTileHeight: 48,
|
minTileHeight: 48,
|
||||||
leading: const Icon(Symbols.notifications),
|
leading: const Icon(Symbols.notifications),
|
||||||
@@ -235,6 +304,16 @@ class AccountScreen extends HookConsumerWidget {
|
|||||||
context.pushNamed('wallet');
|
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(
|
ListTile(
|
||||||
minTileHeight: 48,
|
minTileHeight: 48,
|
||||||
leading: const Icon(Symbols.people),
|
leading: const Icon(Symbols.people),
|
||||||
@@ -265,16 +344,6 @@ class AccountScreen extends HookConsumerWidget {
|
|||||||
context.pushNamed('webFeedMarketplace');
|
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(
|
ListTile(
|
||||||
minTileHeight: 48,
|
minTileHeight: 48,
|
||||||
title: Text('abuseReport').tr(),
|
title: Text('abuseReport').tr(),
|
||||||
@@ -284,37 +353,6 @@ class AccountScreen extends HookConsumerWidget {
|
|||||||
onTap: () => context.pushNamed('reportList'),
|
onTap: () => context.pushNamed('reportList'),
|
||||||
),
|
),
|
||||||
const Divider(height: 1).padding(vertical: 8),
|
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(
|
ListTile(
|
||||||
minTileHeight: 48,
|
minTileHeight: 48,
|
||||||
leading: const Icon(Symbols.info),
|
leading: const Icon(Symbols.info),
|
||||||
@@ -333,6 +371,8 @@ class AccountScreen extends HookConsumerWidget {
|
|||||||
title: Text('debugOptions').tr(),
|
title: Text('debugOptions').tr(),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => DebugSheet(),
|
builder: (context) => DebugSheet(),
|
||||||
);
|
);
|
||||||
|
@@ -4,7 +4,6 @@ import 'package:gap/gap.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/models/account.dart';
|
import 'package:island/models/account.dart';
|
||||||
import 'package:island/pods/network.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:material_symbols_icons/material_symbols_icons.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||||
@@ -59,94 +58,93 @@ class SocialCreditHistoryNotifier extends _$SocialCreditHistoryNotifier
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SocialCreditsScreen extends HookConsumerWidget {
|
class SocialCreditsTab extends HookConsumerWidget {
|
||||||
const SocialCreditsScreen({super.key});
|
const SocialCreditsTab({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final socialCredits = ref.watch(socialCreditsProvider);
|
final socialCredits = ref.watch(socialCreditsProvider);
|
||||||
|
return Column(
|
||||||
return AppScaffold(
|
children: [
|
||||||
appBar: AppBar(title: Text('socialCredits').tr()),
|
const Gap(8),
|
||||||
body: Column(
|
Card(
|
||||||
children: [
|
margin: const EdgeInsets.only(left: 16, right: 16, top: 8),
|
||||||
Card(
|
child: socialCredits
|
||||||
margin: EdgeInsets.only(left: 16, right: 16, top: 8),
|
.when(
|
||||||
child: socialCredits
|
data:
|
||||||
.when(
|
(credits) => Stack(
|
||||||
data:
|
children: [
|
||||||
(credits) => Stack(
|
Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Text(
|
||||||
children: [
|
credits < 100
|
||||||
Text(
|
? 'socialCreditsLevelPoor'.tr()
|
||||||
credits < 100
|
: credits < 150
|
||||||
? 'socialCreditsLevelPoor'.tr()
|
? 'socialCreditsLevelNormal'.tr()
|
||||||
: credits < 150
|
: credits < 200
|
||||||
? 'socialCreditsLevelNormal'.tr()
|
? 'socialCreditsLevelGood'.tr()
|
||||||
: credits < 200
|
: 'socialCreditsLevelExcellent'.tr(),
|
||||||
? 'socialCreditsLevelGood'.tr()
|
).tr().bold().fontSize(20),
|
||||||
: 'socialCreditsLevelExcellent'.tr(),
|
Text(
|
||||||
).tr().bold().fontSize(20),
|
'${credits.toStringAsFixed(2)} pts',
|
||||||
Text(
|
).fontSize(14),
|
||||||
'${credits.toStringAsFixed(2)} pts',
|
const Gap(8),
|
||||||
).fontSize(14),
|
LinearProgressIndicator(value: credits / 200),
|
||||||
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(
|
error: (_, _) => Text('Error loading credits'),
|
||||||
onPressed: () {},
|
loading: () => const LinearProgressIndicator(),
|
||||||
icon: const Icon(Symbols.info),
|
)
|
||||||
tooltip: 'socialCreditsDescription'.tr(),
|
.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'),
|
title: Text(record.reason),
|
||||||
loading: () => const LinearProgressIndicator(),
|
subtitle: Text(
|
||||||
)
|
DateFormat.yMMMd().format(record.createdAt),
|
||||||
.padding(horizontal: 20, vertical: 16),
|
),
|
||||||
),
|
trailing: Text(
|
||||||
Expanded(
|
record.delta > 0
|
||||||
child: PagingHelperView(
|
? '+${record.delta}'
|
||||||
provider: socialCreditHistoryNotifierProvider,
|
: '${record.delta}',
|
||||||
futureRefreshable: socialCreditHistoryNotifierProvider.future,
|
style: TextStyle(
|
||||||
notifierRefreshable: socialCreditHistoryNotifierProvider.notifier,
|
color: record.delta > 0 ? Colors.green : Colors.red,
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
trailing: Text(
|
),
|
||||||
record.delta > 0
|
);
|
||||||
? '+${record.delta}'
|
},
|
||||||
: '${record.delta}',
|
),
|
||||||
style: TextStyle(
|
|
||||||
color: record.delta > 0 ? Colors.green : Colors.red,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,12 +4,12 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/models/account.dart';
|
import 'package:island/models/account.dart';
|
||||||
import 'package:island/models/wallet.dart';
|
import 'package:island/models/wallet.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
import 'package:island/pods/userinfo.dart';
|
import 'package:island/pods/userinfo.dart';
|
||||||
|
import 'package:island/screens/account/credits.dart';
|
||||||
import 'package:island/services/responsive.dart';
|
import 'package:island/services/responsive.dart';
|
||||||
import 'package:island/services/time.dart';
|
import 'package:island/services/time.dart';
|
||||||
import 'package:island/widgets/account/leveling_progress.dart';
|
import 'package:island/widgets/account/leveling_progress.dart';
|
||||||
@@ -89,7 +89,7 @@ class LevelingScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return DefaultTabController(
|
return DefaultTabController(
|
||||||
length: 2,
|
length: 3,
|
||||||
child: AppScaffold(
|
child: AppScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text('levelingProgress'.tr()),
|
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(
|
Tab(
|
||||||
child: Text(
|
child: Text(
|
||||||
'stellarProgram'.tr(),
|
'stellarProgram'.tr(),
|
||||||
@@ -119,6 +128,7 @@ class LevelingScreen extends HookConsumerWidget {
|
|||||||
body: TabBarView(
|
body: TabBarView(
|
||||||
children: [
|
children: [
|
||||||
_buildLevelingTab(context, ref, user.value!),
|
_buildLevelingTab(context, ref, user.value!),
|
||||||
|
const SocialCreditsTab(),
|
||||||
_buildStellarProgramTab(context, ref),
|
_buildStellarProgramTab(context, ref),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -164,10 +174,33 @@ class LevelingScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SliverGap(16),
|
const SliverGap(16),
|
||||||
|
|
||||||
// Stairs visualization with fixed height and horizontal scroll
|
SliverToBoxAdapter(
|
||||||
SliverToBoxAdapter(child: _buildLevelStairs(context, currentLevel)),
|
child: Card(
|
||||||
const SliverGap(24),
|
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
|
// Leveling History
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Text(
|
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(
|
Widget _buildMembershipSection(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
import "dart:async";
|
import "dart:async";
|
||||||
import "dart:convert";
|
import "dart:convert";
|
||||||
|
import "dart:typed_data";
|
||||||
|
import "package:cross_file/cross_file.dart";
|
||||||
import "package:easy_localization/easy_localization.dart";
|
import "package:easy_localization/easy_localization.dart";
|
||||||
import "package:file_picker/file_picker.dart";
|
import "package:file_picker/file_picker.dart";
|
||||||
import "package:flutter/material.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/database/message.dart";
|
||||||
import "package:island/models/chat.dart";
|
import "package:island/models/chat.dart";
|
||||||
import "package:island/models/file.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/messages_notifier.dart";
|
||||||
import "package:island/pods/network.dart";
|
import "package:island/pods/network.dart";
|
||||||
import "package:island/pods/websocket.dart";
|
import "package:island/pods/websocket.dart";
|
||||||
|
import "package:island/services/file.dart";
|
||||||
import "package:island/screens/chat/chat.dart";
|
import "package:island/screens/chat/chat.dart";
|
||||||
import "package:island/services/responsive.dart";
|
import "package:island/services/responsive.dart";
|
||||||
import "package:island/widgets/alert.dart";
|
import "package:island/widgets/alert.dart";
|
||||||
import "package:island/widgets/app_scaffold.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/call_overlay.dart";
|
||||||
import "package:island/widgets/chat/message_item.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/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:island/widgets/response.dart";
|
||||||
import "package:material_symbols_icons/material_symbols_icons.dart";
|
import "package:material_symbols_icons/material_symbols_icons.dart";
|
||||||
import "package:styled_widget/styled_widget.dart";
|
import "package:styled_widget/styled_widget.dart";
|
||||||
@@ -464,6 +474,70 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
const messageKeyPrefix = 'message-';
|
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) =>
|
Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
|
||||||
SuperListView.builder(
|
SuperListView.builder(
|
||||||
listController: listController,
|
listController: listController,
|
||||||
@@ -779,9 +853,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
attachments: attachments.value,
|
attachments: attachments.value,
|
||||||
onUploadAttachment: (_) {
|
onUploadAttachment: uploadAttachment,
|
||||||
// not going to do anything, only upload when send the message
|
|
||||||
},
|
|
||||||
onDeleteAttachment: (index) async {
|
onDeleteAttachment: (index) async {
|
||||||
final attachment = attachments.value[index];
|
final attachment = attachments.value[index];
|
||||||
if (attachment.isOnCloud) {
|
if (attachment.isOnCloud) {
|
||||||
@@ -806,6 +878,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
onAttachmentsChanged: (newAttachments) {
|
onAttachmentsChanged: (newAttachments) {
|
||||||
attachments.value = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
556
lib/screens/files/file_list.dart
Normal file
556
lib/screens/files/file_list.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
69
lib/screens/files/file_list.g.dart
Normal file
69
lib/screens/files/file_list.g.dart
Normal 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
|
@@ -10,6 +10,7 @@ import 'package:island/screens/creators/publishers.dart';
|
|||||||
import 'package:island/screens/posts/compose_article.dart';
|
import 'package:island/screens/posts/compose_article.dart';
|
||||||
import 'package:island/services/responsive.dart';
|
import 'package:island/services/responsive.dart';
|
||||||
import 'package:island/widgets/app_scaffold.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/attachment_preview.dart';
|
||||||
import 'package:island/widgets/content/cloud_files.dart';
|
import 'package:island/widgets/content/cloud_files.dart';
|
||||||
import 'package:island/widgets/post/compose_shared.dart';
|
import 'package:island/widgets/post/compose_shared.dart';
|
||||||
@@ -225,8 +226,26 @@ class PostComposeScreen extends HookConsumerWidget {
|
|||||||
return AttachmentPreview(
|
return AttachmentPreview(
|
||||||
item: state.attachments.value[idx],
|
item: state.attachments.value[idx],
|
||||||
progress: progressMap[idx],
|
progress: progressMap[idx],
|
||||||
onRequestUpload:
|
onRequestUpload: () async {
|
||||||
() => ComposeLogic.uploadAttachment(ref, state, idx),
|
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),
|
onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
|
||||||
onUpdate:
|
onUpdate:
|
||||||
(value) => ComposeLogic.updateAttachment(state, value, idx),
|
(value) => ComposeLogic.updateAttachment(state, value, idx),
|
||||||
@@ -253,8 +272,27 @@ class PostComposeScreen extends HookConsumerWidget {
|
|||||||
return AttachmentPreview(
|
return AttachmentPreview(
|
||||||
item: state.attachments.value[idx],
|
item: state.attachments.value[idx],
|
||||||
progress: progressMap[idx],
|
progress: progressMap[idx],
|
||||||
onRequestUpload:
|
onRequestUpload: () async {
|
||||||
() => ComposeLogic.uploadAttachment(ref, state, idx),
|
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:
|
onDelete:
|
||||||
() => ComposeLogic.deleteAttachment(ref, state, idx),
|
() => ComposeLogic.deleteAttachment(ref, state, idx),
|
||||||
onUpdate:
|
onUpdate:
|
||||||
|
@@ -11,6 +11,7 @@ import 'package:island/models/post.dart';
|
|||||||
import 'package:island/screens/creators/publishers.dart';
|
import 'package:island/screens/creators/publishers.dart';
|
||||||
import 'package:island/services/responsive.dart';
|
import 'package:island/services/responsive.dart';
|
||||||
import 'package:island/widgets/app_scaffold.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/screens/posts/post_detail.dart';
|
||||||
import 'package:island/widgets/content/attachment_preview.dart';
|
import 'package:island/widgets/content/attachment_preview.dart';
|
||||||
import 'package:island/widgets/content/cloud_files.dart';
|
import 'package:island/widgets/content/cloud_files.dart';
|
||||||
@@ -345,12 +346,30 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
|||||||
isCompact: true,
|
isCompact: true,
|
||||||
item: attachments[idx],
|
item: attachments[idx],
|
||||||
progress: progressMap[idx],
|
progress: progressMap[idx],
|
||||||
onRequestUpload:
|
onRequestUpload: () async {
|
||||||
() => ComposeLogic.uploadAttachment(
|
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,
|
ref,
|
||||||
state,
|
state,
|
||||||
idx,
|
idx,
|
||||||
),
|
poolId: config.poolId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
onUpdate:
|
onUpdate:
|
||||||
(value) =>
|
(value) =>
|
||||||
ComposeLogic.updateAttachment(
|
ComposeLogic.updateAttachment(
|
||||||
|
@@ -21,7 +21,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
import 'package:island/pods/config.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';
|
import 'package:island/models/file_pool.dart';
|
||||||
|
|
||||||
class SettingsScreen extends HookConsumerWidget {
|
class SettingsScreen extends HookConsumerWidget {
|
||||||
@@ -146,12 +146,12 @@ class SettingsScreen extends HookConsumerWidget {
|
|||||||
child: Text('Bubble').fontSize(14),
|
child: Text('Bubble').fontSize(14),
|
||||||
),
|
),
|
||||||
DropdownMenuItem<String>(
|
DropdownMenuItem<String>(
|
||||||
value: 'discord',
|
value: 'column',
|
||||||
child: Text('Discord').fontSize(14),
|
child: Text('Column').fontSize(14),
|
||||||
),
|
),
|
||||||
DropdownMenuItem<String>(
|
DropdownMenuItem<String>(
|
||||||
value: 'irc',
|
value: 'compact',
|
||||||
child: Text('IRC').fontSize(14),
|
child: Text('Compact').fontSize(14),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
value: settings.messageDisplayStyle,
|
value: settings.messageDisplayStyle,
|
||||||
|
@@ -290,8 +290,9 @@ class AccountSessionSheet extends HookConsumerWidget {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorAlert(err);
|
showErrorAlert(err);
|
||||||
} finally {
|
} finally {
|
||||||
if (context.mounted)
|
if (context.mounted) {
|
||||||
hideLoadingModal(context);
|
hideLoadingModal(context);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return confirm;
|
return confirm;
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
|
||||||
@@ -8,50 +7,152 @@ class LevelingProgressCard extends StatelessWidget {
|
|||||||
final int level;
|
final int level;
|
||||||
final int experience;
|
final int experience;
|
||||||
final double progress;
|
final double progress;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final bool isCompact;
|
||||||
|
|
||||||
const LevelingProgressCard({
|
const LevelingProgressCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.level,
|
required this.level,
|
||||||
required this.experience,
|
required this.experience,
|
||||||
required this.progress,
|
required this.progress,
|
||||||
|
this.onTap,
|
||||||
|
this.isCompact = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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,
|
margin: EdgeInsets.zero,
|
||||||
child: Column(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: InkWell(
|
||||||
children: [
|
onTap: onTap,
|
||||||
Row(
|
borderRadius: BorderRadius.circular(12),
|
||||||
spacing: 8,
|
child: Container(
|
||||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
decoration: BoxDecoration(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
borderRadius: BorderRadius.circular(12),
|
||||||
textBaseline: TextBaseline.alphabetic,
|
gradient: LinearGradient(
|
||||||
children: [
|
colors: [
|
||||||
Text(
|
stageColor.withOpacity(0.1),
|
||||||
'levelingProgressLevel'.tr(args: [level.toString()]),
|
Theme.of(context).colorScheme.surface,
|
||||||
style: GoogleFonts.robotoMono(),
|
],
|
||||||
).fontSize(13).bold(),
|
begin: Alignment.topLeft,
|
||||||
Text(
|
end: Alignment.bottomRight,
|
||||||
'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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
child: Column(
|
||||||
).padding(horizontal: 16, vertical: 12),
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
365
lib/widgets/attachment_uploader.dart
Normal file
365
lib/widgets/attachment_uploader.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@@ -32,6 +32,7 @@ class ChatInput extends HookConsumerWidget {
|
|||||||
final Function(int) onDeleteAttachment;
|
final Function(int) onDeleteAttachment;
|
||||||
final Function(int, int) onMoveAttachment;
|
final Function(int, int) onMoveAttachment;
|
||||||
final Function(List<UniversalFile>) onAttachmentsChanged;
|
final Function(List<UniversalFile>) onAttachmentsChanged;
|
||||||
|
final Map<String, Map<int, double>> attachmentProgress;
|
||||||
|
|
||||||
const ChatInput({
|
const ChatInput({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -48,6 +49,7 @@ class ChatInput extends HookConsumerWidget {
|
|||||||
required this.onDeleteAttachment,
|
required this.onDeleteAttachment,
|
||||||
required this.onMoveAttachment,
|
required this.onMoveAttachment,
|
||||||
required this.onAttachmentsChanged,
|
required this.onAttachmentsChanged,
|
||||||
|
required this.attachmentProgress,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -123,6 +125,7 @@ class ChatInput extends HookConsumerWidget {
|
|||||||
width: 280,
|
width: 280,
|
||||||
child: AttachmentPreview(
|
child: AttachmentPreview(
|
||||||
item: attachments[idx],
|
item: attachments[idx],
|
||||||
|
progress: attachmentProgress['chat-upload']?[idx],
|
||||||
onRequestUpload: () => onUploadAttachment(idx),
|
onRequestUpload: () => onUploadAttachment(idx),
|
||||||
onDelete: () => onDeleteAttachment(idx),
|
onDelete: () => onDeleteAttachment(idx),
|
||||||
onUpdate: (value) {
|
onUpdate: (value) {
|
||||||
|
@@ -25,7 +25,7 @@ class MessageContent extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Symbols.delete,
|
Symbols.delete,
|
||||||
size: 14,
|
size: 16,
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||||
@@ -34,6 +34,7 @@ class MessageContent extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
item.content ?? 'Deleted a message',
|
item.content ?? 'Deleted a message',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontSize: 13,
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||||
@@ -59,7 +60,7 @@ class MessageContent extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Symbols.edit,
|
Symbols.edit,
|
||||||
size: 14,
|
size: 16,
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||||
@@ -71,7 +72,7 @@ class MessageContent extends StatelessWidget {
|
|||||||
newText: item.content ?? 'Edited a message',
|
newText: item.content ?? 'Edited a message',
|
||||||
defaultTextStyle: Theme.of(
|
defaultTextStyle: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.bodySmall!.copyWith(
|
).textTheme.bodyMedium!.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
addedTextStyle: TextStyle(
|
addedTextStyle: TextStyle(
|
||||||
|
@@ -54,6 +54,8 @@ class MessageItem extends HookConsumerWidget {
|
|||||||
required this.onJump,
|
required this.onJump,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static const kFlashDuration = 300;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final remoteMessage = message.toRemoteMessage();
|
final remoteMessage = message.toRemoteMessage();
|
||||||
@@ -106,113 +108,211 @@ class MessageItem extends HookConsumerWidget {
|
|||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
builder:
|
builder:
|
||||||
(context) => SheetScaffold(
|
(context) => MessageActionSheet(
|
||||||
titleText: 'messageActions'.tr(),
|
isCurrentUser: isCurrentUser,
|
||||||
child: SingleChildScrollView(
|
onAction: onAction,
|
||||||
child: Column(
|
translatableLanguage: translatableLanguage,
|
||||||
children: [
|
translating: translating.value,
|
||||||
if (isCurrentUser)
|
translatedText: translatedText.value,
|
||||||
ListTile(
|
translate: translate,
|
||||||
leading: Icon(Symbols.edit),
|
isMobile: isMobile,
|
||||||
title: Text('edit'.tr()),
|
remoteMessage: remoteMessage,
|
||||||
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);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
onLongPress: showActionMenu,
|
||||||
onSecondaryTap: showActionMenu,
|
onSecondaryTap: showActionMenu,
|
||||||
child: switch (settings.messageDisplayStyle) {
|
onTap: () {
|
||||||
'irc' => MessageItemDisplayIRC(
|
// Jump to related message
|
||||||
message: message,
|
if (['messages.update', 'messages.delete'].contains(message.type) &&
|
||||||
isCurrentUser: isCurrentUser,
|
message.meta['message_id'] is String &&
|
||||||
progress: progress,
|
message.meta['message_id'] != null) {
|
||||||
showAvatar: showAvatar,
|
onJump(message.meta['message_id']);
|
||||||
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,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
|
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
|
isCurrentUser
|
||||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||||
: Theme.of(context).colorScheme.onSurfaceVariant;
|
: Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
final containerColor =
|
|
||||||
isCurrentUser
|
|
||||||
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.5)
|
|
||||||
: Theme.of(context).colorScheme.surfaceContainer;
|
|
||||||
|
|
||||||
final hasBackground =
|
final hasBackground =
|
||||||
ref.watch(backgroundImageFileProvider).valueOrNull != null;
|
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 remoteMessage = message.toRemoteMessage();
|
||||||
final sender = remoteMessage.sender;
|
final sender = remoteMessage.sender;
|
||||||
|
|
||||||
@@ -321,109 +377,98 @@ class MessageItemDisplayBubble extends HookConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Flexible(
|
||||||
child: AnimatedContainer(
|
child: Column(
|
||||||
duration: const Duration(milliseconds: 200),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
color: flashColor,
|
if (remoteMessage.repliedMessageId != null)
|
||||||
borderRadius: BorderRadius.circular(16),
|
MessageQuoteWidget(
|
||||||
),
|
message: message,
|
||||||
padding: const EdgeInsets.symmetric(
|
textColor: textColor,
|
||||||
horizontal: 12,
|
isReply: true,
|
||||||
vertical: 6,
|
).padding(vertical: 4),
|
||||||
),
|
if (remoteMessage.forwardedMessageId != null)
|
||||||
child: Column(
|
MessageQuoteWidget(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
message: message,
|
||||||
children: [
|
textColor: textColor,
|
||||||
if (remoteMessage.repliedMessageId != null)
|
isReply: false,
|
||||||
MessageQuoteWidget(
|
).padding(vertical: 4),
|
||||||
message: message,
|
if (MessageContent.hasContent(remoteMessage))
|
||||||
textColor: textColor,
|
MessageContent(
|
||||||
isReply: true,
|
item: remoteMessage,
|
||||||
).padding(vertical: 4),
|
translatedText: translatedText,
|
||||||
if (remoteMessage.forwardedMessageId != null)
|
),
|
||||||
MessageQuoteWidget(
|
if (remoteMessage.attachments.isNotEmpty)
|
||||||
message: message,
|
LayoutBuilder(
|
||||||
textColor: textColor,
|
builder: (context, constraints) {
|
||||||
isReply: false,
|
return CloudFileList(
|
||||||
).padding(vertical: 4),
|
files: remoteMessage.attachments,
|
||||||
if (MessageContent.hasContent(remoteMessage))
|
maxWidth: constraints.maxWidth,
|
||||||
MessageContent(
|
padding: EdgeInsets.symmetric(vertical: 4),
|
||||||
item: remoteMessage,
|
);
|
||||||
translatedText: translatedText,
|
},
|
||||||
),
|
),
|
||||||
if (remoteMessage.attachments.isNotEmpty)
|
if (remoteMessage.meta['embeds'] != null)
|
||||||
LayoutBuilder(
|
...((remoteMessage.meta['embeds'] as List<dynamic>)
|
||||||
builder: (context, constraints) {
|
.map((embed) => convertMapKeysToSnakeCase(embed))
|
||||||
return CloudFileList(
|
.where((embed) => embed['type'] == 'link')
|
||||||
files: remoteMessage.attachments,
|
.map((embed) => SnScrappedLink.fromJson(embed))
|
||||||
maxWidth: constraints.maxWidth,
|
.map(
|
||||||
padding: EdgeInsets.symmetric(vertical: 4),
|
(link) => LayoutBuilder(
|
||||||
);
|
builder: (context, constraints) {
|
||||||
},
|
return EmbedLinkWidget(
|
||||||
),
|
link: link,
|
||||||
if (remoteMessage.meta['embeds'] != null)
|
maxWidth: math.min(
|
||||||
...((remoteMessage.meta['embeds'] as List<dynamic>)
|
constraints.maxWidth,
|
||||||
.map((embed) => convertMapKeysToSnakeCase(embed))
|
480,
|
||||||
.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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const Gap(4),
|
margin: const EdgeInsets.symmetric(
|
||||||
LinearProgressIndicator(
|
vertical: 4,
|
||||||
value: entry.value / 100,
|
|
||||||
backgroundColor:
|
|
||||||
Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.surfaceVariant,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList()),
|
||||||
|
if (progress != null && progress!.isNotEmpty)
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
if ((remoteMessage.content?.isNotEmpty ?? false))
|
||||||
const Gap(0),
|
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(
|
MessageIndicators(
|
||||||
@@ -467,24 +512,125 @@ class MessageItemDisplayIRC extends HookConsumerWidget {
|
|||||||
final sender = remoteMessage.sender;
|
final sender = remoteMessage.sender;
|
||||||
final textColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
final textColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
|
final isMultiline =
|
||||||
|
message.type == 'text' ||
|
||||||
|
message.repliedMessageId != null ||
|
||||||
|
message.forwardedMessageId != null;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
crossAxisAlignment:
|
||||||
|
isMultiline ? CrossAxisAlignment.start : CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
DateFormat('HH:mm').format(message.createdAt),
|
DateFormat('HH:mm').format(message.createdAt),
|
||||||
style: TextStyle(color: textColor.withOpacity(0.7), fontSize: 12),
|
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),
|
const Gap(8),
|
||||||
Text(
|
|
||||||
'<${sender.account.nick}>',
|
|
||||||
style: TextStyle(color: Colors.blue),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
translatedText ?? remoteMessage.content ?? '',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(color: textColor),
|
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),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@@ -18,7 +18,7 @@ import "package:material_symbols_icons/material_symbols_icons.dart";
|
|||||||
import "package:styled_widget/styled_widget.dart";
|
import "package:styled_widget/styled_widget.dart";
|
||||||
import "package:super_sliver_list/super_sliver_list.dart";
|
import "package:super_sliver_list/super_sliver_list.dart";
|
||||||
import "package:material_symbols_icons/symbols.dart";
|
import "package:material_symbols_icons/symbols.dart";
|
||||||
import "package:riverpod_annotation/riverpod_annotation.dart";
|
|
||||||
import "package:island/screens/chat/chat.dart";
|
import "package:island/screens/chat/chat.dart";
|
||||||
|
|
||||||
class PublicRoomPreview extends HookConsumerWidget {
|
class PublicRoomPreview extends HookConsumerWidget {
|
||||||
|
@@ -470,7 +470,8 @@ class AttachmentPreview extends HookConsumerWidget {
|
|||||||
if (onRequestUpload != null)
|
if (onRequestUpload != null)
|
||||||
InkWell(
|
InkWell(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
onTap: () => onRequestUpload?.call(),
|
onTap:
|
||||||
|
item.isOnCloud ? null : () => onRequestUpload?.call(),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Container(
|
child: Container(
|
||||||
|
@@ -1,15 +1,12 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:dismissible_page/dismissible_page.dart';
|
import 'package:dismissible_page/dismissible_page.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
||||||
import 'package:file_saver/file_saver.dart';
|
import 'package:file_saver/file_saver.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:gal/gal.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/models/file.dart';
|
||||||
import 'package:island/pods/config.dart';
|
import 'package:island/pods/config.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
import 'package:island/utils/format.dart';
|
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/content/cloud_files.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/sensitive.dart';
|
||||||
import 'package:island/widgets/content/sheet.dart';
|
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:path/path.dart' show extension;
|
import 'package:path/path.dart' show extension;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
@@ -361,284 +357,11 @@ class CloudFileZoomIn extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void showInfoSheet() {
|
void showInfoSheet() {
|
||||||
final theme = Theme.of(context);
|
|
||||||
final exifData = item.fileMeta?['exif'] as Map<String, dynamic>? ?? {};
|
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder:
|
builder: (context) => FileInfoSheet(item: item),
|
||||||
(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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
280
lib/widgets/content/file_info_sheet.dart
Normal file
280
lib/widgets/content/file_info_sheet.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -64,6 +64,7 @@ class DebugSheet extends HookConsumerWidget {
|
|||||||
|
|
||||||
return SheetScaffold(
|
return SheetScaffold(
|
||||||
titleText: 'Debug',
|
titleText: 'Debug',
|
||||||
|
heightFactor: 0.6,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
|
@@ -30,10 +30,13 @@ class ComposeEmbedSheet extends HookConsumerWidget {
|
|||||||
final selectedRenderer = useState<PostEmbedViewRenderer>(
|
final selectedRenderer = useState<PostEmbedViewRenderer>(
|
||||||
PostEmbedViewRenderer.webView,
|
PostEmbedViewRenderer.webView,
|
||||||
);
|
);
|
||||||
|
final tabController = useTabController(initialLength: 2);
|
||||||
|
final iframeController = useTextEditingController();
|
||||||
|
|
||||||
void clearForm() {
|
void clearForm() {
|
||||||
uriController.clear();
|
uriController.clear();
|
||||||
aspectRatioController.clear();
|
aspectRatioController.clear();
|
||||||
|
iframeController.clear();
|
||||||
selectedRenderer.value = PostEmbedViewRenderer.webView;
|
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(
|
return SheetScaffold(
|
||||||
titleText: 'embedView'.tr(),
|
titleText: 'embedView'.tr(),
|
||||||
heightFactor: 0.7,
|
heightFactor: 0.7,
|
||||||
@@ -85,7 +139,7 @@ class ComposeEmbedSheet extends HookConsumerWidget {
|
|||||||
// Header with save button when editing
|
// Header with save button when editing
|
||||||
if (currentEmbedView != null)
|
if (currentEmbedView != null)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -97,187 +151,207 @@ class ComposeEmbedSheet extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: saveEmbedView,
|
onPressed: saveEmbedView,
|
||||||
|
style: ButtonStyle(visualDensity: VisualDensity.compact),
|
||||||
child: Text('save'.tr()),
|
child: Text('save'.tr()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Tab bar
|
||||||
|
TabBar(
|
||||||
|
controller: tabController,
|
||||||
|
tabs: [Tab(text: 'auto'.tr()), Tab(text: 'manual'.tr())],
|
||||||
|
),
|
||||||
|
|
||||||
// Content area
|
// Content area
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: TabBarView(
|
||||||
padding: const EdgeInsets.all(16),
|
controller: tabController,
|
||||||
child: Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
// Auto tab
|
||||||
children: [
|
SingleChildScrollView(
|
||||||
// Form fields
|
padding: const EdgeInsets.all(16),
|
||||||
TextField(
|
child: Column(
|
||||||
controller: uriController,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
decoration: InputDecoration(
|
children: [
|
||||||
labelText: 'embedUri'.tr(),
|
TextField(
|
||||||
hintText: 'https://example.com',
|
controller: iframeController,
|
||||||
border: OutlineInputBorder(
|
decoration: InputDecoration(
|
||||||
borderRadius: BorderRadius.circular(8),
|
labelText: 'iframeCode'.tr(),
|
||||||
|
hintText: 'iframeCodeHint'.tr(),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
maxLines: 5,
|
||||||
),
|
),
|
||||||
),
|
const Gap(16),
|
||||||
keyboardType: TextInputType.url,
|
SizedBox(
|
||||||
),
|
width: double.infinity,
|
||||||
const Gap(16),
|
child: FilledButton.icon(
|
||||||
TextField(
|
onPressed: parseIframe,
|
||||||
controller: aspectRatioController,
|
icon: const Icon(Symbols.auto_fix),
|
||||||
decoration: InputDecoration(
|
label: Text('parseIframe'.tr()),
|
||||||
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>(
|
// Manual tab
|
||||||
value: selectedRenderer.value,
|
SingleChildScrollView(
|
||||||
decoration: InputDecoration(
|
padding: const EdgeInsets.all(16),
|
||||||
labelText: 'renderer'.tr(),
|
child: Column(
|
||||||
border: OutlineInputBorder(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
borderRadius: BorderRadius.circular(8),
|
children: [
|
||||||
),
|
// Form fields
|
||||||
),
|
TextField(
|
||||||
items:
|
controller: uriController,
|
||||||
PostEmbedViewRenderer.values.map((renderer) {
|
decoration: InputDecoration(
|
||||||
return DropdownMenuItem(
|
labelText: 'embedUri'.tr(),
|
||||||
value: renderer,
|
hintText: 'https://example.com',
|
||||||
child: Text(renderer.name).tr(),
|
border: OutlineInputBorder(
|
||||||
);
|
borderRadius: BorderRadius.circular(8),
|
||||||
}).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(
|
keyboardType: TextInputType.url,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
),
|
||||||
children: [
|
const Gap(16),
|
||||||
Row(
|
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: [
|
children: [
|
||||||
Icon(
|
Row(
|
||||||
currentEmbedView.renderer ==
|
children: [
|
||||||
PostEmbedViewRenderer.webView
|
Icon(
|
||||||
? Symbols.web
|
currentEmbedView.renderer ==
|
||||||
: Symbols.web,
|
PostEmbedViewRenderer.webView
|
||||||
color: colorScheme.primary,
|
? 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),
|
const Gap(12),
|
||||||
Expanded(
|
Text(
|
||||||
child: Text(
|
'aspectRatio'.tr(),
|
||||||
currentEmbedView.uri,
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
style: theme.textTheme.bodyMedium,
|
color: colorScheme.onSurfaceVariant,
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
const Gap(4),
|
||||||
icon: const Icon(Symbols.delete),
|
Text(
|
||||||
onPressed: () {
|
currentEmbedView.aspectRatio != null
|
||||||
showDialog(
|
? currentEmbedView.aspectRatio!
|
||||||
context: context,
|
.toStringAsFixed(2)
|
||||||
builder:
|
: 'notSet'.tr(),
|
||||||
(dialogContext) => AlertDialog(
|
style: theme.textTheme.bodyMedium,
|
||||||
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(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 ...[
|
||||||
),
|
const Gap(16),
|
||||||
] else ...[
|
SizedBox(
|
||||||
// Save button for new embed
|
width: double.infinity,
|
||||||
const Gap(16),
|
child: FilledButton.icon(
|
||||||
SizedBox(
|
onPressed: saveEmbedView,
|
||||||
width: double.infinity,
|
icon: const Icon(Symbols.add),
|
||||||
child: FilledButton.icon(
|
label: Text('addEmbed'.tr()),
|
||||||
onPressed: saveEmbedView,
|
),
|
||||||
icon: const Icon(Symbols.add),
|
),
|
||||||
label: Text('addEmbed'.tr()),
|
],
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@@ -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_link_attachments.dart';
|
||||||
import 'package:island/widgets/post/compose_poll.dart';
|
import 'package:island/widgets/post/compose_poll.dart';
|
||||||
import 'package:island/widgets/post/compose_recorder.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:pasteboard/pasteboard.dart';
|
||||||
import 'package:textfield_tags/textfield_tags.dart';
|
import 'package:textfield_tags/textfield_tags.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
@@ -672,7 +672,7 @@ class ComposeLogic {
|
|||||||
try {
|
try {
|
||||||
state.submitting.value = true;
|
state.submitting.value = true;
|
||||||
|
|
||||||
// Upload any local attachments first
|
// pload any local attachments first
|
||||||
await Future.wait(
|
await Future.wait(
|
||||||
state.attachments.value
|
state.attachments.value
|
||||||
.asMap()
|
.asMap()
|
||||||
|
@@ -82,75 +82,83 @@ class ComposeToolbar extends HookConsumerWidget {
|
|||||||
constraints: const BoxConstraints(maxWidth: 560),
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
Expanded(
|
||||||
onPressed: pickPhotoMedia,
|
child: SingleChildScrollView(
|
||||||
tooltip: 'addPhoto'.tr(),
|
scrollDirection: Axis.horizontal,
|
||||||
icon: const Icon(Symbols.add_a_photo),
|
child: Row(
|
||||||
color: colorScheme.primary,
|
children: [
|
||||||
),
|
IconButton(
|
||||||
IconButton(
|
onPressed: pickPhotoMedia,
|
||||||
onPressed: pickVideoMedia,
|
tooltip: 'addPhoto'.tr(),
|
||||||
tooltip: 'addVideo'.tr(),
|
icon: const Icon(Symbols.add_a_photo),
|
||||||
icon: const Icon(Symbols.videocam),
|
color: colorScheme.primary,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
IconButton(
|
||||||
);
|
onPressed: pickVideoMedia,
|
||||||
},
|
tooltip: 'addVideo'.tr(),
|
||||||
),
|
icon: const Icon(Symbols.videocam),
|
||||||
// Embed button with visual state when embed is present
|
color: colorScheme.primary,
|
||||||
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: 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)
|
if (originalPost == null && state.isEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Symbols.draft),
|
icon: const Icon(Symbols.draft),
|
||||||
|
@@ -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/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
@@ -58,8 +61,8 @@ class EmbedViewRenderer extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
embedView.renderer == PostEmbedViewRenderer.webView
|
embedView.renderer == PostEmbedViewRenderer.webView
|
||||||
? Symbols.web
|
? Symbols.globe
|
||||||
: Symbols.web,
|
: Symbols.iframe,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
@@ -74,13 +77,13 @@ class EmbedViewRenderer extends HookConsumerWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
InkWell(
|
||||||
icon: Icon(
|
child: Icon(
|
||||||
Symbols.open_in_new,
|
Symbols.open_in_new,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onTap: () async {
|
||||||
final uri = Uri.parse(embedView.uri);
|
final uri = Uri.parse(embedView.uri);
|
||||||
if (await canLaunchUrl(uri)) {
|
if (await canLaunchUrl(uri)) {
|
||||||
await launchUrl(
|
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(
|
? Stack(
|
||||||
children: [
|
children: [
|
||||||
InAppWebView(
|
InAppWebView(
|
||||||
|
gestureRecognizers: {
|
||||||
|
Factory<VerticalDragGestureRecognizer>(
|
||||||
|
() => VerticalDragGestureRecognizer(),
|
||||||
|
),
|
||||||
|
Factory<HorizontalDragGestureRecognizer>(
|
||||||
|
() => HorizontalDragGestureRecognizer(),
|
||||||
|
),
|
||||||
|
Factory<ScaleGestureRecognizer>(
|
||||||
|
() => ScaleGestureRecognizer(),
|
||||||
|
),
|
||||||
|
Factory<TapGestureRecognizer>(
|
||||||
|
() => TapGestureRecognizer(),
|
||||||
|
),
|
||||||
|
},
|
||||||
initialUrlRequest: URLRequest(
|
initialUrlRequest: URLRequest(
|
||||||
url: WebUri(embedView.uri),
|
url: WebUri(embedView.uri),
|
||||||
),
|
),
|
||||||
@@ -256,14 +269,14 @@ class EmbedViewRenderer extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Symbols.play_arrow,
|
Symbols.play_arrow,
|
||||||
|
fill: 1,
|
||||||
size: 48,
|
size: 48,
|
||||||
color: colorScheme.onSurfaceVariant.withOpacity(
|
color: colorScheme.onSurfaceVariant.withOpacity(
|
||||||
0.6,
|
0.6,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
Text(
|
||||||
'Tap to load content',
|
'viewEmbedLoadHint'.tr(),
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant
|
color: colorScheme.onSurfaceVariant
|
||||||
.withOpacity(0.6),
|
.withOpacity(0.6),
|
||||||
|
@@ -31,6 +31,36 @@ import 'package:share_plus/share_plus.dart';
|
|||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
import 'package:super_context_menu/super_context_menu.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 {
|
class PostActionableItem extends HookConsumerWidget {
|
||||||
final SnPost item;
|
final SnPost item;
|
||||||
final EdgeInsets? padding;
|
final EdgeInsets? padding;
|
||||||
@@ -490,57 +520,66 @@ class PostItem extends HookConsumerWidget {
|
|||||||
trailing:
|
trailing:
|
||||||
isCompact
|
isCompact
|
||||||
? null
|
? null
|
||||||
: IconButton(
|
: SizedBox(
|
||||||
icon:
|
width: 36,
|
||||||
mostReaction == null
|
height: 36,
|
||||||
? const Icon(Symbols.add_reaction)
|
child: IconButton(
|
||||||
: Badge(
|
icon:
|
||||||
label: Center(
|
mostReaction == null
|
||||||
child: Text(
|
? const Icon(Symbols.add_reaction)
|
||||||
'x${item.reactionsCount[mostReaction]}',
|
: Badge(
|
||||||
style: const TextStyle(fontSize: 11),
|
label: Center(
|
||||||
textAlign: TextAlign.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),
|
style: ButtonStyle(
|
||||||
backgroundColor: Theme.of(
|
backgroundColor: WidgetStatePropertyAll(
|
||||||
|
(item.reactionsMade[mostReaction] ?? false)
|
||||||
|
? Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.primary.withOpacity(0.75),
|
).colorScheme.primary.withOpacity(0.5)
|
||||||
textColor:
|
: null,
|
||||||
Theme.of(context).colorScheme.onPrimary,
|
),
|
||||||
child: Text(
|
),
|
||||||
kReactionTemplates[mostReaction]?.icon ?? '',
|
onPressed: () {
|
||||||
style: const TextStyle(fontSize: 20),
|
showModalBottomSheet(
|
||||||
),
|
context: context,
|
||||||
),
|
useRootNavigator: true,
|
||||||
style: ButtonStyle(
|
builder: (BuildContext context) {
|
||||||
backgroundColor: WidgetStatePropertyAll(
|
return _PostReactionSheet(
|
||||||
(item.reactionsMade[mostReaction] ?? false)
|
reactionsCount: item.reactionsCount,
|
||||||
? Theme.of(
|
reactionsMade: item.reactionsMade,
|
||||||
context,
|
onReact: (symbol, attitude) {
|
||||||
).colorScheme.primary.withOpacity(0.5)
|
reactPost(symbol, attitude);
|
||||||
: null,
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
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(
|
return SizedBox(
|
||||||
height: 28,
|
height: 40,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: padding ?? EdgeInsets.zero,
|
padding: padding ?? EdgeInsets.zero,
|
||||||
@@ -649,7 +688,7 @@ class PostReactionList extends HookConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: ActionChip(
|
child: ActionChip(
|
||||||
avatar: Text(kReactionTemplates[symbol]?.icon ?? '?'),
|
avatar: _buildReactionIcon(symbol, 24),
|
||||||
label: Row(
|
label: Row(
|
||||||
spacing: 4,
|
spacing: 4,
|
||||||
children: [
|
children: [
|
||||||
@@ -786,37 +825,96 @@ class _PostReactionSheet extends StatelessWidget {
|
|||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final symbol = allReactions[index];
|
final symbol = allReactions[index];
|
||||||
final count = reactionsCount[symbol] ?? 0;
|
final count = reactionsCount[symbol] ?? 0;
|
||||||
return Card(
|
final hasImage = _getReactionImageAvailable(symbol);
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
return Badge(
|
||||||
color:
|
label: Text('x$count'),
|
||||||
(reactionsMade[symbol] ?? false)
|
isLabelVisible: count > 0,
|
||||||
? Theme.of(context).colorScheme.primaryContainer
|
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||||
: Theme.of(context).colorScheme.surfaceContainerLowest,
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
child: InkWell(
|
offset: Offset(0, 0),
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
child: Card(
|
||||||
onTap: () {
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
onReact(symbol, attitude);
|
color: Theme.of(context).colorScheme.surfaceContainerLowest,
|
||||||
Navigator.pop(context);
|
child: InkWell(
|
||||||
},
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
child: Column(
|
onTap: () {
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
onReact(symbol, attitude);
|
||||||
children: [
|
Navigator.pop(context);
|
||||||
Text(
|
},
|
||||||
kReactionTemplates[symbol]?.icon ?? '',
|
child: Container(
|
||||||
textAlign: TextAlign.center,
|
decoration:
|
||||||
).fontSize(24),
|
hasImage
|
||||||
Text(
|
? BoxDecoration(
|
||||||
ReactInfo.getTranslationKey(symbol),
|
borderRadius: BorderRadius.circular(8),
|
||||||
textAlign: TextAlign.center,
|
image: DecorationImage(
|
||||||
).tr(),
|
image: AssetImage(
|
||||||
if (count > 0)
|
'assets/images/stickers/$symbol.png',
|
||||||
Text(
|
),
|
||||||
'x$count',
|
fit: BoxFit.cover,
|
||||||
textAlign: TextAlign.center,
|
colorFilter:
|
||||||
).bold().padding(bottom: 4)
|
(reactionsMade[symbol] ?? false)
|
||||||
else
|
? ColorFilter.mode(
|
||||||
const Gap(20),
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@@ -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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.7.2
|
sdk: ^3.7.2
|
||||||
@@ -189,6 +189,7 @@ flutter:
|
|||||||
- assets/i18n/
|
- assets/i18n/
|
||||||
- assets/images/
|
- assets/images/
|
||||||
- assets/images/oidc/
|
- assets/images/oidc/
|
||||||
|
- assets/images/stickers/
|
||||||
- assets/icons/
|
- assets/icons/
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
|
Reference in New Issue
Block a user