🎨 Use feature based folder structure

This commit is contained in:
2026-02-06 00:37:02 +08:00
parent 62a3ea26e3
commit 862e3b451b
539 changed files with 8406 additions and 5056 deletions

View File

@@ -0,0 +1,456 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/main.dart';
import 'package:island/accounts/accounts_models/account.dart';
import 'package:island/core/config.dart';
import 'package:island/core/notification.dart';
import 'package:island/talker.dart';
import 'package:just_audio/just_audio.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:top_snackbar_flutter/top_snack_bar.dart';
import 'package:url_launcher/url_launcher.dart';
void showSnackBar(String message, {SnackBarAction? action}) {
final context = globalOverlay.currentState!.context;
final screenWidth = MediaQuery.of(context).size.width;
final padding = 40.0;
final availableWidth = screenWidth - padding;
showTopSnackBar(
globalOverlay.currentState!,
Center(
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: availableWidth.clamp(0, 400),
maxWidth: availableWidth.clamp(0, 600),
),
child: Card(
elevation: 2,
color: Theme.of(context).colorScheme.surfaceContainer,
child: Text(message).padding(horizontal: 20, vertical: 16),
),
),
),
displayDuration: const Duration(milliseconds: 1500),
animationDuration: const Duration(milliseconds: 300),
reverseAnimationDuration: const Duration(milliseconds: 300),
curve: Curves.fastLinearToSlowEaseIn,
dismissType: DismissType.onTap,
snackBarPosition: SnackBarPosition.bottom,
);
}
OverlayEntry? _loadingOverlay;
GlobalKey<_FadeOverlayState> _loadingOverlayKey = GlobalKey();
class _FadeOverlay extends StatefulWidget {
const _FadeOverlay({
super.key,
this.child,
this.builder,
this.duration = const Duration(milliseconds: 200),
this.curve = Curves.linear,
}) : assert(child != null || builder != null);
final Widget? child;
final Widget Function(BuildContext, Animation<double>)? builder;
final Duration duration;
final Curve curve;
@override
State<_FadeOverlay> createState() => _FadeOverlayState();
}
class _FadeOverlayState extends State<_FadeOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: widget.duration);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> animateOut() async {
await _controller.reverse();
}
@override
Widget build(BuildContext context) {
final animation = CurvedAnimation(parent: _controller, curve: widget.curve);
if (widget.builder != null) {
return widget.builder!(context, animation);
}
return FadeTransition(opacity: animation, child: widget.child);
}
}
void showLoadingModal(BuildContext context) {
if (_loadingOverlay != null) return;
_loadingOverlay = OverlayEntry(
builder: (context) => _FadeOverlay(
key: _loadingOverlayKey,
child: Material(
color: Colors.black54,
child: Center(
child: AlertDialog(
content: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
year2023: false,
padding: EdgeInsets.zero,
).width(28).height(28).padding(horizontal: 8),
const Gap(16),
Text('loading'.tr()),
],
),
contentPadding: EdgeInsets.symmetric(horizontal: 32, vertical: 24),
),
),
),
),
);
Overlay.of(context).insert(_loadingOverlay!);
}
void hideLoadingModal(BuildContext context) async {
if (_loadingOverlay == null) return;
final entry = _loadingOverlay!;
_loadingOverlay = null;
final state = entry.mounted ? _loadingOverlayKey.currentState : null;
if (state != null) {
await state.animateOut();
}
entry.remove();
}
String _parseRemoteError(DioException err) {
String? message;
if (err.response?.data is String) {
message = err.response?.data;
} else if (err.response?.data?['message'] != null) {
message = <String?>[
err.response?.data?['message']?.toString(),
err.response?.data?['detail']?.toString(),
].where((e) => e != null).cast<String>().map((e) => e.trim()).join('\n');
} else if (err.response?.data?['errors'] != null) {
final errors = err.response?.data['errors'] as Map<String, dynamic>;
message = errors.values
.map(
(ele) =>
(ele as List<dynamic>).map((ele) => ele.toString()).join('\n'),
)
.join('\n');
}
if (message == null || message.isEmpty) message = err.response?.statusMessage;
message ??= err.message;
return message ?? err.toString();
}
final List<void Function()> _activeOverlayDialogs = [];
Future<T?> showOverlayDialog<T>({
required Widget Function(BuildContext context, void Function(T? result) close)
builder,
bool barrierDismissible = true,
}) {
final completer = Completer<T?>();
final key = GlobalKey<_FadeOverlayState>();
late OverlayEntry entry;
void close(T? result) async {
if (completer.isCompleted) return;
final state = key.currentState;
if (state != null) {
await state.animateOut();
}
entry.remove();
_activeOverlayDialogs.remove(close);
completer.complete(result);
}
entry = OverlayEntry(
builder: (context) => _FadeOverlay(
key: key,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
builder: (context, animation) {
return Stack(
children: [
Positioned.fill(
child: FadeTransition(
opacity: animation,
child: GestureDetector(
onTap: barrierDismissible ? () => close(null) : null,
behavior: HitTestBehavior.opaque,
child: const ColoredBox(color: Colors.black54),
),
),
),
Center(
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.05),
end: Offset.zero,
).animate(animation),
child: FadeTransition(
opacity: animation,
child: builder(context, close),
),
),
),
],
);
},
),
);
_activeOverlayDialogs.add(() => close(null));
globalOverlay.currentState!.insert(entry);
return completer.future;
}
bool closeTopmostOverlayDialog() {
if (_activeOverlayDialogs.isNotEmpty) {
final closeFunc = _activeOverlayDialogs.last;
closeFunc();
return true;
}
return false;
}
const kDialogMaxWidth = 480.0;
Future<void> _playSfx(String assetPath, double volume) async {
final player = AudioPlayer();
await player.setVolume(volume);
await player.setAudioSource(AudioSource.asset(assetPath));
await player.play();
await player.dispose();
}
void showErrorAlert(dynamic err, {IconData? icon}) {
final context = globalOverlay.currentState!.context;
final ref = ProviderScope.containerOf(context);
final settings = ref.read(appSettingsProvider);
if (settings.soundEffects) {
unawaited(_playSfx('assets/audio/alert.reversed.wav', 0.75));
}
if (err is Error) {
talker.error('Something went wrong...', err, err.stackTrace);
}
final text = switch (err) {
String _ => err,
DioException _ => _parseRemoteError(err),
Exception _ => err.toString(),
_ => err.toString(),
};
showOverlayDialog<void>(
builder: (context, close) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kDialogMaxWidth),
child: AlertDialog(
title: null,
titlePadding: EdgeInsets.zero,
contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon ?? Icons.error_outline_rounded,
size: 48,
color: Theme.of(context).colorScheme.error,
),
const Gap(16),
Text(
'somethingWentWrong'.tr(),
style: Theme.of(context).textTheme.titleLarge,
),
const Gap(8),
SelectableText(text),
const Gap(8),
],
),
),
actions: [
TextButton(
onPressed: () => close(null),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
),
),
);
}
void showInfoAlert(String message, String title, {IconData? icon}) {
final context = globalOverlay.currentState!.context;
final ref = ProviderScope.containerOf(context);
final settings = ref.read(appSettingsProvider);
if (settings.soundEffects) {
unawaited(_playSfx('assets/audio/alert.wav', 0.75));
}
showOverlayDialog<void>(
builder: (context, close) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kDialogMaxWidth),
child: AlertDialog(
title: null,
titlePadding: EdgeInsets.zero,
contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon ?? Symbols.info_rounded,
fill: 1,
size: 48,
color: Theme.of(context).colorScheme.primary,
),
const Gap(16),
Text(title, style: Theme.of(context).textTheme.titleLarge),
const Gap(8),
Text(message),
const Gap(8),
],
),
actions: [
TextButton(
onPressed: () => close(null),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
),
),
);
}
Future<bool> showConfirmAlert(
String message,
String title, {
IconData? icon,
bool isDanger = false,
}) async {
final context = globalOverlay.currentState!.context;
final ref = ProviderScope.containerOf(context);
final settings = ref.read(appSettingsProvider);
if (settings.soundEffects) {
unawaited(_playSfx('assets/audio/alert.wav', 0.75));
}
final result = await showOverlayDialog<bool>(
builder: (context, close) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kDialogMaxWidth),
child: AlertDialog(
title: null,
titlePadding: EdgeInsets.zero,
contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon ?? Symbols.help_rounded,
size: 48,
fill: 1,
color: Theme.of(context).colorScheme.primary,
),
const Gap(16),
Text(title, style: Theme.of(context).textTheme.titleLarge),
const Gap(8),
Text(message),
const Gap(8),
],
),
actions: [
TextButton(
onPressed: () => close(false),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () => close(true),
style: isDanger
? TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
)
: null,
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
),
),
);
return result ?? false;
}
void showNotification({
required String title,
String content = '',
String subtitle = '',
Map<String, dynamic> meta = const {},
Duration? duration,
}) {
final context = globalOverlay.currentState!.context;
final ref = ProviderScope.containerOf(context);
final notification = SnNotification(
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
deletedAt: null,
id: 'local_${DateTime.now().millisecondsSinceEpoch}',
topic: 'local',
title: title,
subtitle: subtitle,
content: content,
meta: meta,
priority: 0,
viewedAt: null,
accountId: 'local',
);
ref
.read(notificationStateProvider.notifier)
.add(notification, duration: duration);
}
Future<void> openExternalLink(Uri url, WidgetRef ref) async {
final whitelistDomains = ['solian.app', 'solsynth.dev'];
if (whitelistDomains.any(
(domain) => url.host == domain || url.host.endsWith('.$domain'),
)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
final value = await showConfirmAlert(
'openLinkConfirmDescription'.tr(args: [url.toString()]),
'openLinkConfirm'.tr(),
);
if (value) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
}
}

