Compare commits

...

3 Commits

Author SHA1 Message Date
edb0a25f34 💄 Optimize embed view renderer loading logic 2025-09-08 15:48:10 +08:00
7cd10118cc Post award 2025-09-08 15:47:57 +08:00
fcddc8f345 💄 Optimize embed webview 2025-09-08 02:45:20 +08:00
3 changed files with 509 additions and 156 deletions

View File

@@ -270,6 +270,8 @@ class _PaymentContentState extends ConsumerState<_PaymentContent> {
}
} else if (err.response?.statusCode == 400) {
errorMessage = err.response?.data?['error'] ?? errorMessage;
} else {
rethrow;
}
}
throw errorMessage;
@@ -419,42 +421,48 @@ class _PaymentContentState extends ConsumerState<_PaymentContent> {
}
Widget _buildBiometricAuth() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Symbols.fingerprint, size: 48),
const Gap(16),
Text(
'useBiometricToConfirm'.tr(),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
textAlign: TextAlign.center,
),
Text(
'The biometric data will only be processed on your device',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 11,
),
textAlign: TextAlign.center,
).opacity(0.75),
const Gap(28),
ElevatedButton.icon(
onPressed: _authenticateWithBiometric,
icon: const Icon(Symbols.fingerprint),
label: Text('authenticateNow'.tr()),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
TextButton(
onPressed: () => _fallbackToPinMode(null),
child: Text('usePinInstead'.tr()),
),
],
).center();
return SingleChildScrollView(
child:
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Symbols.fingerprint, size: 48),
const Gap(16),
Text(
'useBiometricToConfirm'.tr(),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
textAlign: TextAlign.center,
),
Text(
'The biometric data will only be processed on your device',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 11,
),
textAlign: TextAlign.center,
).opacity(0.75),
const Gap(28),
ElevatedButton.icon(
onPressed: _authenticateWithBiometric,
icon: const Icon(Symbols.fingerprint),
label: Text('authenticateNow'.tr()),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
TextButton(
onPressed: () => _fallbackToPinMode(null),
child: Text('usePinInstead'.tr()),
),
],
).center(),
);
}
Widget _buildActionButtons() {

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
@@ -22,6 +23,10 @@ class EmbedViewRenderer extends HookConsumerWidget {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// State management for lazy loading
final shouldLoad = useState(false);
final isLoading = useState(false);
return Container(
constraints: BoxConstraints(maxHeight: maxHeight ?? 400),
decoration: BoxDecoration(
@@ -93,128 +98,181 @@ class EmbedViewRenderer extends HookConsumerWidget {
),
),
// WebView content
// WebView content with lazy loading
AspectRatio(
aspectRatio: embedView.aspectRatio ?? 1,
child: InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(embedView.uri)),
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
mediaPlaybackRequiresUserGesture: false,
allowsInlineMediaPlayback: true,
useShouldOverrideUrlLoading: true,
useOnLoadResource: true,
supportZoom: false,
useWideViewPort: false,
loadWithOverviewMode: true,
builtInZoomControls: false,
displayZoomControls: false,
minimumFontSize: 12,
preferredContentMode: UserPreferredContentMode.MOBILE,
allowsBackForwardNavigationGestures: false,
allowsLinkPreview: false,
isInspectable: false,
applicationNameForUserAgent: 'Solian/3.0',
userAgent:
'Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36 Solian/3.0',
),
onWebViewCreated: (controller) {
// Configure webview settings
controller.addJavaScriptHandler(
handlerName: 'onHeightChanged',
callback: (args) {
// Handle dynamic height changes if needed
},
);
},
onLoadStart: (controller, url) {
// Handle load start
},
onLoadStop: (controller, url) async {
// Inject CSS to improve mobile display and remove borders
await controller.evaluateJavascript(
source: '''
// Remove unwanted elements
var elements = document.querySelectorAll('nav, header, footer, .ads, .advertisement, .sidebar');
for (var i = 0; i < elements.length; i++) {
elements[i].style.display = 'none';
}
// Remove borders from embedded content (YouTube, Vimeo, etc.)
var iframes = document.querySelectorAll('iframe');
for (var i = 0; i < iframes.length; i++) {
iframes[i].style.border = 'none';
iframes[i].style.borderRadius = '0';
}
// Remove borders from video elements
var videos = document.querySelectorAll('video');
for (var i = 0; i < videos.length; i++) {
videos[i].style.border = 'none';
videos[i].style.borderRadius = '0';
}
// Remove borders from any element that might have them
var allElements = document.querySelectorAll('*');
for (var i = 0; i < allElements.length; i++) {
if (allElements[i].style.border) {
allElements[i].style.border = 'none';
}
}
// Improve readability
var body = document.body;
body.style.fontSize = '14px';
body.style.lineHeight = '1.4';
body.style.margin = '0';
body.style.padding = '0';
// Handle dynamic content
var observer = new MutationObserver(function(mutations) {
// Remove borders from newly added elements
var newIframes = document.querySelectorAll('iframe');
for (var i = 0; i < newIframes.length; i++) {
if (!newIframes[i].style.border || newIframes[i].style.border !== 'none') {
newIframes[i].style.border = 'none';
newIframes[i].style.borderRadius = '0';
}
}
var newVideos = document.querySelectorAll('video');
for (var i = 0; i < newVideos.length; i++) {
if (!newVideos[i].style.border || newVideos[i].style.border !== 'none') {
newVideos[i].style.border = 'none';
newVideos[i].style.borderRadius = '0';
}
}
window.flutter_inappwebview.callHandler('onHeightChanged', document.body.scrollHeight);
});
observer.observe(document.body, { childList: true, subtree: true });
''',
);
},
onLoadError: (controller, url, code, message) {
// Handle load errors
},
onLoadHttpError: (controller, url, statusCode, description) {
// Handle HTTP errors
},
shouldOverrideUrlLoading: (controller, navigationAction) async {
final uri = navigationAction.request.url;
if (uri != null && uri.toString() != embedView.uri) {
// Open external links in browser
// You might want to use url_launcher here
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.ALLOW;
},
onProgressChanged: (controller, progress) {
// Handle progress changes if needed
},
onConsoleMessage: (controller, consoleMessage) {
// Handle console messages for debugging
debugPrint('WebView Console: ${consoleMessage.message}');
},
),
child:
shouldLoad.value
? Stack(
children: [
InAppWebView(
initialUrlRequest: URLRequest(
url: WebUri(embedView.uri),
),
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
mediaPlaybackRequiresUserGesture: false,
allowsInlineMediaPlayback: true,
useShouldOverrideUrlLoading: true,
useOnLoadResource: true,
supportZoom: false,
useWideViewPort: false,
loadWithOverviewMode: true,
builtInZoomControls: false,
displayZoomControls: false,
minimumFontSize: 12,
preferredContentMode:
UserPreferredContentMode.RECOMMENDED,
allowsBackForwardNavigationGestures: false,
allowsLinkPreview: false,
isInspectable: false,
),
onWebViewCreated: (controller) {
// Configure webview settings
controller.addJavaScriptHandler(
handlerName: 'onHeightChanged',
callback: (args) {
// Handle dynamic height changes if needed
},
);
},
onLoadStart: (controller, url) {
isLoading.value = true;
},
onLoadStop: (controller, url) async {
isLoading.value = false;
// Inject CSS to improve mobile display and remove borders
await controller.evaluateJavascript(
source: '''
// Remove unwanted elements
var elements = document.querySelectorAll('nav, header, footer, .ads, .advertisement, .sidebar');
for (var i = 0; i < elements.length; i++) {
elements[i].style.display = 'none';
}
// Remove borders from embedded content (YouTube, Vimeo, etc.)
var iframes = document.querySelectorAll('iframe');
for (var i = 0; i < iframes.length; i++) {
iframes[i].style.border = 'none';
iframes[i].style.borderRadius = '0';
}
// Remove borders from video elements
var videos = document.querySelectorAll('video');
for (var i = 0; i < videos.length; i++) {
videos[i].style.border = 'none';
videos[i].style.borderRadius = '0';
}
// Remove borders from any element that might have them
var allElements = document.querySelectorAll('*');
for (var i = 0; i < allElements.length; i++) {
if (allElements[i].style.border) {
allElements[i].style.border = 'none';
}
}
// Improve readability
var body = document.body;
body.style.fontSize = '14px';
body.style.lineHeight = '1.4';
body.style.margin = '0';
body.style.padding = '0';
// Handle dynamic content
var observer = new MutationObserver(function(mutations) {
// Remove borders from newly added elements
var newIframes = document.querySelectorAll('iframe');
for (var i = 0; i < newIframes.length; i++) {
if (!newIframes[i].style.border || newIframes[i].style.border !== 'none') {
newIframes[i].style.border = 'none';
newIframes[i].style.borderRadius = '0';
}
}
var newVideos = document.querySelectorAll('video');
for (var i = 0; i < newVideos.length; i++) {
if (!newVideos[i].style.border || newVideos[i].style.border !== 'none') {
newVideos[i].style.border = 'none';
newVideos[i].style.borderRadius = '0';
}
}
window.flutter_inappwebview.callHandler('onHeightChanged', document.body.scrollHeight);
});
observer.observe(document.body, { childList: true, subtree: true });
''',
);
},
onLoadError: (controller, url, code, message) {
isLoading.value = false;
},
onLoadHttpError: (
controller,
url,
statusCode,
description,
) {
isLoading.value = false;
},
shouldOverrideUrlLoading: (
controller,
navigationAction,
) async {
final uri = navigationAction.request.url;
if (uri != null &&
uri.toString() != embedView.uri) {
// Open external links in browser
// You might want to use url_launcher here
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.ALLOW;
},
onProgressChanged: (controller, progress) {
// Handle progress changes if needed
},
onConsoleMessage: (controller, consoleMessage) {
// Handle console messages for debugging
debugPrint(
'WebView Console: ${consoleMessage.message}',
);
},
),
if (isLoading.value)
Container(
color: colorScheme.surfaceContainerLowest,
child: const Center(
child: CircularProgressIndicator(),
),
),
],
)
: GestureDetector(
onTap: () {
shouldLoad.value = true;
},
child: Container(
color: colorScheme.surfaceContainerLowest,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Symbols.play_arrow,
size: 48,
color: colorScheme.onSurfaceVariant.withOpacity(
0.6,
),
),
const SizedBox(height: 8),
Text(
'Tap to load content',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant
.withOpacity(0.6),
),
),
],
),
),
),
),
],
),

View File

@@ -0,0 +1,287 @@
import 'package:easy_localization/easy_localization.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/post.dart';
import 'package:island/models/wallet.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/payment/payment_overlay.dart';
import 'package:material_symbols_icons/symbols.dart';
class PostAwardSheet extends HookConsumerWidget {
final SnPost post;
const PostAwardSheet({super.key, required this.post});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messageController = useTextEditingController();
final amountController = useTextEditingController();
final selectedAttitude = useState<int>(0); // 0 for positive, 2 for negative
return SheetScaffold(
titleText: 'awardPost'.tr(),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Post Preview Section
_buildPostPreview(context),
const Gap(20),
// Award Result Explanation
_buildAwardResultExplanation(context),
const Gap(20),
Text(
'awardMessage'.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
const Gap(8),
TextField(
controller: messageController,
maxLines: 3,
decoration: InputDecoration(
hintText: 'awardMessageHint'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
),
),
const Gap(16),
Text(
'awardAttitude'.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
const Gap(8),
SegmentedButton<int>(
segments: [
ButtonSegment<int>(
value: 0,
label: Text('awardAttitudePositive'.tr()),
icon: const Icon(Symbols.thumb_up),
),
ButtonSegment<int>(
value: 2,
label: Text('awardAttitudeNegative'.tr()),
icon: const Icon(Symbols.thumb_down),
),
],
selected: {selectedAttitude.value},
onSelectionChanged: (Set<int> selection) {
selectedAttitude.value = selection.first;
},
),
const Gap(16),
Text(
'awardAmount'.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
const Gap(8),
TextField(
controller: amountController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
hintText: 'awardAmountHint'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
suffixText: 'NSP',
),
),
const Gap(24),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed:
() => _submitAward(
context,
ref,
messageController,
amountController,
selectedAttitude.value,
),
icon: const Icon(Symbols.star),
label: Text('awardSubmit'.tr()),
),
),
],
),
),
);
}
Widget _buildPostPreview(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.article,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const Gap(8),
Text(
'awardPostPreview'.tr(),
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
],
),
const Gap(8),
Text(
post.content ?? 'awardNoContent'.tr(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
...[
const Gap(4),
Row(
spacing: 6,
children: [
Text(
'awardByPublisher'.tr(args: ['@${post.publisher.name}']),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
ProfilePictureWidget(file: post.publisher.picture, radius: 8),
],
),
],
],
),
);
}
Widget _buildAwardResultExplanation(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.info,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const Gap(8),
Text(
'awardBenefits'.tr(),
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
const Gap(8),
Text(
'awardBenefitsDescription'.tr(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
Future<void> _submitAward(
BuildContext context,
WidgetRef ref,
TextEditingController messageController,
TextEditingController amountController,
int selectedAttitude,
) async {
// Get values from controllers
final message = messageController.text.trim();
final amountText = amountController.text.trim();
// Validate inputs
if (amountText.isEmpty) {
showSnackBar('awardAmountRequired'.tr());
return;
}
final amount = double.tryParse(amountText);
if (amount == null || amount <= 0) {
showSnackBar('awardAmountInvalid'.tr());
return;
}
if (message.length > 4096) {
showSnackBar('awardMessageTooLong'.tr());
return;
}
try {
showLoadingModal(context);
final client = ref.read(apiClientProvider);
// Send award request
final awardResponse = await client.post(
'/sphere/posts/${post.id}/awards',
data: {
'amount': amount,
'attitude': selectedAttitude,
if (message.isNotEmpty) 'message': message,
},
);
final orderId = awardResponse.data['order_id'] as String;
// Fetch order details
final orderResponse = await client.get('/id/orders/$orderId');
final order = SnWalletOrder.fromJson(orderResponse.data);
if (context.mounted) {
hideLoadingModal(context);
// Show payment overlay
final paidOrder = await PaymentOverlay.show(
context: context,
order: order,
enableBiometric: true,
);
if (paidOrder != null && context.mounted) {
showSnackBar('awardSuccess'.tr());
Navigator.of(context).pop();
}
}
} catch (err) {
if (context.mounted) {
hideLoadingModal(context);
showErrorAlert(err);
}
}
}
}