View File

@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/accounts/accounts_models/account.dart';
import 'package:island/route.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
class NotificationCard extends HookConsumerWidget {
final SnNotification notification;
const NotificationCard({super.key, required this.notification});
@override
Widget build(BuildContext context, WidgetRef ref) {
final icon = Symbols.info;
return GestureDetector(
onTap: () {
if (notification.meta['action_uri'] != null) {
var uri = notification.meta['action_uri'] as String;
if (uri.startsWith('solian://')) {
uri = uri.replaceFirst('solian://', '');
}
if (uri.startsWith('/')) {
// In-app routes
rootNavigatorKey.currentContext?.push(
notification.meta['action_uri'],
);
} else {
// External URLs
launchUrlString(uri);
}
}
},
child: Card(
elevation: 4,
margin: const EdgeInsets.only(bottom: 8),
color: Theme.of(context).colorScheme.surfaceContainer,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (notification.meta['pfp'] != null)
ProfilePictureWidget(
fileId: notification.meta['pfp'],
radius: 12,
).padding(right: 12, top: 2)
else
Icon(
icon,
color: Theme.of(context).colorScheme.primary,
size: 24,
).padding(right: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
notification.title,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
if (notification.content.isNotEmpty)
Text(
notification.content,
style: Theme.of(context).textTheme.bodyMedium,
),
if (notification.subtitle.isNotEmpty)
Text(
notification.subtitle,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,640 @@
import 'dart:io';
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:island/command_palette/palette.dart';
import 'package:island/core/config.dart';
import 'package:island/route.dart';
import 'package:island/accounts/accounts_pod.dart';
import 'package:island/core/websocket.dart';
import 'package:island/core/services/event_bus.dart';
import 'package:island/core/services/responsive.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/notifications/notification_overlay.dart';
import 'package:island/shared/widgets/task_overlay.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shake/shake.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:window_manager/window_manager.dart';
class AppScrollBehavior extends MaterialScrollBehavior {
@override
Set<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.touch, // default
PointerDeviceKind.trackpad, // default
PointerDeviceKind.mouse, // add mouse dragging
};
}
class WindowScaffold extends HookConsumerWidget {
final Widget child;
const WindowScaffold({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isMaximized = useState(false);
final showPalette = useState(false);
final keyboardFocusNode = useFocusNode();
useEffect(() {
keyboardFocusNode.requestFocus();
return null;
}, []);
// Add window resize listener for desktop platforms
useEffect(() {
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
void saveWindowSize() {
windowManager.getBounds().then((bounds) {
final settingsNotifier = ref.read(appSettingsProvider.notifier);
settingsNotifier.setWindowSize(bounds.size);
});
}
// Save window size when app is about to close
WidgetsBinding.instance.addObserver(
_WindowSizeObserver(saveWindowSize),
);
final maximizeListener = _WindowMaximizeListener(isMaximized);
windowManager.addListener(maximizeListener);
windowManager.isMaximized().then((max) => isMaximized.value = max);
return () {
// Cleanup observer when widget is disposed
WidgetsBinding.instance.removeObserver(
_WindowSizeObserver(saveWindowSize),
);
windowManager.removeListener(maximizeListener);
};
}
return null;
}, []);
// Event bus listener for command palette
final subscription = useMemoized(
() => eventBus.on<CommandPaletteTriggerEvent>().listen(
(_) => showPalette.value = true,
),
[],
);
useEffect(() => subscription.cancel, [subscription]);
final router = ref.watch(routerProvider);
final pageActionsButton = [
if (router.canPop())
IconButton(
icon: Icon(Symbols.close),
onPressed: router.canPop() ? () => router.pop() : null,
iconSize: 16,
padding: EdgeInsets.all(8),
constraints: BoxConstraints(),
color: Theme.of(context).iconTheme.color,
)
else
IconButton(
icon: Icon(Symbols.home),
onPressed: () => router.go('/'),
iconSize: 16,
padding: EdgeInsets.all(8),
constraints: BoxConstraints(),
color: Theme.of(context).iconTheme.color,
),
const Gap(8),
];
final popHotKey = HotKey(
identifier: 'return_previous_page',
key: PhysicalKeyboardKey.escape,
scope: HotKeyScope.inapp,
);
final cmpHotKey = HotKey(
identifier: 'open_command_pattle',
key: PhysicalKeyboardKey.tab,
modifiers: [HotKeyModifier.shift],
scope: HotKeyScope.inapp,
);
useEffect(() {
if (kIsWeb) return null;
hotKeyManager.register(
popHotKey,
keyDownHandler: (_) {
if (closeTopmostOverlayDialog()) {
return;
}
// If no overlay to close, pop the route
if (ref.watch(routerProvider).canPop()) {
ref.read(routerProvider).pop();
}
},
);
hotKeyManager.register(
cmpHotKey,
keyDownHandler: (_) {
showPalette.value = true;
},
);
ShakeDetector? detactor;
if (!kIsWeb && (Platform.isIOS && Platform.isAndroid)) {
detactor = ShakeDetector.autoStart(
onPhoneShake: (_) {
showPalette.value = true;
},
);
}
return () {
hotKeyManager.unregister(popHotKey);
hotKeyManager.unregister(cmpHotKey);
detactor?.stopListening();
};
}, []);
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
return Material(
color: Theme.of(context).colorScheme.surfaceContainer,
child: Stack(
fit: StackFit.expand,
children: [
Column(
children: [
DragToMoveArea(
child: Platform.isMacOS
? Stack(
alignment: Alignment.center,
children: [
if (isWideScreen(context))
Row(
children: [
const Spacer(),
...pageActionsButton,
],
)
else
SizedBox(height: 32),
Text(
'Solar Network',
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Expanded(
child: Row(
children: [
Image.asset(
Theme.of(context).brightness ==
Brightness.dark
? 'assets/icons/icon-dark.png'
: 'assets/icons/icon.png',
width: 20,
height: 20,
),
const SizedBox(width: 8),
Text(
'Solar Network',
textAlign: TextAlign.start,
),
],
).padding(horizontal: 12, vertical: 5),
),
IconButton(
icon: Icon(Symbols.minimize),
onPressed: () => windowManager.minimize(),
iconSize: 16,
padding: EdgeInsets.all(8),
constraints: BoxConstraints(),
color: Theme.of(context).iconTheme.color,
),
IconButton(
icon: Icon(
isMaximized.value
? Symbols.fullscreen_exit
: Symbols.fullscreen,
),
onPressed: () async {
if (await windowManager.isMaximized()) {
windowManager.restore();
} else {
windowManager.maximize();
}
},
iconSize: 16,
padding: EdgeInsets.all(8),
constraints: BoxConstraints(),
color: Theme.of(context).iconTheme.color,
),
IconButton(
icon: Icon(Symbols.close),
onPressed: () => windowManager.hide(),
iconSize: 16,
padding: EdgeInsets.all(8),
constraints: BoxConstraints(),
color: Theme.of(context).iconTheme.color,
),
],
),
),
Expanded(child: child),
],
),
_WebSocketIndicator(),
const TaskOverlay(),
const NotificationOverlay(),
if (showPalette.value)
CommandPaletteWidget(onDismiss: () => showPalette.value = false),
],
),
);
}
return Stack(
fit: StackFit.expand,
children: [
Positioned.fill(child: child),
_WebSocketIndicator(),
const TaskOverlay(),
const NotificationOverlay(),
if (showPalette.value)
CommandPaletteWidget(onDismiss: () => showPalette.value = false),
],
);
}
}
class _WindowSizeObserver extends WidgetsBindingObserver {
final VoidCallback onSaveWindowSize;
_WindowSizeObserver(this.onSaveWindowSize);
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
// Save window size when app is paused, detached, or hidden
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached ||
state == AppLifecycleState.hidden) {
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
onSaveWindowSize();
}
}
}
@override
bool operator ==(Object other) {
return other is _WindowSizeObserver &&
other.onSaveWindowSize == onSaveWindowSize;
}
@override
int get hashCode => onSaveWindowSize.hashCode;
}
class _WindowMaximizeListener with WindowListener {
final ValueNotifier<bool> isMaximized;
_WindowMaximizeListener(this.isMaximized);
@override
void onWindowMaximize() {
isMaximized.value = true;
}
@override
void onWindowUnmaximize() {
isMaximized.value = false;
}
}
final rootScaffoldKey = GlobalKey<ScaffoldState>();
class AppScaffold extends HookConsumerWidget {
final Widget? body;
final PreferredSizeWidget? bottomNavigationBar;
final PreferredSizeWidget? bottomSheet;
final Drawer? drawer;
final Widget? endDrawer;
final FloatingActionButtonAnimator? floatingActionButtonAnimator;
final FloatingActionButtonLocation? floatingActionButtonLocation;
final Widget? floatingActionButton;
final PreferredSizeWidget? appBar;
final DrawerCallback? onDrawerChanged;
final DrawerCallback? onEndDrawerChanged;
final bool? isNoBackground;
final bool? extendBody;
const AppScaffold({
super.key,
this.appBar,
this.body,
this.floatingActionButton,
this.floatingActionButtonLocation,
this.floatingActionButtonAnimator,
this.bottomNavigationBar,
this.bottomSheet,
this.drawer,
this.endDrawer,
this.onDrawerChanged,
this.onEndDrawerChanged,
this.isNoBackground,
this.extendBody,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final appBarHeight = appBar?.preferredSize.height ?? 0;
final safeTop = MediaQuery.of(context).padding.top;
final noBackground = isNoBackground ?? isWideScreen(context);
final builtWidget = Scaffold(
extendBody: extendBody ?? true,
extendBodyBehindAppBar: true,
backgroundColor: Colors.transparent,
body: Column(
children: [
IgnorePointer(
child: SizedBox(
height: appBar != null ? appBarHeight + safeTop : 0,
),
),
if (body != null) Expanded(child: body!),
],
),
appBar: appBar,
bottomNavigationBar: bottomNavigationBar,
bottomSheet: bottomSheet,
drawer: drawer,
endDrawer: endDrawer,
floatingActionButton: floatingActionButton,
floatingActionButtonAnimator: floatingActionButtonAnimator,
onDrawerChanged: onDrawerChanged,
onEndDrawerChanged: onEndDrawerChanged,
);
return noBackground
? builtWidget
: AppBackground(isRoot: true, child: builtWidget);
}
}
class PageBackButton extends StatelessWidget {
final Color? color;
final List<Shadow>? shadows;
final VoidCallback? onWillPop;
final String? backTo;
const PageBackButton({
super.key,
this.shadows,
this.onWillPop,
this.color,
this.backTo,
});
@override
Widget build(BuildContext context) {
final hasPageAction = !kIsWeb && Platform.isMacOS;
if (hasPageAction && isWideScreen(context)) return const SizedBox.shrink();
return IconButton(
onPressed: () {
onWillPop?.call();
if (context.canPop()) {
context.pop();
} else {
context.go(backTo ?? '/');
}
},
icon: Icon(
color: color,
context.canPop()
? (!kIsWeb && (Platform.isMacOS || Platform.isIOS))
? Symbols.arrow_back_ios_new
: Symbols.arrow_back
: Symbols.home,
shadows: shadows,
),
);
}
}
const kAppBackgroundImagePath = 'island_app_background';
final backgroundImageFileProvider = FutureProvider<File?>((ref) async {
if (kIsWeb) return null;
final dir = await getApplicationSupportDirectory();
final path = '${dir.path}/$kAppBackgroundImagePath';
final file = File(path);
return file.existsSync() ? file : null;
});
class AppBackground extends ConsumerWidget {
final Widget child;
final bool isRoot;
const AppBackground({super.key, required this.child, this.isRoot = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
final imageFileAsync = ref.watch(backgroundImageFileProvider);
final settings = ref.watch(appSettingsProvider);
if (isRoot || !isWideScreen(context)) {
return imageFileAsync.when(
data: (file) {
if (file != null && settings.showBackgroundImage) {
return Container(
color: Theme.of(context).colorScheme.surface,
child: Container(
decoration: BoxDecoration(
backgroundBlendMode: BlendMode.darken,
color: Theme.of(context).colorScheme.surface,
image: DecorationImage(
opacity: 0.2,
image: FileImage(file),
fit: BoxFit.cover,
),
),
child: child,
),
);
}
return Material(
color: Theme.of(context).colorScheme.surface,
child: child,
);
},
loading: () => const SizedBox(),
error: (_, _) => Material(
color: Theme.of(context).colorScheme.surface,
child: child,
),
);
}
return Material(color: Colors.transparent, child: child);
}
}
class EmptyPageHolder extends HookConsumerWidget {
const EmptyPageHolder({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasBackground = ref.watch(backgroundImageFileProvider).value != null;
if (hasBackground) {
return const SizedBox.shrink();
}
return Container(color: Theme.of(context).scaffoldBackgroundColor);
}
}
class _WebSocketIndicator extends HookConsumerWidget {
const _WebSocketIndicator();
@override
Widget build(BuildContext context, WidgetRef ref) {
final isDesktop =
!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux);
final devicePadding = MediaQuery.of(context).padding;
final user = ref.watch(userInfoProvider);
final websocketState = ref.watch(websocketStateProvider);
Color indicatorColor;
String indicatorText;
Widget indicatorIcon;
bool isInteractive = true;
double opacity = 0.0;
if (websocketState == WebSocketState.connected()) {
indicatorColor = Colors.green;
indicatorText = 'connectionConnected';
indicatorIcon = Icon(
key: ValueKey('ws_connected'),
Symbols.power,
color: Colors.white,
size: 16,
);
opacity = 0.0;
isInteractive = false;
} else if (websocketState == WebSocketState.connecting()) {
indicatorColor = Colors.teal;
indicatorText = 'connectionReconnecting';
indicatorIcon = SizedBox(
key: ValueKey('ws_connecting'),
width: 16,
height: 16,
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
strokeWidth: 2,
padding: EdgeInsets.zero,
),
);
opacity = 1.0;
isInteractive = false;
} else if (websocketState == WebSocketState.serverDown()) {
indicatorColor = Colors.red;
indicatorText = 'connectionServerDown';
isInteractive = true;
indicatorIcon = Icon(
key: ValueKey('ws_server_down'),
Symbols.power_off,
color: Colors.white,
size: 16,
);
opacity = 1.0;
} else {
indicatorColor = Colors.red;
indicatorText = 'connectionDisconnected';
indicatorIcon = Icon(
key: ValueKey('ws_disconnected'),
Symbols.power_off,
color: Colors.white,
size: 16,
);
opacity = 1.0;
isInteractive = false;
}
if (user.value == null) {
opacity = 0.0;
}
return Positioned(
top: devicePadding.top + (isDesktop ? 27.5 : 25),
left: 0,
right: 0,
child: IgnorePointer(
ignoring: !isInteractive,
child: Align(
alignment: Alignment.topCenter,
child: AnimatedOpacity(
duration: Duration(milliseconds: 300),
opacity: opacity,
child: Material(
elevation:
user.value == null ||
websocketState == WebSocketState.connected()
? 0
: 4,
borderRadius: BorderRadius.circular(999),
child: GestureDetector(
onTap: () {
ref.read(websocketStateProvider.notifier).manualReconnect();
},
child: Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: indicatorColor,
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
spacing: 8,
children: [
AnimatedSwitcher(
duration: Duration(milliseconds: 300),
child: indicatorIcon,
),
Text(
indicatorText,
style: TextStyle(color: Colors.white, fontSize: 13),
).tr(),
],
),
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,349 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:in_app_review/in_app_review.dart';
import 'package:island/auth/web_auth/web_auth_providers.dart';
import 'package:island/notifications/notification.dart';
import 'package:island/thought/thought/think_sheet.dart';
import 'package:protocol_handler/protocol_handler.dart';
import 'package:island/activity/activity_rpc.dart';
import 'package:island/core/config.dart';
import 'package:island/core/network.dart';
import 'package:island/core/websocket.dart';
import 'package:island/route.dart';
import 'package:island/auth/login_content.dart';
import 'package:island/settings/tray_manager.dart';
import 'package:island/core/services/notify.dart';
import 'package:island/core/services/sharing_intent.dart';
import 'package:island/core/services/update_service.dart';
import 'package:island/core/widgets/content/network_status_sheet.dart';
import 'package:island/core/tour/tour.dart';
import 'package:island/posts/posts_widgets/compose_sheet.dart';
import 'package:island/core/services/event_bus.dart';
import 'package:snow_fall_animation/snow_fall_animation.dart';
import 'package:tray_manager/tray_manager.dart';
import 'package:window_manager/window_manager.dart';
class AppWrapper extends HookConsumerWidget {
final Widget child;
const AppWrapper({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
final networkStateShowing = useState(false);
final websocketState = ref.watch(websocketStateProvider);
final apiState = ref.watch(networkStatusProvider);
final isShowSnow = useState(false);
final isSnowGone = useState(false);
// Handle network status modal
useEffect(() {
bool triedOpen = false;
if (websocketState == WebSocketState.duplicateDevice() &&
!networkStateShowing.value &&
!triedOpen) {
networkStateShowing.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => NetworkStatusSheet(autoClose: true),
).then((_) => networkStateShowing.value = false);
});
triedOpen = true;
}
if (apiState != NetworkStatus.online &&
!networkStateShowing.value &&
!triedOpen) {
networkStateShowing.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => const NetworkStatusSheet(),
).then((_) => networkStateShowing.value = false);
});
triedOpen = true;
}
return null;
}, [websocketState, apiState]);
// Initialize services and listeners
useEffect(() {
final ntySubs = setupNotificationListener(context, ref);
final sharingService = SharingIntentService();
sharingService.initialize(context);
UpdateService().checkForUpdates(context);
final trayService = TrayService.instance;
trayService.initialize(
_TrayListenerImpl(
onTrayIconMouseDown: () => windowManager.show(),
onTrayIconRightMouseUp: () => trayManager.popUpContextMenu(),
onTrayMenuItemClick: (menuItem) => trayService.handleAction(menuItem),
),
);
ref.read(rpcServerStateProvider.notifier).start();
ref.read(webAuthServerStateProvider.notifier).start();
// Listen to special action events
final composeSheetSubs = eventBus.on<ShowComposeSheetEvent>().listen((
event,
) {
if (context.mounted) _showComposeSheet(context);
});
final notificationSheetSubs = eventBus
.on<ShowNotificationSheetEvent>()
.listen((event) {
if (context.mounted) _showNotificationSheet(context);
});
final thoughtSheetSubs = eventBus.on<ShowThoughtSheetEvent>().listen((
event,
) {
if (context.mounted) _showThoughtSheet(context, event);
});
// Protocol handler listener
final protocolListener = _ProtocolListenerImpl(
onProtocolUrlReceived: (url) =>
_handleDeepLink(Uri.parse(url), ref, context),
);
protocolHandler.addListener(protocolListener);
// Handle initial URL
protocolHandler.getInitialUrl().then((initialUrl) {
if (initialUrl != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleDeepLink(Uri.parse(initialUrl), ref, context);
});
}
});
return () {
protocolHandler.removeListener(protocolListener);
ref.read(rpcServerProvider).stop();
trayService.dispose(
_TrayListenerImpl(
onTrayIconMouseDown: () => {},
onTrayIconRightMouseUp: () => {},
onTrayMenuItemClick: (menuItem) => {},
),
);
ntySubs?.cancel();
composeSheetSubs.cancel();
notificationSheetSubs.cancel();
thoughtSheetSubs.cancel();
};
}, []);
final settings = ref.watch(appSettingsProvider);
final settingsNotifier = ref.watch(appSettingsProvider.notifier);
useEffect(() {
if (settings.defaultScreen != null &&
settings.defaultScreen != 'dashboard') {
Future(() {
ref.read(routerProvider).goNamed(settings.defaultScreen!);
});
}
return null;
}, []);
final now = DateTime.now();
final doesShowSnow =
settings.festivalFeatures &&
now.month == 12 &&
(now.day >= 22 && now.day <= 28);
useEffect(() {
Future(() {
final now = DateTime.now();
if (doesShowSnow) {
isShowSnow.value = true;
Future.delayed(const Duration(seconds: 60), () {
if (!context.mounted) return;
isShowSnow.value = false;
Future.delayed(const Duration(seconds: 3), () {
if (!context.mounted) return;
isSnowGone.value = true;
});
});
}
if (settings.firstLaunchAt == null) {
settingsNotifier.setFirstLaunchAt(now.toIso8601String());
} else if (!settings.askedReview) {
final launchAt = DateTime.parse(settings.firstLaunchAt!);
final daysSinceFirstLaunch = now.difference(launchAt).inDays;
if (daysSinceFirstLaunch >= 3 &&
!kIsWeb &&
(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) {
final InAppReview inAppReview = InAppReview.instance;
Future(() async {
if (await inAppReview.isAvailable()) {
inAppReview.requestReview();
}
});
settingsNotifier.setAskedReview(true);
}
}
});
return null;
}, []);
return TourTriggerWidget(
key: const Key("app_tour_trigger"),
child: Stack(
children: [
child,
if (doesShowSnow && !isSnowGone.value)
IgnorePointer(
child: AnimatedOpacity(
opacity: isShowSnow.value ? 1 : 00,
duration: const Duration(seconds: 3),
child: SnowFallAnimation(
key: const Key("app_snow_animation"),
config: SnowfallConfig(numberOfSnowflakes: 50, speed: 1.0),
),
),
),
],
),
);
}
void _showComposeSheet(BuildContext context) {
PostComposeSheet.show(context);
}
void _showNotificationSheet(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => const NotificationSheet(),
);
}
void _showThoughtSheet(BuildContext context, ShowThoughtSheetEvent event) {
ThoughtSheet.show(
context,
initialMessage: event.initialMessage,
attachedMessages: event.attachedMessages,
attachedPosts: event.attachedPosts,
);
}
void _handleDeepLink(Uri uri, WidgetRef ref, BuildContext context) async {
String path = '/${uri.host}${uri.path}';
// Special handling for OIDC auth callback
if (path == '/auth/callback' && uri.queryParameters.containsKey('token')) {
final token = uri.queryParameters['token']!;
setToken(ref.read(sharedPreferencesProvider), token);
ref.invalidate(tokenProvider);
// Do post login tasks
await performPostLogin(context, ref);
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
windowManager.show();
}
return;
}
// Special handling for share intent deep links
// Share intents are handled by SharingIntentService showing a modal,
// not by routing to a page
if (path == '/share') {
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
windowManager.show();
}
return;
}
if (path == '/notifications') {
eventBus.fire(ShowNotificationSheetEvent());
return;
}
final router = ref.read(routerProvider);
if (path == '/dashboard') {
router.go('/');
return;
}
// Handle bottom navigation routes properly to prevent navigation bar disappearance
// These routes should navigate within the bottom navigation shell
final bottomNavRoutes = ['/', '/explore', '/chat', '/realms', '/account'];
if (bottomNavRoutes.contains(path)) {
// Navigate within the bottom navigation shell using go() to maintain shell context
router.go(path);
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
windowManager.show();
}
return;
}
if (uri.queryParameters.isNotEmpty) {
path = Uri.parse(
path,
).replace(queryParameters: uri.queryParameters).toString();
}
// For non-bottom navigation routes, use push() to navigate outside the shell
router.push(path);
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
windowManager.show();
}
}
}
class _TrayListenerImpl implements TrayListener {
final VoidCallback _primaryAction;
final VoidCallback _secondaryAction;
final void Function(MenuItem) _onTrayMenuItemClick;
_TrayListenerImpl({
required VoidCallback onTrayIconMouseDown,
required VoidCallback onTrayIconRightMouseUp,
required void Function(MenuItem) onTrayMenuItemClick,
}) : _primaryAction = onTrayIconMouseDown,
_secondaryAction = onTrayIconRightMouseUp,
_onTrayMenuItemClick = onTrayMenuItemClick;
@override
void onTrayIconMouseDown() => _primaryAction();
@override
void onTrayIconRightMouseUp() => _secondaryAction();
@override
void onTrayIconMouseUp() => _primaryAction();
@override
void onTrayIconRightMouseDown() => _secondaryAction();
@override
void onTrayMenuItemClick(MenuItem menuItem) => _onTrayMenuItemClick(menuItem);
}
class _ProtocolListenerImpl implements ProtocolListener {
final void Function(String) _onProtocolUrlReceived;
_ProtocolListenerImpl({required void Function(String) onProtocolUrlReceived})
: _onProtocolUrlReceived = onProtocolUrlReceived;
@override
void onProtocolUrlReceived(String url) => _onProtocolUrlReceived(url);
}

View File

@@ -0,0 +1,377 @@
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/drive/drive_models/file.dart';
import 'package:island/drive/drive_models/file_pool.dart';
import 'package:island/drive/drive/file_pool.dart';
import 'package:island/core/widgets/content/attachment_preview.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:gap/gap.dart';
import 'package:island/posts/posts_widgets/post/compose_shared.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 List<UniversalFile>? attachments;
final int index;
const AttachmentUploaderSheet({
super.key,
required this.ref,
this.state,
this.attachments,
required this.index,
}) : assert(
state != null || attachments != null,
'Either state or attachments must be provided',
);
@override
State<AttachmentUploaderSheet> createState() =>
_AttachmentUploaderSheetState();
}
class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
String? selectedPoolId;
@override
Widget build(BuildContext context) {
final attachment =
widget.attachments?[widget.index] ??
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!;
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?)
?.cast<String>();
final mimeType =
attachment.data.mimeType ??
ComposeLogic.getMimeTypeFromFileType(
attachment.type,
);
final typeAccepted = _isMimeTypeAccepted(
mimeType,
acceptTypes,
);
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] ??
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.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?)
?.cast<String>();
final mimeType =
attachment.data.mimeType ??
ComposeLogic.getMimeTypeFromFileType(attachment.type);
final typeAccepted = _isMimeTypeAccepted(mimeType, acceptTypes);
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);
}
bool _isMimeTypeAccepted(String mimeType, List<String>? acceptTypes) {
if (acceptTypes == null || acceptTypes.isEmpty) return true;
return acceptTypes.any((type) {
if (type.endsWith('/*')) {
final mainType = type.substring(0, type.length - 2);
return mimeType.startsWith('$mainType/');
} else {
return mimeType == type;
}
});
}
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
class EmptyState extends StatelessWidget {
final IconData icon;
final String title;
final String description;
final Widget? action;
const EmptyState({
super.key,
required this.icon,
required this.title,
required this.description,
this.action,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 64,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 16),
Text(
title,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
description,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
if (action != null) ...[
const SizedBox(height: 24),
action!,
],
],
),
),
);
}
}

View File

@@ -0,0 +1,57 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:uuid/uuid.dart';
class RefreshIntent extends Intent {
const RefreshIntent();
}
class ExtendedRefreshIndicator extends HookConsumerWidget {
final Widget child;
final RefreshCallback onRefresh;
const ExtendedRefreshIndicator({
super.key,
required this.child,
required this.onRefresh,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hotKeyIdentifier = useState(Uuid().v4().substring(0, 8));
final refreshHotKey = HotKey(
identifier: 'refresh_indicator_$hotKeyIdentifier',
key: PhysicalKeyboardKey.keyR,
modifiers: [
(!kIsWeb && Platform.isMacOS)
? HotKeyModifier.meta
: HotKeyModifier.control,
],
scope: HotKeyScope.inapp,
);
useEffect(() {
if (kIsWeb) return null;
hotKeyManager.register(
refreshHotKey,
keyDownHandler: (_) {
onRefresh.call();
},
);
return () {
hotKeyManager.unregister(refreshHotKey);
};
}, []);
return RefreshIndicator(onRefresh: onRefresh, child: child);
}
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
class InfoRow extends StatelessWidget {
final String label;
final String? value;
final IconData icon;
final bool monospace;
final VoidCallback? onTap;
const InfoRow({
super.key,
required this.label,
this.value,
required this.icon,
this.monospace = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
Widget? valueWidget =
value == null
? null
: Text(
value!,
style:
monospace
? GoogleFonts.robotoMono(fontSize: 14)
: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.end,
);
if (onTap != null) valueWidget = InkWell(onTap: onTap, child: valueWidget);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
const Gap(12),
Expanded(
flex: 2,
child: Text(
label,
style:
valueWidget == null
? null
: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
if (valueWidget != null) const Gap(12),
if (valueWidget != null) Expanded(flex: 3, child: valueWidget),
],
);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
/// A simple loading indicator widget that can be used throughout the app
class LoadingIndicator extends StatelessWidget {
/// The size of the loading indicator
final double size;
/// The color of the loading indicator
final Color? color;
/// Creates a loading indicator
const LoadingIndicator({
super.key,
this.size = 24.0,
this.color,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(
strokeWidth: 2.0,
valueColor: color != null
? AlwaysStoppedAnimation<Color>(
color!,
)
: null,
),
);
}
}

View File

@@ -0,0 +1,402 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_riverpod/misc.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pagination/pagination.dart';
import 'package:island/shared/widgets/extended_refresh_indicator.dart';
import 'package:island/shared/widgets/response.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
import 'package:visibility_detector/visibility_detector.dart';
class PaginationList<T> extends HookConsumerWidget {
final ProviderListenable<AsyncValue<PaginationState<T>>> provider;
final Refreshable<PaginationController<T>> notifier;
final Widget? Function(BuildContext, int, T) itemBuilder;
final Widget? Function(BuildContext, int, T)? seperatorBuilder;
final double? spacing;
final bool isRefreshable;
final bool isSliver;
final bool showDefaultWidgets;
final EdgeInsets? padding;
final Widget? footerSkeletonChild;
final double? footerSkeletonMaxWidth;
const PaginationList({
super.key,
required this.provider,
required this.notifier,
required this.itemBuilder,
this.seperatorBuilder,
this.spacing,
this.isRefreshable = true,
this.isSliver = false,
this.showDefaultWidgets = true,
this.padding,
this.footerSkeletonChild,
this.footerSkeletonMaxWidth,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final data = ref.watch(provider);
final noti = ref.watch(notifier);
// For sliver cases, avoid animation to prevent complex sliver issues
if (isSliver) {
if ((data.isLoading || data.value?.isLoading == true) &&
data.value?.items.isEmpty == true) {
final content = List<Widget>.generate(
10,
(_) => Skeletonizer(
enabled: true,
effect: ShimmerEffect(
baseColor: Theme.of(context).colorScheme.surfaceContainerHigh,
highlightColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child:
footerSkeletonChild ??
_DefaultSkeletonChild(maxWidth: footerSkeletonMaxWidth),
),
);
return SliverList.list(children: content);
}
if (data.hasError) {
final content = ResponseErrorWidget(
error: data.error,
onRetry: noti.refresh,
);
return SliverFillRemaining(child: content);
}
final listView = SuperSliverList.separated(
itemCount: (data.value?.items.length ?? 0) + 1,
itemBuilder: (context, idx) {
if (idx == data.value?.items.length) {
return PaginationListFooter(
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
skeletonMaxWidth: footerSkeletonMaxWidth,
);
}
final entry = data.value?.items[idx];
if (entry != null) return itemBuilder(context, idx, entry);
return null;
},
separatorBuilder: (context, index) {
if (seperatorBuilder != null) {
final entry = data.value?.items[index];
if (entry != null) {
return seperatorBuilder!(context, index, entry) ??
const SizedBox();
}
return const SizedBox();
}
if (spacing != null && spacing! > 0) {
return Gap(spacing!);
}
return const SizedBox();
},
);
return isRefreshable
? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: listView)
: listView;
}
// For non-sliver cases, use AnimatedSwitcher for smooth transitions
Widget buildContent() {
if ((data.isLoading || data.value?.isLoading == true) &&
data.value?.items.isEmpty == true) {
final content = List<Widget>.generate(
10,
(_) => Skeletonizer(
enabled: true,
effect: ShimmerEffect(
baseColor: Theme.of(context).colorScheme.surfaceContainerHigh,
highlightColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child:
footerSkeletonChild ??
_DefaultSkeletonChild(maxWidth: footerSkeletonMaxWidth),
),
);
return SizedBox(
key: const ValueKey('loading'),
child: ListView(padding: padding, children: content),
);
}
if (data.hasError) {
final content = ResponseErrorWidget(
error: data.error,
onRetry: noti.refresh,
);
return SizedBox(key: const ValueKey('error'), child: content);
}
final listView = SuperListView.separated(
padding: padding,
itemCount: (data.value?.items.length ?? 0) + 1,
itemBuilder: (context, idx) {
if (idx == data.value?.items.length) {
return PaginationListFooter(
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
skeletonMaxWidth: footerSkeletonMaxWidth,
);
}
final entry = data.value?.items[idx];
if (entry != null) return itemBuilder(context, idx, entry);
return null;
},
separatorBuilder: (context, index) {
if (seperatorBuilder != null) {
final entry = data.value?.items[index];
if (entry != null) {
return seperatorBuilder!(context, index, entry) ??
const SizedBox();
}
return const SizedBox();
}
if (spacing != null && spacing! > 0) {
return Gap(spacing!);
}
return const SizedBox();
},
);
return SizedBox(
key: const ValueKey('data'),
child: isRefreshable
? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: listView)
: listView,
);
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: buildContent(),
);
}
}
class PaginationWidget<T> extends HookConsumerWidget {
final ProviderListenable<AsyncValue<PaginationState<T>>> provider;
final Refreshable<PaginationController<T>> notifier;
final Widget Function(List<T>, Widget) contentBuilder;
final bool isRefreshable;
final bool isSliver;
final bool showDefaultWidgets;
final Widget? footerSkeletonChild;
final double? footerSkeletonMaxWidth;
const PaginationWidget({
super.key,
required this.provider,
required this.notifier,
required this.contentBuilder,
this.isRefreshable = true,
this.isSliver = false,
this.showDefaultWidgets = true,
this.footerSkeletonChild,
this.footerSkeletonMaxWidth,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final data = ref.watch(provider);
final noti = ref.watch(notifier);
// For sliver cases, avoid animation to prevent complex sliver issues
if (isSliver) {
if ((data.isLoading || data.value?.isLoading == true) &&
data.value?.items.isEmpty == true) {
final content = List<Widget>.generate(
10,
(_) => Skeletonizer(
enabled: true,
effect: ShimmerEffect(
baseColor: Theme.of(context).colorScheme.surfaceContainerHigh,
highlightColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child:
footerSkeletonChild ??
_DefaultSkeletonChild(maxWidth: footerSkeletonMaxWidth),
),
);
return SliverList.list(children: content);
}
if (data.hasError) {
final content = ResponseErrorWidget(
error: data.error,
onRetry: noti.refresh,
);
return SliverFillRemaining(child: content);
}
final footer = PaginationListFooter(
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
skeletonMaxWidth: footerSkeletonMaxWidth,
);
final content = contentBuilder(data.value?.items ?? [], footer);
return isRefreshable
? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: content)
: content;
}
// For non-sliver cases, use AnimatedSwitcher for smooth transitions
Widget buildContent() {
if ((data.isLoading || data.value?.isLoading == true) &&
data.value?.items.isEmpty == true) {
final content = List<Widget>.generate(
10,
(_) => Skeletonizer(
enabled: true,
effect: ShimmerEffect(
baseColor: Theme.of(context).colorScheme.surfaceContainerHigh,
highlightColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child:
footerSkeletonChild ??
_DefaultSkeletonChild(maxWidth: footerSkeletonMaxWidth),
),
);
return SizedBox(
key: const ValueKey('loading'),
child: ListView(children: content),
);
}
if (data.hasError) {
final content = ResponseErrorWidget(
error: data.error,
onRetry: noti.refresh,
);
return SizedBox(key: const ValueKey('error'), child: content);
}
final footer = PaginationListFooter(
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
skeletonMaxWidth: footerSkeletonMaxWidth,
);
final content = contentBuilder(data.value?.items ?? [], footer);
return SizedBox(
key: const ValueKey('data'),
child: isRefreshable
? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: content)
: content,
);
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: buildContent(),
);
}
}
class PaginationListFooter<T> extends HookConsumerWidget {
final PaginationController<T> noti;
final AsyncValue<PaginationState<T>> data;
final Widget? skeletonChild;
final double? skeletonMaxWidth;
final bool isSliver;
const PaginationListFooter({
super.key,
required this.noti,
required this.data,
this.skeletonChild,
this.skeletonMaxWidth,
this.isSliver = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasBeenVisible = useState(false);
final placeholder = Skeletonizer(
enabled: true,
effect: ShimmerEffect(
baseColor: Theme.of(context).colorScheme.surfaceContainerHigh,
highlightColor: Theme.of(context).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child: skeletonChild ?? _DefaultSkeletonChild(maxWidth: skeletonMaxWidth),
);
final child = hasBeenVisible.value
? (data.isLoading || data.value?.isLoading == true)
? placeholder
: Row(
spacing: 8,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Symbols.close, size: 16),
Text('noFurtherData').tr().fontSize(13),
],
).opacity(0.9).height(64).center()
: placeholder;
return VisibilityDetector(
key: Key("pagination-list-${noti.hashCode}"),
onVisibilityChanged: (VisibilityInfo info) {
hasBeenVisible.value = true;
if (!noti.fetchedAll &&
!(data.isLoading || data.value?.isLoading == true) &&
!data.hasError) {
if (context.mounted) noti.fetchFurther();
}
},
child: isSliver ? SliverToBoxAdapter(child: child) : child,
);
}
}
class _DefaultSkeletonChild extends StatelessWidget {
final double? maxWidth;
const _DefaultSkeletonChild({this.maxWidth});
@override
Widget build(BuildContext context) {
final content = ListTile(
title: Text('Some data'),
subtitle: const Text('Subtitle here'),
trailing: const Icon(Icons.ac_unit),
);
if (maxWidth != null) {
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth!),
child: content,
),
);
}
return content;
}
}

View File

@@ -0,0 +1,121 @@
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:island/auth/login_modal.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class ResponseErrorWidget extends StatelessWidget {
final dynamic error;
final VoidCallback onRetry;
const ResponseErrorWidget({
super.key,
required this.error,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Symbols.error_outline, size: 48),
const Gap(4),
if (error is DioException && error.response?.statusCode == 401)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Column(
children: [
Text(
'unauthorized'.tr(),
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF757575)),
).bold(),
Text(
'unauthorizedHint'.tr(),
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF757575)),
),
],
),
).center()
else if (error is DioException && error.response != null)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Text(
error.response.toString(),
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF757575)),
),
).center()
else
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Text(
error.toString(),
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF757575)),
),
).center(),
const Gap(8),
TextButton(onPressed: onRetry, child: const Text('retry').tr()),
],
);
}
}
class ResponseLoadingWidget extends StatelessWidget {
const ResponseLoadingWidget({super.key});
@override
Widget build(BuildContext context) {
return const Center(child: CircularProgressIndicator());
}
}
class ResponseUnauthorizedWidget extends StatelessWidget {
const ResponseUnauthorizedWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Symbols.error_outline, size: 48),
const Gap(4),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Column(
children: [
Text(
'unauthorized'.tr(),
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF757575)),
).bold(),
Text(
'unauthorizedHint'.tr(),
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF757575)),
),
const Gap(8),
TextButton.icon(
onPressed: () {
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => const LoginModal(),
);
},
icon: const Icon(Symbols.login),
label: Text('login').tr(),
),
],
),
).center(),
],
);
}
}

View File

@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/core/services/responsive.dart';
import 'package:island/posts/posts_widgets/post/article_sidebar_panel.dart';
class ResponsiveSidebar extends HookConsumerWidget {
final Widget attachmentsContent;
final Widget settingsContent;
final Widget mainContent;
final double sidebarWidth;
final ValueNotifier<bool> showSidebar;
const ResponsiveSidebar({
super.key,
required this.attachmentsContent,
required this.settingsContent,
required this.mainContent,
this.sidebarWidth = 480,
required this.showSidebar,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isWide = isWideScreen(context);
final animationController = useAnimationController(
duration: const Duration(milliseconds: 300),
);
final animation = useMemoized(
() => Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: animationController, curve: Curves.easeInOut),
),
[animationController],
);
final showDrawer = useState(false);
final scaffoldKey = useMemoized(() => GlobalKey<ScaffoldState>());
useEffect(() {
void listener() {
final currentIsWide = isWideScreen(context);
if (currentIsWide) {
if (showSidebar.value && !showDrawer.value) {
showDrawer.value = true;
animationController.forward();
} else if (!showSidebar.value && showDrawer.value) {
// Don't set showDrawer.value = false here - let animation complete first
animationController.reverse();
}
} else {
if (showSidebar.value) {
scaffoldKey.currentState?.openEndDrawer();
} else {
Navigator.of(context).pop();
}
}
}
showSidebar.addListener(listener);
// Set initial state after first frame
WidgetsBinding.instance.addPostFrameCallback((_) => listener());
return () => showSidebar.removeListener(listener);
}, []);
useEffect(() {
void listener() {
if (!animationController.isAnimating &&
animationController.value == 0) {
showDrawer.value = false;
}
}
animationController.addListener(listener);
return () => animationController.removeListener(listener);
}, [animationController]);
void closeSidebar() {
showSidebar.value = false;
}
if (isWide) {
return LayoutBuilder(
builder: (context, constraints) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Stack(
children: [
_buildWideScreenContent(
context,
constraints,
animation,
mainContent,
),
if (showDrawer.value)
Positioned(
right: 0,
top: 0,
bottom: 0,
width: sidebarWidth,
child: _buildWideScreenSidebar(
context,
animation,
attachmentsContent,
settingsContent,
closeSidebar,
),
),
],
);
},
);
},
);
} else {
return Scaffold(
key: scaffoldKey,
endDrawer: Drawer(
width: sidebarWidth,
child: ArticleSidebarPanelWidget(
attachmentsContent: attachmentsContent,
settingsContent: settingsContent,
onClose: () {
showSidebar.value = false;
Navigator.of(context).pop();
},
isWide: false,
width: sidebarWidth,
),
),
body: mainContent,
);
}
}
Widget _buildWideScreenContent(
BuildContext context,
BoxConstraints constraints,
Animation<double> animation,
Widget mainContent,
) {
return Positioned(
left: 0,
top: 0,
bottom: 0,
width: constraints.maxWidth - animation.value * sidebarWidth,
child: mainContent,
);
}
Widget _buildWideScreenSidebar(
BuildContext context,
Animation<double> animation,
Widget attachmentsContent,
Widget settingsContent,
VoidCallback onClose,
) {
return Transform.translate(
offset: Offset((1 - animation.value) * sidebarWidth, 0),
child: SizedBox(
width: sidebarWidth,
child: Material(
elevation: 8,
color: Theme.of(context).colorScheme.surfaceContainer,
child: ArticleSidebarPanelWidget(
attachmentsContent: attachmentsContent,
settingsContent: settingsContent,
onClose: onClose,
isWide: true,
width: sidebarWidth,
),
),
),
);
}
}

File diff suppressed because it is too large Load Diff