🎨 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

65
lib/core/audio.dart Normal file
View File

@@ -0,0 +1,65 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart';
import 'package:island/core/config.dart';
import 'package:audio_session/audio_session.dart';
final sfxPlayerProvider = Provider<AudioPlayer>((ref) {
final player = AudioPlayer();
ref.onDispose(() {
player.dispose();
});
return player;
});
Future<void> _configureAudioSession() async {
final session = await AudioSession.instance;
await session.configure(
const AudioSessionConfiguration(
avAudioSessionCategory: AVAudioSessionCategory.playback,
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.mixWithOthers,
),
);
await session.setActive(true);
}
final audioSessionProvider = FutureProvider<void>((ref) async {
await _configureAudioSession();
});
final notificationSfxProvider = FutureProvider<void>((ref) async {
final player = ref.watch(sfxPlayerProvider);
await player.setVolume(0.75);
await player.setAudioSource(
AudioSource.asset('assets/audio/notification.wav'),
preload: true,
);
});
final messageSfxProvider = FutureProvider<void>((ref) async {
final player = ref.watch(sfxPlayerProvider);
await player.setAudioSource(
AudioSource.asset('assets/audio/messages.wav'),
preload: true,
);
});
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 playNotificationSfx(WidgetRef ref) {
final settings = ref.read(appSettingsProvider);
if (!settings.soundEffects) return;
_playSfx('assets/audio/notification.mp3', 0.75);
}
void playMessageSfx(WidgetRef ref) {
final settings = ref.read(appSettingsProvider);
if (!settings.soundEffects) return;
_playSfx('assets/audio/messages.mp3', 0.75);
}

388
lib/core/config.dart Normal file
View File

@@ -0,0 +1,388 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/core/services/analytics_service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:window_manager/window_manager.dart';
part 'config.freezed.dart';
part 'config.g.dart';
const kTokenPairStoreKey = 'dyn_user_tk';
const kNetworkServerDefault = 'https://api.solian.app';
const kNetworkServerStoreKey = 'app_server_url';
const kAppbarTransparentStoreKey = 'app_bar_transparent';
const kAppBackgroundStoreKey = 'app_has_background';
const kAppShowBackgroundImage = 'app_show_background_image';
const kAppColorSchemeStoreKey = 'app_color_scheme';
const kAppCustomColorsStoreKey = 'app_custom_colors';
const kAppNotifyWithHaptic = 'app_notify_with_haptic';
const kAppCustomFonts = 'app_custom_fonts';
const kAppDataSavingMode = 'app_data_saving_mode';
const kAppSoundEffects = 'app_sound_effects';
const kAppFestivalFeatures = 'app_feastival_features';
const kAppWindowSize = 'app_window_size';
const kAppWindowOpacity = 'app_window_opacity';
const kAppCardTransparent = 'app_card_transparent';
const kAppEnterToSend = 'app_enter_to_send';
const kAppDefaultPoolId = 'app_default_pool_id';
const kAppMessageDisplayStyle = 'app_message_display_style';
const kAppThemeMode = 'app_theme_mode';
const kAppDisableAnimation = 'app_disable_animation';
const kAppGroupedChatList = 'app_grouped_chat_list';
const kFeaturedPostsCollapsedId =
'featured_posts_collapsed_id'; // Key for storing the ID of the collapsed featured post
const kAppFirstLaunchAt = 'app_first_launch_at';
const kAppAskedReview = 'app_asked_review';
const kAppDashSearchEngine = 'app_dash_search_engine';
const kAppDefaultScreen = 'app_default_screen';
const kAppShowFediverseContent = 'app_show_fediverse_content';
const kAppDashboardConfig = 'app_dashboard_config';
// Will be overrided by the ProviderScope
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
throw UnimplementedError();
});
final serverUrlProvider = Provider<String>((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
return prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
});
@freezed
sealed class ThemeColors with _$ThemeColors {
factory ThemeColors({
int? primary,
int? secondary,
int? tertiary,
int? surface,
int? background,
int? error,
}) = _ThemeColors;
factory ThemeColors.fromJson(Map<String, dynamic> json) =>
_$ThemeColorsFromJson(json);
}
@freezed
sealed class DashboardConfig with _$DashboardConfig {
factory DashboardConfig({
required List<String> verticalLayouts,
required List<String> horizontalLayouts,
required bool showSearchBar,
required bool showClockAndCountdown,
}) = _DashboardConfig;
factory DashboardConfig.fromJson(Map<String, dynamic> json) =>
_$DashboardConfigFromJson(json);
}
@freezed
sealed class AppSettings with _$AppSettings {
const factory AppSettings({
required bool dataSavingMode,
required bool soundEffects,
required bool festivalFeatures,
required bool enterToSend,
required bool appBarTransparent,
required bool showBackgroundImage,
required bool notifyWithHaptic,
required String? customFonts,
required int? appColorScheme, // The color stored via the int type
required ThemeColors? customColors,
required Size? windowSize, // The window size for desktop platforms
required double windowOpacity, // The window opacity for desktop platforms
required double cardTransparency, // The card background opacity
required String? defaultPoolId,
required String messageDisplayStyle,
required String? themeMode,
required bool disableAnimation,
required bool groupedChatList,
required String? firstLaunchAt,
required bool askedReview,
required String? dashSearchEngine,
required String? defaultScreen,
required bool showFediverseContent,
required DashboardConfig? dashboardConfig,
}) = _AppSettings;
}
@riverpod
class AppSettingsNotifier extends _$AppSettingsNotifier {
@override
AppSettings build() {
final prefs = ref.watch(sharedPreferencesProvider);
return AppSettings(
dataSavingMode: prefs.getBool(kAppDataSavingMode) ?? false,
soundEffects: prefs.getBool(kAppSoundEffects) ?? true,
festivalFeatures: prefs.getBool(kAppFestivalFeatures) ?? true,
enterToSend: prefs.getBool(kAppEnterToSend) ?? true,
appBarTransparent: prefs.getBool(kAppbarTransparentStoreKey) ?? false,
showBackgroundImage: prefs.getBool(kAppShowBackgroundImage) ?? true,
notifyWithHaptic: prefs.getBool(kAppNotifyWithHaptic) ?? true,
customFonts: prefs.getString(kAppCustomFonts),
appColorScheme: prefs.getInt(kAppColorSchemeStoreKey),
customColors: _getThemeColorsFromPrefs(prefs),
windowSize: _getWindowSizeFromPrefs(prefs),
windowOpacity: prefs.getDouble(kAppWindowOpacity) ?? 1.0,
cardTransparency: prefs.getDouble(kAppCardTransparent) ?? 1.0,
defaultPoolId: prefs.getString(kAppDefaultPoolId),
messageDisplayStyle: prefs.getString(kAppMessageDisplayStyle) ?? 'bubble',
themeMode: prefs.getString(kAppThemeMode) ?? 'system',
disableAnimation: prefs.getBool(kAppDisableAnimation) ?? false,
groupedChatList: prefs.getBool(kAppGroupedChatList) ?? false,
askedReview: prefs.getBool(kAppAskedReview) ?? false,
firstLaunchAt: prefs.getString(kAppFirstLaunchAt),
dashSearchEngine: prefs.getString(kAppDashSearchEngine),
defaultScreen: prefs.getString(kAppDefaultScreen),
showFediverseContent: prefs.getBool(kAppShowFediverseContent) ?? true,
dashboardConfig: _getDashboardConfigFromPrefs(prefs),
);
}
Size? _getWindowSizeFromPrefs(SharedPreferences prefs) {
final sizeString = prefs.getString(kAppWindowSize);
if (sizeString == null) return null;
try {
final parts = sizeString.split(',');
if (parts.length == 2) {
final width = double.parse(parts[0]);
final height = double.parse(parts[1]);
return Size(width, height);
}
} catch (e) {
// Invalid format, return null
}
return null;
}
ThemeColors? _getThemeColorsFromPrefs(SharedPreferences prefs) {
final jsonString = prefs.getString(kAppCustomColorsStoreKey);
if (jsonString == null) return null;
try {
final json = jsonDecode(jsonString);
return ThemeColors.fromJson(json);
} catch (e) {
return null;
}
}
DashboardConfig? _getDashboardConfigFromPrefs(SharedPreferences prefs) {
final jsonString = prefs.getString(kAppDashboardConfig);
if (jsonString == null) return null;
try {
final json = jsonDecode(jsonString);
return DashboardConfig.fromJson(json);
} catch (e) {
return null;
}
}
void setDefaultPoolId(String? value) {
final prefs = ref.read(sharedPreferencesProvider);
if (value != null) {
prefs.setString(kAppDefaultPoolId, value);
} else {
prefs.remove(kAppDefaultPoolId);
}
state = state.copyWith(defaultPoolId: value);
}
void setDataSavingMode(bool value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setBool(kAppDataSavingMode, value);
state = state.copyWith(dataSavingMode: value);
}
void setSoundEffects(bool value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setBool(kAppSoundEffects, value);
state = state.copyWith(soundEffects: value);
}
void setFeativalFeatures(bool value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setBool(kAppFestivalFeatures, value);
state = state.copyWith(festivalFeatures: value);
}
void setEnterToSend(bool value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setBool(kAppEnterToSend, value);
state = state.copyWith(enterToSend: value);
}
void setAppBarTransparent(bool value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setBool(kAppbarTransparentStoreKey, value);
state = state.copyWith(appBarTransparent: value);
}
void setShowBackgroundImage(bool value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setBool(kAppShowBackgroundImage, value);
state = state.copyWith(showBackgroundImage: value);
}
void setNotifyWithHaptic(bool value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setBool(kAppNotifyWithHaptic, value);
state = state.copyWith(notifyWithHaptic: value);
}
void setCustomFonts(String? value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setString(kAppCustomFonts, value ?? '');
state = state.copyWith(customFonts: value);
}
void setDefaultScreen(String? value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setString(kAppDefaultScreen, value ?? 'dashboard');
state = state.copyWith(defaultScreen: value);
}
void setAppColorScheme(int? value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setInt(kAppColorSchemeStoreKey, value ?? 0);
state = state.copyWith(appColorScheme: value);
}
void setWindowSize(Size? size) {
final prefs = ref.read(sharedPreferencesProvider);
if (size != null) {
prefs.setString(kAppWindowSize, '${size.width},${size.height}');
} else {
prefs.remove(kAppWindowSize);
}
state = state.copyWith(windowSize: size);
}
Size? getWindowSize() {
return state.windowSize;
}
void setMessageDisplayStyle(String value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setString(kAppMessageDisplayStyle, value);
state = state.copyWith(messageDisplayStyle: value);
}
void setWindowOpacity(double value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setDouble(kAppWindowOpacity, value);
state = state.copyWith(windowOpacity: value);
Future(() => windowManager.setOpacity(value));
}
void setThemeMode(String value) {
final prefs = ref.read(sharedPreferencesProvider);
final oldValue = state.themeMode;
prefs.setString(kAppThemeMode, value);
state = state.copyWith(themeMode: value);
AnalyticsService().logThemeChanged(oldValue ?? 'system', value);
}
void setAppTransparentBackground(double value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setDouble(kAppCardTransparent, value);
state = state.copyWith(cardTransparency: value);
}
void setCustomColors(ThemeColors? value) {
final prefs = ref.read(sharedPreferencesProvider);
if (value != null) {
final json = jsonEncode(value.toJson());
prefs.setString(kAppCustomColorsStoreKey, json);
} else {
prefs.remove(kAppCustomColorsStoreKey);
}
state = state.copyWith(customColors: value);
}
void setDisableAnimation(bool value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setBool(kAppDisableAnimation, value);
state = state.copyWith(disableAnimation: value);
}
void setGroupedChatList(bool value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setBool(kAppGroupedChatList, value);
state = state.copyWith(groupedChatList: value);
}
void setFirstLaunchAt(String? value) {
final prefs = ref.read(sharedPreferencesProvider);
if (value != null) {
prefs.setString(kAppFirstLaunchAt, value);
} else {
prefs.remove(kAppFirstLaunchAt);
}
state = state.copyWith(firstLaunchAt: value);
}
void setAskedReview(bool value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setBool(kAppAskedReview, value);
state = state.copyWith(askedReview: value);
}
void setDashSearchEngine(String? value) {
final prefs = ref.read(sharedPreferencesProvider);
if (value != null) {
prefs.setString(kAppDashSearchEngine, value);
} else {
prefs.remove(kAppDashSearchEngine);
}
state = state.copyWith(dashSearchEngine: value);
}
void setShowFediverseContent(bool value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setBool(kAppShowFediverseContent, value);
state = state.copyWith(showFediverseContent: value);
}
void setDashboardConfig(DashboardConfig? value) {
final prefs = ref.read(sharedPreferencesProvider);
if (value != null) {
final json = jsonEncode(value.toJson());
prefs.setString(kAppDashboardConfig, json);
} else {
prefs.remove(kAppDashboardConfig);
}
state = state.copyWith(dashboardConfig: value);
}
void resetDashboardConfig() {
final prefs = ref.read(sharedPreferencesProvider);
prefs.remove(kAppDashboardConfig);
state = state.copyWith(dashboardConfig: null);
}
}
final updateInfoProvider =
NotifierProvider<UpdateInfoNotifier, (String?, String?)>(
UpdateInfoNotifier.new,
);
class UpdateInfoNotifier extends Notifier<(String?, String?)> {
@override
(String?, String?) build() {
return (null, null);
}
void setUpdate(String newVersion, String newChangelog) {
state = (newVersion, newChangelog);
}
}

View File

@@ -0,0 +1,940 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'config.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ThemeColors {
int? get primary; int? get secondary; int? get tertiary; int? get surface; int? get background; int? get error;
/// Create a copy of ThemeColors
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ThemeColorsCopyWith<ThemeColors> get copyWith => _$ThemeColorsCopyWithImpl<ThemeColors>(this as ThemeColors, _$identity);
/// Serializes this ThemeColors to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ThemeColors&&(identical(other.primary, primary) || other.primary == primary)&&(identical(other.secondary, secondary) || other.secondary == secondary)&&(identical(other.tertiary, tertiary) || other.tertiary == tertiary)&&(identical(other.surface, surface) || other.surface == surface)&&(identical(other.background, background) || other.background == background)&&(identical(other.error, error) || other.error == error));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,primary,secondary,tertiary,surface,background,error);
@override
String toString() {
return 'ThemeColors(primary: $primary, secondary: $secondary, tertiary: $tertiary, surface: $surface, background: $background, error: $error)';
}
}
/// @nodoc
abstract mixin class $ThemeColorsCopyWith<$Res> {
factory $ThemeColorsCopyWith(ThemeColors value, $Res Function(ThemeColors) _then) = _$ThemeColorsCopyWithImpl;
@useResult
$Res call({
int? primary, int? secondary, int? tertiary, int? surface, int? background, int? error
});
}
/// @nodoc
class _$ThemeColorsCopyWithImpl<$Res>
implements $ThemeColorsCopyWith<$Res> {
_$ThemeColorsCopyWithImpl(this._self, this._then);
final ThemeColors _self;
final $Res Function(ThemeColors) _then;
/// Create a copy of ThemeColors
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? primary = freezed,Object? secondary = freezed,Object? tertiary = freezed,Object? surface = freezed,Object? background = freezed,Object? error = freezed,}) {
return _then(_self.copyWith(
primary: freezed == primary ? _self.primary : primary // ignore: cast_nullable_to_non_nullable
as int?,secondary: freezed == secondary ? _self.secondary : secondary // ignore: cast_nullable_to_non_nullable
as int?,tertiary: freezed == tertiary ? _self.tertiary : tertiary // ignore: cast_nullable_to_non_nullable
as int?,surface: freezed == surface ? _self.surface : surface // ignore: cast_nullable_to_non_nullable
as int?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable
as int?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as int?,
));
}
}
/// Adds pattern-matching-related methods to [ThemeColors].
extension ThemeColorsPatterns on ThemeColors {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ThemeColors value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ThemeColors() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ThemeColors value) $default,){
final _that = this;
switch (_that) {
case _ThemeColors():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ThemeColors value)? $default,){
final _that = this;
switch (_that) {
case _ThemeColors() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int? primary, int? secondary, int? tertiary, int? surface, int? background, int? error)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ThemeColors() when $default != null:
return $default(_that.primary,_that.secondary,_that.tertiary,_that.surface,_that.background,_that.error);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int? primary, int? secondary, int? tertiary, int? surface, int? background, int? error) $default,) {final _that = this;
switch (_that) {
case _ThemeColors():
return $default(_that.primary,_that.secondary,_that.tertiary,_that.surface,_that.background,_that.error);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int? primary, int? secondary, int? tertiary, int? surface, int? background, int? error)? $default,) {final _that = this;
switch (_that) {
case _ThemeColors() when $default != null:
return $default(_that.primary,_that.secondary,_that.tertiary,_that.surface,_that.background,_that.error);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ThemeColors implements ThemeColors {
_ThemeColors({this.primary, this.secondary, this.tertiary, this.surface, this.background, this.error});
factory _ThemeColors.fromJson(Map<String, dynamic> json) => _$ThemeColorsFromJson(json);
@override final int? primary;
@override final int? secondary;
@override final int? tertiary;
@override final int? surface;
@override final int? background;
@override final int? error;
/// Create a copy of ThemeColors
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ThemeColorsCopyWith<_ThemeColors> get copyWith => __$ThemeColorsCopyWithImpl<_ThemeColors>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ThemeColorsToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ThemeColors&&(identical(other.primary, primary) || other.primary == primary)&&(identical(other.secondary, secondary) || other.secondary == secondary)&&(identical(other.tertiary, tertiary) || other.tertiary == tertiary)&&(identical(other.surface, surface) || other.surface == surface)&&(identical(other.background, background) || other.background == background)&&(identical(other.error, error) || other.error == error));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,primary,secondary,tertiary,surface,background,error);
@override
String toString() {
return 'ThemeColors(primary: $primary, secondary: $secondary, tertiary: $tertiary, surface: $surface, background: $background, error: $error)';
}
}
/// @nodoc
abstract mixin class _$ThemeColorsCopyWith<$Res> implements $ThemeColorsCopyWith<$Res> {
factory _$ThemeColorsCopyWith(_ThemeColors value, $Res Function(_ThemeColors) _then) = __$ThemeColorsCopyWithImpl;
@override @useResult
$Res call({
int? primary, int? secondary, int? tertiary, int? surface, int? background, int? error
});
}
/// @nodoc
class __$ThemeColorsCopyWithImpl<$Res>
implements _$ThemeColorsCopyWith<$Res> {
__$ThemeColorsCopyWithImpl(this._self, this._then);
final _ThemeColors _self;
final $Res Function(_ThemeColors) _then;
/// Create a copy of ThemeColors
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? primary = freezed,Object? secondary = freezed,Object? tertiary = freezed,Object? surface = freezed,Object? background = freezed,Object? error = freezed,}) {
return _then(_ThemeColors(
primary: freezed == primary ? _self.primary : primary // ignore: cast_nullable_to_non_nullable
as int?,secondary: freezed == secondary ? _self.secondary : secondary // ignore: cast_nullable_to_non_nullable
as int?,tertiary: freezed == tertiary ? _self.tertiary : tertiary // ignore: cast_nullable_to_non_nullable
as int?,surface: freezed == surface ? _self.surface : surface // ignore: cast_nullable_to_non_nullable
as int?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable
as int?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as int?,
));
}
}
/// @nodoc
mixin _$DashboardConfig {
List<String> get verticalLayouts; List<String> get horizontalLayouts; bool get showSearchBar; bool get showClockAndCountdown;
/// Create a copy of DashboardConfig
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$DashboardConfigCopyWith<DashboardConfig> get copyWith => _$DashboardConfigCopyWithImpl<DashboardConfig>(this as DashboardConfig, _$identity);
/// Serializes this DashboardConfig to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is DashboardConfig&&const DeepCollectionEquality().equals(other.verticalLayouts, verticalLayouts)&&const DeepCollectionEquality().equals(other.horizontalLayouts, horizontalLayouts)&&(identical(other.showSearchBar, showSearchBar) || other.showSearchBar == showSearchBar)&&(identical(other.showClockAndCountdown, showClockAndCountdown) || other.showClockAndCountdown == showClockAndCountdown));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(verticalLayouts),const DeepCollectionEquality().hash(horizontalLayouts),showSearchBar,showClockAndCountdown);
@override
String toString() {
return 'DashboardConfig(verticalLayouts: $verticalLayouts, horizontalLayouts: $horizontalLayouts, showSearchBar: $showSearchBar, showClockAndCountdown: $showClockAndCountdown)';
}
}
/// @nodoc
abstract mixin class $DashboardConfigCopyWith<$Res> {
factory $DashboardConfigCopyWith(DashboardConfig value, $Res Function(DashboardConfig) _then) = _$DashboardConfigCopyWithImpl;
@useResult
$Res call({
List<String> verticalLayouts, List<String> horizontalLayouts, bool showSearchBar, bool showClockAndCountdown
});
}
/// @nodoc
class _$DashboardConfigCopyWithImpl<$Res>
implements $DashboardConfigCopyWith<$Res> {
_$DashboardConfigCopyWithImpl(this._self, this._then);
final DashboardConfig _self;
final $Res Function(DashboardConfig) _then;
/// Create a copy of DashboardConfig
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? verticalLayouts = null,Object? horizontalLayouts = null,Object? showSearchBar = null,Object? showClockAndCountdown = null,}) {
return _then(_self.copyWith(
verticalLayouts: null == verticalLayouts ? _self.verticalLayouts : verticalLayouts // ignore: cast_nullable_to_non_nullable
as List<String>,horizontalLayouts: null == horizontalLayouts ? _self.horizontalLayouts : horizontalLayouts // ignore: cast_nullable_to_non_nullable
as List<String>,showSearchBar: null == showSearchBar ? _self.showSearchBar : showSearchBar // ignore: cast_nullable_to_non_nullable
as bool,showClockAndCountdown: null == showClockAndCountdown ? _self.showClockAndCountdown : showClockAndCountdown // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// Adds pattern-matching-related methods to [DashboardConfig].
extension DashboardConfigPatterns on DashboardConfig {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _DashboardConfig value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _DashboardConfig() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _DashboardConfig value) $default,){
final _that = this;
switch (_that) {
case _DashboardConfig():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _DashboardConfig value)? $default,){
final _that = this;
switch (_that) {
case _DashboardConfig() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<String> verticalLayouts, List<String> horizontalLayouts, bool showSearchBar, bool showClockAndCountdown)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _DashboardConfig() when $default != null:
return $default(_that.verticalLayouts,_that.horizontalLayouts,_that.showSearchBar,_that.showClockAndCountdown);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<String> verticalLayouts, List<String> horizontalLayouts, bool showSearchBar, bool showClockAndCountdown) $default,) {final _that = this;
switch (_that) {
case _DashboardConfig():
return $default(_that.verticalLayouts,_that.horizontalLayouts,_that.showSearchBar,_that.showClockAndCountdown);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<String> verticalLayouts, List<String> horizontalLayouts, bool showSearchBar, bool showClockAndCountdown)? $default,) {final _that = this;
switch (_that) {
case _DashboardConfig() when $default != null:
return $default(_that.verticalLayouts,_that.horizontalLayouts,_that.showSearchBar,_that.showClockAndCountdown);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _DashboardConfig implements DashboardConfig {
_DashboardConfig({required final List<String> verticalLayouts, required final List<String> horizontalLayouts, required this.showSearchBar, required this.showClockAndCountdown}): _verticalLayouts = verticalLayouts,_horizontalLayouts = horizontalLayouts;
factory _DashboardConfig.fromJson(Map<String, dynamic> json) => _$DashboardConfigFromJson(json);
final List<String> _verticalLayouts;
@override List<String> get verticalLayouts {
if (_verticalLayouts is EqualUnmodifiableListView) return _verticalLayouts;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_verticalLayouts);
}
final List<String> _horizontalLayouts;
@override List<String> get horizontalLayouts {
if (_horizontalLayouts is EqualUnmodifiableListView) return _horizontalLayouts;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_horizontalLayouts);
}
@override final bool showSearchBar;
@override final bool showClockAndCountdown;
/// Create a copy of DashboardConfig
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$DashboardConfigCopyWith<_DashboardConfig> get copyWith => __$DashboardConfigCopyWithImpl<_DashboardConfig>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$DashboardConfigToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _DashboardConfig&&const DeepCollectionEquality().equals(other._verticalLayouts, _verticalLayouts)&&const DeepCollectionEquality().equals(other._horizontalLayouts, _horizontalLayouts)&&(identical(other.showSearchBar, showSearchBar) || other.showSearchBar == showSearchBar)&&(identical(other.showClockAndCountdown, showClockAndCountdown) || other.showClockAndCountdown == showClockAndCountdown));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_verticalLayouts),const DeepCollectionEquality().hash(_horizontalLayouts),showSearchBar,showClockAndCountdown);
@override
String toString() {
return 'DashboardConfig(verticalLayouts: $verticalLayouts, horizontalLayouts: $horizontalLayouts, showSearchBar: $showSearchBar, showClockAndCountdown: $showClockAndCountdown)';
}
}
/// @nodoc
abstract mixin class _$DashboardConfigCopyWith<$Res> implements $DashboardConfigCopyWith<$Res> {
factory _$DashboardConfigCopyWith(_DashboardConfig value, $Res Function(_DashboardConfig) _then) = __$DashboardConfigCopyWithImpl;
@override @useResult
$Res call({
List<String> verticalLayouts, List<String> horizontalLayouts, bool showSearchBar, bool showClockAndCountdown
});
}
/// @nodoc
class __$DashboardConfigCopyWithImpl<$Res>
implements _$DashboardConfigCopyWith<$Res> {
__$DashboardConfigCopyWithImpl(this._self, this._then);
final _DashboardConfig _self;
final $Res Function(_DashboardConfig) _then;
/// Create a copy of DashboardConfig
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? verticalLayouts = null,Object? horizontalLayouts = null,Object? showSearchBar = null,Object? showClockAndCountdown = null,}) {
return _then(_DashboardConfig(
verticalLayouts: null == verticalLayouts ? _self._verticalLayouts : verticalLayouts // ignore: cast_nullable_to_non_nullable
as List<String>,horizontalLayouts: null == horizontalLayouts ? _self._horizontalLayouts : horizontalLayouts // ignore: cast_nullable_to_non_nullable
as List<String>,showSearchBar: null == showSearchBar ? _self.showSearchBar : showSearchBar // ignore: cast_nullable_to_non_nullable
as bool,showClockAndCountdown: null == showClockAndCountdown ? _self.showClockAndCountdown : showClockAndCountdown // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// @nodoc
mixin _$AppSettings {
bool get dataSavingMode; bool get soundEffects; bool get festivalFeatures; bool get enterToSend; bool get appBarTransparent; bool get showBackgroundImage; bool get notifyWithHaptic; String? get customFonts; int? get appColorScheme;// The color stored via the int type
ThemeColors? get customColors; Size? get windowSize;// The window size for desktop platforms
double get windowOpacity;// The window opacity for desktop platforms
double get cardTransparency;// The card background opacity
String? get defaultPoolId; String get messageDisplayStyle; String? get themeMode; bool get disableAnimation; bool get groupedChatList; String? get firstLaunchAt; bool get askedReview; String? get dashSearchEngine; String? get defaultScreen; bool get showFediverseContent; DashboardConfig? get dashboardConfig;
/// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$AppSettingsCopyWith<AppSettings> get copyWith => _$AppSettingsCopyWithImpl<AppSettings>(this as AppSettings, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is AppSettings&&(identical(other.dataSavingMode, dataSavingMode) || other.dataSavingMode == dataSavingMode)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.festivalFeatures, festivalFeatures) || other.festivalFeatures == festivalFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend)&&(identical(other.appBarTransparent, appBarTransparent) || other.appBarTransparent == appBarTransparent)&&(identical(other.showBackgroundImage, showBackgroundImage) || other.showBackgroundImage == showBackgroundImage)&&(identical(other.notifyWithHaptic, notifyWithHaptic) || other.notifyWithHaptic == notifyWithHaptic)&&(identical(other.customFonts, customFonts) || other.customFonts == customFonts)&&(identical(other.appColorScheme, appColorScheme) || other.appColorScheme == appColorScheme)&&(identical(other.customColors, customColors) || other.customColors == customColors)&&(identical(other.windowSize, windowSize) || other.windowSize == windowSize)&&(identical(other.windowOpacity, windowOpacity) || other.windowOpacity == windowOpacity)&&(identical(other.cardTransparency, cardTransparency) || other.cardTransparency == cardTransparency)&&(identical(other.defaultPoolId, defaultPoolId) || other.defaultPoolId == defaultPoolId)&&(identical(other.messageDisplayStyle, messageDisplayStyle) || other.messageDisplayStyle == messageDisplayStyle)&&(identical(other.themeMode, themeMode) || other.themeMode == themeMode)&&(identical(other.disableAnimation, disableAnimation) || other.disableAnimation == disableAnimation)&&(identical(other.groupedChatList, groupedChatList) || other.groupedChatList == groupedChatList)&&(identical(other.firstLaunchAt, firstLaunchAt) || other.firstLaunchAt == firstLaunchAt)&&(identical(other.askedReview, askedReview) || other.askedReview == askedReview)&&(identical(other.dashSearchEngine, dashSearchEngine) || other.dashSearchEngine == dashSearchEngine)&&(identical(other.defaultScreen, defaultScreen) || other.defaultScreen == defaultScreen)&&(identical(other.showFediverseContent, showFediverseContent) || other.showFediverseContent == showFediverseContent)&&(identical(other.dashboardConfig, dashboardConfig) || other.dashboardConfig == dashboardConfig));
}
@override
int get hashCode => Object.hashAll([runtimeType,dataSavingMode,soundEffects,festivalFeatures,enterToSend,appBarTransparent,showBackgroundImage,notifyWithHaptic,customFonts,appColorScheme,customColors,windowSize,windowOpacity,cardTransparency,defaultPoolId,messageDisplayStyle,themeMode,disableAnimation,groupedChatList,firstLaunchAt,askedReview,dashSearchEngine,defaultScreen,showFediverseContent,dashboardConfig]);
@override
String toString() {
return 'AppSettings(dataSavingMode: $dataSavingMode, soundEffects: $soundEffects, festivalFeatures: $festivalFeatures, enterToSend: $enterToSend, appBarTransparent: $appBarTransparent, showBackgroundImage: $showBackgroundImage, notifyWithHaptic: $notifyWithHaptic, customFonts: $customFonts, appColorScheme: $appColorScheme, customColors: $customColors, windowSize: $windowSize, windowOpacity: $windowOpacity, cardTransparency: $cardTransparency, defaultPoolId: $defaultPoolId, messageDisplayStyle: $messageDisplayStyle, themeMode: $themeMode, disableAnimation: $disableAnimation, groupedChatList: $groupedChatList, firstLaunchAt: $firstLaunchAt, askedReview: $askedReview, dashSearchEngine: $dashSearchEngine, defaultScreen: $defaultScreen, showFediverseContent: $showFediverseContent, dashboardConfig: $dashboardConfig)';
}
}
/// @nodoc
abstract mixin class $AppSettingsCopyWith<$Res> {
factory $AppSettingsCopyWith(AppSettings value, $Res Function(AppSettings) _then) = _$AppSettingsCopyWithImpl;
@useResult
$Res call({
bool dataSavingMode, bool soundEffects, bool festivalFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, bool notifyWithHaptic, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool disableAnimation, bool groupedChatList, String? firstLaunchAt, bool askedReview, String? dashSearchEngine, String? defaultScreen, bool showFediverseContent, DashboardConfig? dashboardConfig
});
$ThemeColorsCopyWith<$Res>? get customColors;$DashboardConfigCopyWith<$Res>? get dashboardConfig;
}
/// @nodoc
class _$AppSettingsCopyWithImpl<$Res>
implements $AppSettingsCopyWith<$Res> {
_$AppSettingsCopyWithImpl(this._self, this._then);
final AppSettings _self;
final $Res Function(AppSettings) _then;
/// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? dataSavingMode = null,Object? soundEffects = null,Object? festivalFeatures = null,Object? enterToSend = null,Object? appBarTransparent = null,Object? showBackgroundImage = null,Object? notifyWithHaptic = null,Object? customFonts = freezed,Object? appColorScheme = freezed,Object? customColors = freezed,Object? windowSize = freezed,Object? windowOpacity = null,Object? cardTransparency = null,Object? defaultPoolId = freezed,Object? messageDisplayStyle = null,Object? themeMode = freezed,Object? disableAnimation = null,Object? groupedChatList = null,Object? firstLaunchAt = freezed,Object? askedReview = null,Object? dashSearchEngine = freezed,Object? defaultScreen = freezed,Object? showFediverseContent = null,Object? dashboardConfig = freezed,}) {
return _then(_self.copyWith(
dataSavingMode: null == dataSavingMode ? _self.dataSavingMode : dataSavingMode // ignore: cast_nullable_to_non_nullable
as bool,soundEffects: null == soundEffects ? _self.soundEffects : soundEffects // ignore: cast_nullable_to_non_nullable
as bool,festivalFeatures: null == festivalFeatures ? _self.festivalFeatures : festivalFeatures // ignore: cast_nullable_to_non_nullable
as bool,enterToSend: null == enterToSend ? _self.enterToSend : enterToSend // ignore: cast_nullable_to_non_nullable
as bool,appBarTransparent: null == appBarTransparent ? _self.appBarTransparent : appBarTransparent // ignore: cast_nullable_to_non_nullable
as bool,showBackgroundImage: null == showBackgroundImage ? _self.showBackgroundImage : showBackgroundImage // ignore: cast_nullable_to_non_nullable
as bool,notifyWithHaptic: null == notifyWithHaptic ? _self.notifyWithHaptic : notifyWithHaptic // ignore: cast_nullable_to_non_nullable
as bool,customFonts: freezed == customFonts ? _self.customFonts : customFonts // ignore: cast_nullable_to_non_nullable
as String?,appColorScheme: freezed == appColorScheme ? _self.appColorScheme : appColorScheme // ignore: cast_nullable_to_non_nullable
as int?,customColors: freezed == customColors ? _self.customColors : customColors // ignore: cast_nullable_to_non_nullable
as ThemeColors?,windowSize: freezed == windowSize ? _self.windowSize : windowSize // ignore: cast_nullable_to_non_nullable
as Size?,windowOpacity: null == windowOpacity ? _self.windowOpacity : windowOpacity // ignore: cast_nullable_to_non_nullable
as double,cardTransparency: null == cardTransparency ? _self.cardTransparency : cardTransparency // ignore: cast_nullable_to_non_nullable
as double,defaultPoolId: freezed == defaultPoolId ? _self.defaultPoolId : defaultPoolId // ignore: cast_nullable_to_non_nullable
as String?,messageDisplayStyle: null == messageDisplayStyle ? _self.messageDisplayStyle : messageDisplayStyle // ignore: cast_nullable_to_non_nullable
as String,themeMode: freezed == themeMode ? _self.themeMode : themeMode // ignore: cast_nullable_to_non_nullable
as String?,disableAnimation: null == disableAnimation ? _self.disableAnimation : disableAnimation // ignore: cast_nullable_to_non_nullable
as bool,groupedChatList: null == groupedChatList ? _self.groupedChatList : groupedChatList // ignore: cast_nullable_to_non_nullable
as bool,firstLaunchAt: freezed == firstLaunchAt ? _self.firstLaunchAt : firstLaunchAt // ignore: cast_nullable_to_non_nullable
as String?,askedReview: null == askedReview ? _self.askedReview : askedReview // ignore: cast_nullable_to_non_nullable
as bool,dashSearchEngine: freezed == dashSearchEngine ? _self.dashSearchEngine : dashSearchEngine // ignore: cast_nullable_to_non_nullable
as String?,defaultScreen: freezed == defaultScreen ? _self.defaultScreen : defaultScreen // ignore: cast_nullable_to_non_nullable
as String?,showFediverseContent: null == showFediverseContent ? _self.showFediverseContent : showFediverseContent // ignore: cast_nullable_to_non_nullable
as bool,dashboardConfig: freezed == dashboardConfig ? _self.dashboardConfig : dashboardConfig // ignore: cast_nullable_to_non_nullable
as DashboardConfig?,
));
}
/// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$ThemeColorsCopyWith<$Res>? get customColors {
if (_self.customColors == null) {
return null;
}
return $ThemeColorsCopyWith<$Res>(_self.customColors!, (value) {
return _then(_self.copyWith(customColors: value));
});
}/// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$DashboardConfigCopyWith<$Res>? get dashboardConfig {
if (_self.dashboardConfig == null) {
return null;
}
return $DashboardConfigCopyWith<$Res>(_self.dashboardConfig!, (value) {
return _then(_self.copyWith(dashboardConfig: value));
});
}
}
/// Adds pattern-matching-related methods to [AppSettings].
extension AppSettingsPatterns on AppSettings {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _AppSettings value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _AppSettings() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _AppSettings value) $default,){
final _that = this;
switch (_that) {
case _AppSettings():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _AppSettings value)? $default,){
final _that = this;
switch (_that) {
case _AppSettings() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool dataSavingMode, bool soundEffects, bool festivalFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, bool notifyWithHaptic, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool disableAnimation, bool groupedChatList, String? firstLaunchAt, bool askedReview, String? dashSearchEngine, String? defaultScreen, bool showFediverseContent, DashboardConfig? dashboardConfig)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _AppSettings() when $default != null:
return $default(_that.dataSavingMode,_that.soundEffects,_that.festivalFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.notifyWithHaptic,_that.customFonts,_that.appColorScheme,_that.customColors,_that.windowSize,_that.windowOpacity,_that.cardTransparency,_that.defaultPoolId,_that.messageDisplayStyle,_that.themeMode,_that.disableAnimation,_that.groupedChatList,_that.firstLaunchAt,_that.askedReview,_that.dashSearchEngine,_that.defaultScreen,_that.showFediverseContent,_that.dashboardConfig);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool dataSavingMode, bool soundEffects, bool festivalFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, bool notifyWithHaptic, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool disableAnimation, bool groupedChatList, String? firstLaunchAt, bool askedReview, String? dashSearchEngine, String? defaultScreen, bool showFediverseContent, DashboardConfig? dashboardConfig) $default,) {final _that = this;
switch (_that) {
case _AppSettings():
return $default(_that.dataSavingMode,_that.soundEffects,_that.festivalFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.notifyWithHaptic,_that.customFonts,_that.appColorScheme,_that.customColors,_that.windowSize,_that.windowOpacity,_that.cardTransparency,_that.defaultPoolId,_that.messageDisplayStyle,_that.themeMode,_that.disableAnimation,_that.groupedChatList,_that.firstLaunchAt,_that.askedReview,_that.dashSearchEngine,_that.defaultScreen,_that.showFediverseContent,_that.dashboardConfig);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool dataSavingMode, bool soundEffects, bool festivalFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, bool notifyWithHaptic, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool disableAnimation, bool groupedChatList, String? firstLaunchAt, bool askedReview, String? dashSearchEngine, String? defaultScreen, bool showFediverseContent, DashboardConfig? dashboardConfig)? $default,) {final _that = this;
switch (_that) {
case _AppSettings() when $default != null:
return $default(_that.dataSavingMode,_that.soundEffects,_that.festivalFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.notifyWithHaptic,_that.customFonts,_that.appColorScheme,_that.customColors,_that.windowSize,_that.windowOpacity,_that.cardTransparency,_that.defaultPoolId,_that.messageDisplayStyle,_that.themeMode,_that.disableAnimation,_that.groupedChatList,_that.firstLaunchAt,_that.askedReview,_that.dashSearchEngine,_that.defaultScreen,_that.showFediverseContent,_that.dashboardConfig);case _:
return null;
}
}
}
/// @nodoc
class _AppSettings implements AppSettings {
const _AppSettings({required this.dataSavingMode, required this.soundEffects, required this.festivalFeatures, required this.enterToSend, required this.appBarTransparent, required this.showBackgroundImage, required this.notifyWithHaptic, required this.customFonts, required this.appColorScheme, required this.customColors, required this.windowSize, required this.windowOpacity, required this.cardTransparency, required this.defaultPoolId, required this.messageDisplayStyle, required this.themeMode, required this.disableAnimation, required this.groupedChatList, required this.firstLaunchAt, required this.askedReview, required this.dashSearchEngine, required this.defaultScreen, required this.showFediverseContent, required this.dashboardConfig});
@override final bool dataSavingMode;
@override final bool soundEffects;
@override final bool festivalFeatures;
@override final bool enterToSend;
@override final bool appBarTransparent;
@override final bool showBackgroundImage;
@override final bool notifyWithHaptic;
@override final String? customFonts;
@override final int? appColorScheme;
// The color stored via the int type
@override final ThemeColors? customColors;
@override final Size? windowSize;
// The window size for desktop platforms
@override final double windowOpacity;
// The window opacity for desktop platforms
@override final double cardTransparency;
// The card background opacity
@override final String? defaultPoolId;
@override final String messageDisplayStyle;
@override final String? themeMode;
@override final bool disableAnimation;
@override final bool groupedChatList;
@override final String? firstLaunchAt;
@override final bool askedReview;
@override final String? dashSearchEngine;
@override final String? defaultScreen;
@override final bool showFediverseContent;
@override final DashboardConfig? dashboardConfig;
/// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$AppSettingsCopyWith<_AppSettings> get copyWith => __$AppSettingsCopyWithImpl<_AppSettings>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppSettings&&(identical(other.dataSavingMode, dataSavingMode) || other.dataSavingMode == dataSavingMode)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.festivalFeatures, festivalFeatures) || other.festivalFeatures == festivalFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend)&&(identical(other.appBarTransparent, appBarTransparent) || other.appBarTransparent == appBarTransparent)&&(identical(other.showBackgroundImage, showBackgroundImage) || other.showBackgroundImage == showBackgroundImage)&&(identical(other.notifyWithHaptic, notifyWithHaptic) || other.notifyWithHaptic == notifyWithHaptic)&&(identical(other.customFonts, customFonts) || other.customFonts == customFonts)&&(identical(other.appColorScheme, appColorScheme) || other.appColorScheme == appColorScheme)&&(identical(other.customColors, customColors) || other.customColors == customColors)&&(identical(other.windowSize, windowSize) || other.windowSize == windowSize)&&(identical(other.windowOpacity, windowOpacity) || other.windowOpacity == windowOpacity)&&(identical(other.cardTransparency, cardTransparency) || other.cardTransparency == cardTransparency)&&(identical(other.defaultPoolId, defaultPoolId) || other.defaultPoolId == defaultPoolId)&&(identical(other.messageDisplayStyle, messageDisplayStyle) || other.messageDisplayStyle == messageDisplayStyle)&&(identical(other.themeMode, themeMode) || other.themeMode == themeMode)&&(identical(other.disableAnimation, disableAnimation) || other.disableAnimation == disableAnimation)&&(identical(other.groupedChatList, groupedChatList) || other.groupedChatList == groupedChatList)&&(identical(other.firstLaunchAt, firstLaunchAt) || other.firstLaunchAt == firstLaunchAt)&&(identical(other.askedReview, askedReview) || other.askedReview == askedReview)&&(identical(other.dashSearchEngine, dashSearchEngine) || other.dashSearchEngine == dashSearchEngine)&&(identical(other.defaultScreen, defaultScreen) || other.defaultScreen == defaultScreen)&&(identical(other.showFediverseContent, showFediverseContent) || other.showFediverseContent == showFediverseContent)&&(identical(other.dashboardConfig, dashboardConfig) || other.dashboardConfig == dashboardConfig));
}
@override
int get hashCode => Object.hashAll([runtimeType,dataSavingMode,soundEffects,festivalFeatures,enterToSend,appBarTransparent,showBackgroundImage,notifyWithHaptic,customFonts,appColorScheme,customColors,windowSize,windowOpacity,cardTransparency,defaultPoolId,messageDisplayStyle,themeMode,disableAnimation,groupedChatList,firstLaunchAt,askedReview,dashSearchEngine,defaultScreen,showFediverseContent,dashboardConfig]);
@override
String toString() {
return 'AppSettings(dataSavingMode: $dataSavingMode, soundEffects: $soundEffects, festivalFeatures: $festivalFeatures, enterToSend: $enterToSend, appBarTransparent: $appBarTransparent, showBackgroundImage: $showBackgroundImage, notifyWithHaptic: $notifyWithHaptic, customFonts: $customFonts, appColorScheme: $appColorScheme, customColors: $customColors, windowSize: $windowSize, windowOpacity: $windowOpacity, cardTransparency: $cardTransparency, defaultPoolId: $defaultPoolId, messageDisplayStyle: $messageDisplayStyle, themeMode: $themeMode, disableAnimation: $disableAnimation, groupedChatList: $groupedChatList, firstLaunchAt: $firstLaunchAt, askedReview: $askedReview, dashSearchEngine: $dashSearchEngine, defaultScreen: $defaultScreen, showFediverseContent: $showFediverseContent, dashboardConfig: $dashboardConfig)';
}
}
/// @nodoc
abstract mixin class _$AppSettingsCopyWith<$Res> implements $AppSettingsCopyWith<$Res> {
factory _$AppSettingsCopyWith(_AppSettings value, $Res Function(_AppSettings) _then) = __$AppSettingsCopyWithImpl;
@override @useResult
$Res call({
bool dataSavingMode, bool soundEffects, bool festivalFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, bool notifyWithHaptic, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool disableAnimation, bool groupedChatList, String? firstLaunchAt, bool askedReview, String? dashSearchEngine, String? defaultScreen, bool showFediverseContent, DashboardConfig? dashboardConfig
});
@override $ThemeColorsCopyWith<$Res>? get customColors;@override $DashboardConfigCopyWith<$Res>? get dashboardConfig;
}
/// @nodoc
class __$AppSettingsCopyWithImpl<$Res>
implements _$AppSettingsCopyWith<$Res> {
__$AppSettingsCopyWithImpl(this._self, this._then);
final _AppSettings _self;
final $Res Function(_AppSettings) _then;
/// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? dataSavingMode = null,Object? soundEffects = null,Object? festivalFeatures = null,Object? enterToSend = null,Object? appBarTransparent = null,Object? showBackgroundImage = null,Object? notifyWithHaptic = null,Object? customFonts = freezed,Object? appColorScheme = freezed,Object? customColors = freezed,Object? windowSize = freezed,Object? windowOpacity = null,Object? cardTransparency = null,Object? defaultPoolId = freezed,Object? messageDisplayStyle = null,Object? themeMode = freezed,Object? disableAnimation = null,Object? groupedChatList = null,Object? firstLaunchAt = freezed,Object? askedReview = null,Object? dashSearchEngine = freezed,Object? defaultScreen = freezed,Object? showFediverseContent = null,Object? dashboardConfig = freezed,}) {
return _then(_AppSettings(
dataSavingMode: null == dataSavingMode ? _self.dataSavingMode : dataSavingMode // ignore: cast_nullable_to_non_nullable
as bool,soundEffects: null == soundEffects ? _self.soundEffects : soundEffects // ignore: cast_nullable_to_non_nullable
as bool,festivalFeatures: null == festivalFeatures ? _self.festivalFeatures : festivalFeatures // ignore: cast_nullable_to_non_nullable
as bool,enterToSend: null == enterToSend ? _self.enterToSend : enterToSend // ignore: cast_nullable_to_non_nullable
as bool,appBarTransparent: null == appBarTransparent ? _self.appBarTransparent : appBarTransparent // ignore: cast_nullable_to_non_nullable
as bool,showBackgroundImage: null == showBackgroundImage ? _self.showBackgroundImage : showBackgroundImage // ignore: cast_nullable_to_non_nullable
as bool,notifyWithHaptic: null == notifyWithHaptic ? _self.notifyWithHaptic : notifyWithHaptic // ignore: cast_nullable_to_non_nullable
as bool,customFonts: freezed == customFonts ? _self.customFonts : customFonts // ignore: cast_nullable_to_non_nullable
as String?,appColorScheme: freezed == appColorScheme ? _self.appColorScheme : appColorScheme // ignore: cast_nullable_to_non_nullable
as int?,customColors: freezed == customColors ? _self.customColors : customColors // ignore: cast_nullable_to_non_nullable
as ThemeColors?,windowSize: freezed == windowSize ? _self.windowSize : windowSize // ignore: cast_nullable_to_non_nullable
as Size?,windowOpacity: null == windowOpacity ? _self.windowOpacity : windowOpacity // ignore: cast_nullable_to_non_nullable
as double,cardTransparency: null == cardTransparency ? _self.cardTransparency : cardTransparency // ignore: cast_nullable_to_non_nullable
as double,defaultPoolId: freezed == defaultPoolId ? _self.defaultPoolId : defaultPoolId // ignore: cast_nullable_to_non_nullable
as String?,messageDisplayStyle: null == messageDisplayStyle ? _self.messageDisplayStyle : messageDisplayStyle // ignore: cast_nullable_to_non_nullable
as String,themeMode: freezed == themeMode ? _self.themeMode : themeMode // ignore: cast_nullable_to_non_nullable
as String?,disableAnimation: null == disableAnimation ? _self.disableAnimation : disableAnimation // ignore: cast_nullable_to_non_nullable
as bool,groupedChatList: null == groupedChatList ? _self.groupedChatList : groupedChatList // ignore: cast_nullable_to_non_nullable
as bool,firstLaunchAt: freezed == firstLaunchAt ? _self.firstLaunchAt : firstLaunchAt // ignore: cast_nullable_to_non_nullable
as String?,askedReview: null == askedReview ? _self.askedReview : askedReview // ignore: cast_nullable_to_non_nullable
as bool,dashSearchEngine: freezed == dashSearchEngine ? _self.dashSearchEngine : dashSearchEngine // ignore: cast_nullable_to_non_nullable
as String?,defaultScreen: freezed == defaultScreen ? _self.defaultScreen : defaultScreen // ignore: cast_nullable_to_non_nullable
as String?,showFediverseContent: null == showFediverseContent ? _self.showFediverseContent : showFediverseContent // ignore: cast_nullable_to_non_nullable
as bool,dashboardConfig: freezed == dashboardConfig ? _self.dashboardConfig : dashboardConfig // ignore: cast_nullable_to_non_nullable
as DashboardConfig?,
));
}
/// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$ThemeColorsCopyWith<$Res>? get customColors {
if (_self.customColors == null) {
return null;
}
return $ThemeColorsCopyWith<$Res>(_self.customColors!, (value) {
return _then(_self.copyWith(customColors: value));
});
}/// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$DashboardConfigCopyWith<$Res>? get dashboardConfig {
if (_self.dashboardConfig == null) {
return null;
}
return $DashboardConfigCopyWith<$Res>(_self.dashboardConfig!, (value) {
return _then(_self.copyWith(dashboardConfig: value));
});
}
}
// dart format on

106
lib/core/config.g.dart Normal file
View File

@@ -0,0 +1,106 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'config.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ThemeColors _$ThemeColorsFromJson(Map<String, dynamic> json) => _ThemeColors(
primary: (json['primary'] as num?)?.toInt(),
secondary: (json['secondary'] as num?)?.toInt(),
tertiary: (json['tertiary'] as num?)?.toInt(),
surface: (json['surface'] as num?)?.toInt(),
background: (json['background'] as num?)?.toInt(),
error: (json['error'] as num?)?.toInt(),
);
Map<String, dynamic> _$ThemeColorsToJson(_ThemeColors instance) =>
<String, dynamic>{
'primary': instance.primary,
'secondary': instance.secondary,
'tertiary': instance.tertiary,
'surface': instance.surface,
'background': instance.background,
'error': instance.error,
};
_DashboardConfig _$DashboardConfigFromJson(Map<String, dynamic> json) =>
_DashboardConfig(
verticalLayouts: (json['vertical_layouts'] as List<dynamic>)
.map((e) => e as String)
.toList(),
horizontalLayouts: (json['horizontal_layouts'] as List<dynamic>)
.map((e) => e as String)
.toList(),
showSearchBar: json['show_search_bar'] as bool,
showClockAndCountdown: json['show_clock_and_countdown'] as bool,
);
Map<String, dynamic> _$DashboardConfigToJson(_DashboardConfig instance) =>
<String, dynamic>{
'vertical_layouts': instance.verticalLayouts,
'horizontal_layouts': instance.horizontalLayouts,
'show_search_bar': instance.showSearchBar,
'show_clock_and_countdown': instance.showClockAndCountdown,
};
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(AppSettingsNotifier)
final appSettingsProvider = AppSettingsNotifierProvider._();
final class AppSettingsNotifierProvider
extends $NotifierProvider<AppSettingsNotifier, AppSettings> {
AppSettingsNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'appSettingsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$appSettingsNotifierHash();
@$internal
@override
AppSettingsNotifier create() => AppSettingsNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AppSettings value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AppSettings>(value),
);
}
}
String _$appSettingsNotifierHash() =>
r'fc474771ced89ec8637c0f773a9c6bc392f0df60';
abstract class _$AppSettingsNotifier extends $Notifier<AppSettings> {
AppSettings build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AppSettings, AppSettings>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AppSettings, AppSettings>,
AppSettings,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/core/config.dart';
typedef WidgetBuilder = Widget Function();
class DataSavingGate extends ConsumerWidget {
final bool bypass;
final WidgetBuilder content;
final Widget placeholder;
const DataSavingGate({
super.key,
required this.bypass,
required this.content,
required this.placeholder,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final dataSaving = ref.watch(
appSettingsProvider.select((s) => s.dataSavingMode),
);
if (bypass || !dataSaving) return content();
return placeholder;
}
}

37
lib/core/database.dart Normal file
View File

@@ -0,0 +1,37 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/data/drift_db.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:island/data/database.native.dart'
if (dart.library.html) 'package:island/data/database.web.dart';
final databaseProvider = Provider<AppDatabase>((ref) {
final db = constructDb();
ref.onDispose(() => db.close());
return db;
});
Future<void> resetDatabase(WidgetRef ref) async {
if (kIsWeb) return;
final db = ref.read(databaseProvider);
// Close current database connection
await db.close();
// Get the correct database file path
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(join(dbFolder.path, 'solar_network_data.sqlite'));
// Delete database file if it exists
if (await file.exists()) {
await file.delete();
}
// Force refresh the database provider to create a new instance
ref.invalidate(databaseProvider);
}

275
lib/core/debug_sheet.dart Normal file
View File

@@ -0,0 +1,275 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/core/database.dart';
import 'package:island/core/network.dart';
import 'package:island/fitness/fitness_data.dart';
import 'package:island/fitness/fitness_service.dart';
import 'package:island/core/services/update_service.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/core/widgets/content/network_status_sheet.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:island/core/config.dart';
import 'package:talker_flutter/talker_flutter.dart';
import 'package:island/talker.dart';
Future<void> _showSetTokenDialog(BuildContext context, WidgetRef ref) async {
final TextEditingController controller = TextEditingController();
final prefs = ref.read(sharedPreferencesProvider);
return showDialog<void>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Set access token'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
hintText: 'Enter access token',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
autofocus: true,
),
actions: <Widget>[
TextButton(
child: const Text('Cancel'),
onPressed: () {
Navigator.of(context).pop();
},
),
TextButton(
child: const Text('Set'),
onPressed: () async {
final token = controller.text.trim();
if (token.isNotEmpty) {
await setToken(prefs, token);
ref.invalidate(tokenProvider);
// Store context in local variable to avoid async gap issue
final navigatorContext = context;
if (navigatorContext.mounted) {
Navigator.of(navigatorContext).pop();
}
}
},
),
],
);
},
);
}
class DebugSheet extends HookConsumerWidget {
const DebugSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SheetScaffold(
titleText: 'Debug',
heightFactor: 0.6,
child: SingleChildScrollView(
child: Column(
children: [
const Gap(4),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.update),
trailing: const Icon(Symbols.chevron_right),
title: Text('Force update'),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () async {
// Fetch latest release and show the unified sheet
final svc = UpdateService();
// Reuse service fetch + compare to decide content
showLoadingModal(context);
final release = await svc.fetchLatestRelease();
if (!context.mounted) return;
hideLoadingModal(context);
if (release != null) {
await svc.showUpdateSheet(context, release);
} else {
showInfoAlert(
'Currently cannot get update from the GitHub.',
'Unable to check for updates',
);
}
},
),
const Divider(height: 8),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.wifi),
trailing: const Icon(Symbols.chevron_right),
title: Text('Connection status'),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => NetworkStatusSheet(),
);
},
),
const Divider(height: 8),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.bug_report),
trailing: const Icon(Symbols.chevron_right),
title: Text('Logs'),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => TalkerScreen(talker: talker),
),
);
},
),
const Divider(height: 8),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.error),
trailing: const Icon(Symbols.chevron_right),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: const Text('Test error alert'),
onTap: () {
showErrorAlert(
'This is a test error message for debugging purposes.',
);
},
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.info),
trailing: const Icon(Symbols.chevron_right),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: const Text('Test info alert'),
onTap: () {
showInfoAlert(
'This is a test info message for debugging purposes.',
'Test Alert',
);
},
),
const Divider(height: 8),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.copy_all),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('Copy access token'),
onTap: () async {
final tk = ref.watch(tokenProvider);
Clipboard.setData(ClipboardData(text: tk!.token));
},
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.edit),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('Set access token'),
onTap: () async {
await _showSetTokenDialog(context, ref);
},
),
const Divider(height: 8),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.delete),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('Reset database'),
onTap: () async {
resetDatabase(ref);
},
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.clear),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('Clear cache'),
onTap: () async {
DefaultCacheManager().emptyCache();
},
),
const Divider(height: 8),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.fitness_center),
trailing: const Icon(Symbols.chevron_right),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: const Text('Load Last 7 Days Workouts'),
onTap: () async {
try {
final fitnessService = ref.read(fitnessServiceProvider);
// Check if platform is supported
if (!fitnessService.isPlatformSupported) {
showErrorAlert(
'Fitness data is only available on iOS and Android devices.',
);
return;
}
// Check permissions first
final permissionStatus = await fitnessService
.getPermissionStatus();
if (permissionStatus != FitnessPermissionStatus.granted) {
final granted = await fitnessService.requestPermissions();
if (!granted) {
showErrorAlert(
'Permission to access fitness data was denied. Please enable it in your device settings.',
);
return;
}
}
// Get workouts from the last 7 days
final workouts = await fitnessService.getWorkoutsLast7Days();
if (workouts.isEmpty) {
showInfoAlert(
'No workout data found for the last 7 days.',
'No Data',
);
return;
}
// Format the workout data for display
StringBuffer sb = StringBuffer();
for (final workout in workouts) {
final dateStr =
'${workout.startTime.day}/${workout.startTime.month}';
final energyStr = workout.energyBurnedString.isNotEmpty
? '${workout.energyBurnedString}'
: '';
final distanceStr = workout.distanceString.isNotEmpty
? '${workout.distanceString}'
: '';
final stepsStr = workout.stepsString.isNotEmpty
? '${workout.stepsString} steps'
: '';
sb.write(
'${workout.workoutTypeString}$dateStr${workout.durationString}$energyStr$distanceStr$stepsStr\n',
);
}
showInfoAlert(sb.toString(), 'Workout Data Retrieved');
} catch (e) {
showErrorAlert(e);
}
},
),
],
),
),
);
}
}

30
lib/core/lifecycle.dart Normal file
View File

@@ -0,0 +1,30 @@
import "dart:async";
import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
final appLifecycleStateProvider = StreamProvider<AppLifecycleState>((ref) {
final controller = StreamController<AppLifecycleState>();
final observer = _AppLifecycleObserver((state) {
if (controller.isClosed) return;
controller.add(state);
});
WidgetsBinding.instance.addObserver(observer);
ref.onDispose(() {
WidgetsBinding.instance.removeObserver(observer);
controller.close();
});
return controller.stream;
});
class _AppLifecycleObserver extends WidgetsBindingObserver {
final ValueChanged<AppLifecycleState> onChange;
_AppLifecycleObserver(this.onChange);
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
onChange(state);
}
}

View File

@@ -0,0 +1,28 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/posts/posts_models/embed.dart';
import 'package:island/core/network.dart';
part 'link_preview.g.dart';
@riverpod
class LinkPreview extends _$LinkPreview {
@override
Future<SnScrappedLink?> build(String url) async {
final client = ref.read(apiClientProvider);
try {
final response = await client.get(
'/scrap/link',
queryParameters: {'url': url},
);
if (response.statusCode == 200 && response.data != null) {
return SnScrappedLink.fromJson(response.data);
}
return null;
} catch (e) {
// Return null on error to show fallback UI
return null;
}
}
}

View File

@@ -0,0 +1,99 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'link_preview.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(LinkPreview)
final linkPreviewProvider = LinkPreviewFamily._();
final class LinkPreviewProvider
extends $AsyncNotifierProvider<LinkPreview, SnScrappedLink?> {
LinkPreviewProvider._({
required LinkPreviewFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'linkPreviewProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$linkPreviewHash();
@override
String toString() {
return r'linkPreviewProvider'
''
'($argument)';
}
@$internal
@override
LinkPreview create() => LinkPreview();
@override
bool operator ==(Object other) {
return other is LinkPreviewProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$linkPreviewHash() => r'5130593d3066155cb958d20714ee577df1f940d7';
final class LinkPreviewFamily extends $Family
with
$ClassFamilyOverride<
LinkPreview,
AsyncValue<SnScrappedLink?>,
SnScrappedLink?,
FutureOr<SnScrappedLink?>,
String
> {
LinkPreviewFamily._()
: super(
retry: null,
name: r'linkPreviewProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
LinkPreviewProvider call(String url) =>
LinkPreviewProvider._(argument: url, from: this);
@override
String toString() => r'linkPreviewProvider';
}
abstract class _$LinkPreview extends $AsyncNotifier<SnScrappedLink?> {
late final _$args = ref.$arg as String;
String get url => _$args;
FutureOr<SnScrappedLink?> build(String url);
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<SnScrappedLink?>, SnScrappedLink?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<SnScrappedLink?>, SnScrappedLink?>,
AsyncValue<SnScrappedLink?>,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}

View File

@@ -0,0 +1,103 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/accounts/accounts_models/account.dart';
part 'activity.freezed.dart';
part 'activity.g.dart';
@freezed
sealed class SnNotableDay with _$SnNotableDay {
const factory SnNotableDay({
required DateTime date,
required String localName,
required String globalName,
required String? countryCode,
required String? localizableKey,
required List<int> holidays,
}) = _SnNotableDay;
factory SnNotableDay.fromJson(Map<String, dynamic> json) =>
_$SnNotableDayFromJson(json);
}
@freezed
sealed class SnTimelineEvent with _$SnTimelineEvent {
const factory SnTimelineEvent({
required String id,
required String type,
required String resourceIdentifier,
required dynamic data,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnTimelineEvent;
factory SnTimelineEvent.fromJson(Map<String, dynamic> json) =>
_$SnTimelineEventFromJson(json);
}
@freezed
sealed class SnCheckInResult with _$SnCheckInResult {
const factory SnCheckInResult({
required String id,
required int level,
required List<SnFortuneTip> tips,
required String accountId,
required SnAccount? account,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnCheckInResult;
factory SnCheckInResult.fromJson(Map<String, dynamic> json) =>
_$SnCheckInResultFromJson(json);
}
@freezed
sealed class SnFortuneTip with _$SnFortuneTip {
const factory SnFortuneTip({
required bool isPositive,
required String title,
required String content,
}) = _SnFortuneTip;
factory SnFortuneTip.fromJson(Map<String, dynamic> json) =>
_$SnFortuneTipFromJson(json);
}
@freezed
sealed class SnEventCalendarEntry with _$SnEventCalendarEntry {
const factory SnEventCalendarEntry({
required DateTime date,
required SnCheckInResult? checkInResult,
required List<SnAccountStatus> statuses,
}) = _SnEventCalendarEntry;
factory SnEventCalendarEntry.fromJson(Map<String, dynamic> json) =>
_$SnEventCalendarEntryFromJson(json);
}
@freezed
sealed class SnPresenceActivity with _$SnPresenceActivity {
const factory SnPresenceActivity({
required String id,
required int type,
required String? manualId,
required String? title,
required String? subtitle,
required String? caption,
required String? titleUrl,
required String? subtitleUrl,
required String? smallImage,
required String? largeImage,
required Map<String, dynamic>? meta,
required int leaseMinutes,
required DateTime leaseExpiresAt,
required String accountId,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnPresenceActivity;
factory SnPresenceActivity.fromJson(Map<String, dynamic> json) =>
_$SnPresenceActivityFromJson(json);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,163 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'activity.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_SnNotableDay _$SnNotableDayFromJson(Map<String, dynamic> json) =>
_SnNotableDay(
date: DateTime.parse(json['date'] as String),
localName: json['local_name'] as String,
globalName: json['global_name'] as String,
countryCode: json['country_code'] as String?,
localizableKey: json['localizable_key'] as String?,
holidays: (json['holidays'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
);
Map<String, dynamic> _$SnNotableDayToJson(_SnNotableDay instance) =>
<String, dynamic>{
'date': instance.date.toIso8601String(),
'local_name': instance.localName,
'global_name': instance.globalName,
'country_code': instance.countryCode,
'localizable_key': instance.localizableKey,
'holidays': instance.holidays,
};
_SnTimelineEvent _$SnTimelineEventFromJson(Map<String, dynamic> json) =>
_SnTimelineEvent(
id: json['id'] as String,
type: json['type'] as String,
resourceIdentifier: json['resource_identifier'] as String,
data: json['data'],
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
);
Map<String, dynamic> _$SnTimelineEventToJson(_SnTimelineEvent instance) =>
<String, dynamic>{
'id': instance.id,
'type': instance.type,
'resource_identifier': instance.resourceIdentifier,
'data': instance.data,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnCheckInResult _$SnCheckInResultFromJson(Map<String, dynamic> json) =>
_SnCheckInResult(
id: json['id'] as String,
level: (json['level'] as num).toInt(),
tips: (json['tips'] as List<dynamic>)
.map((e) => SnFortuneTip.fromJson(e as Map<String, dynamic>))
.toList(),
accountId: json['account_id'] as String,
account: json['account'] == null
? null
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
);
Map<String, dynamic> _$SnCheckInResultToJson(_SnCheckInResult instance) =>
<String, dynamic>{
'id': instance.id,
'level': instance.level,
'tips': instance.tips.map((e) => e.toJson()).toList(),
'account_id': instance.accountId,
'account': instance.account?.toJson(),
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnFortuneTip _$SnFortuneTipFromJson(Map<String, dynamic> json) =>
_SnFortuneTip(
isPositive: json['is_positive'] as bool,
title: json['title'] as String,
content: json['content'] as String,
);
Map<String, dynamic> _$SnFortuneTipToJson(_SnFortuneTip instance) =>
<String, dynamic>{
'is_positive': instance.isPositive,
'title': instance.title,
'content': instance.content,
};
_SnEventCalendarEntry _$SnEventCalendarEntryFromJson(
Map<String, dynamic> json,
) => _SnEventCalendarEntry(
date: DateTime.parse(json['date'] as String),
checkInResult: json['check_in_result'] == null
? null
: SnCheckInResult.fromJson(
json['check_in_result'] as Map<String, dynamic>,
),
statuses: (json['statuses'] as List<dynamic>)
.map((e) => SnAccountStatus.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$SnEventCalendarEntryToJson(
_SnEventCalendarEntry instance,
) => <String, dynamic>{
'date': instance.date.toIso8601String(),
'check_in_result': instance.checkInResult?.toJson(),
'statuses': instance.statuses.map((e) => e.toJson()).toList(),
};
_SnPresenceActivity _$SnPresenceActivityFromJson(Map<String, dynamic> json) =>
_SnPresenceActivity(
id: json['id'] as String,
type: (json['type'] as num).toInt(),
manualId: json['manual_id'] as String?,
title: json['title'] as String?,
subtitle: json['subtitle'] as String?,
caption: json['caption'] as String?,
titleUrl: json['title_url'] as String?,
subtitleUrl: json['subtitle_url'] as String?,
smallImage: json['small_image'] as String?,
largeImage: json['large_image'] as String?,
meta: json['meta'] as Map<String, dynamic>?,
leaseMinutes: (json['lease_minutes'] as num).toInt(),
leaseExpiresAt: DateTime.parse(json['lease_expires_at'] as String),
accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
);
Map<String, dynamic> _$SnPresenceActivityToJson(_SnPresenceActivity instance) =>
<String, dynamic>{
'id': instance.id,
'type': instance.type,
'manual_id': instance.manualId,
'title': instance.title,
'subtitle': instance.subtitle,
'caption': instance.caption,
'title_url': instance.titleUrl,
'subtitle_url': instance.subtitleUrl,
'small_image': instance.smallImage,
'large_image': instance.largeImage,
'meta': instance.meta,
'lease_minutes': instance.leaseMinutes,
'lease_expires_at': instance.leaseExpiresAt.toIso8601String(),
'account_id': instance.accountId,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};

View File

@@ -0,0 +1,108 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'activitypub.freezed.dart';
part 'activitypub.g.dart';
@freezed
sealed class SnActivityPubInstance with _$SnActivityPubInstance {
const factory SnActivityPubInstance({
required String id,
required String domain,
String? name,
String? description,
String? software,
String? version,
String? iconUrl,
String? thumbnailUrl,
String? contactEmail,
String? contactAccountUsername,
int? activeUsers,
@Default(false) bool isBlocked,
@Default(false) bool isSilenced,
String? blockReason,
Map<String, dynamic>? metadata,
DateTime? lastFetchedAt,
DateTime? lastActivityAt,
DateTime? metadataFetchedAt,
}) = _SnActivityPubInstance;
factory SnActivityPubInstance.fromJson(Map<String, dynamic> json) =>
_$SnActivityPubInstanceFromJson(json);
}
@freezed
sealed class SnActivityPubUser with _$SnActivityPubUser {
const factory SnActivityPubUser({
required String actorUri,
required String username,
required String displayName,
required String bio,
required String avatarUrl,
required DateTime followedAt,
required bool isLocal,
required String instanceDomain,
}) = _SnActivityPubUser;
factory SnActivityPubUser.fromJson(Map<String, dynamic> json) =>
_$SnActivityPubUserFromJson(json);
}
@freezed
sealed class SnActivityPubActor with _$SnActivityPubActor {
const factory SnActivityPubActor({
required String id,
required String uri,
@Default('') String type,
String? displayName,
String? username,
String? summary,
String? inboxUri,
String? outboxUri,
String? followersUri,
String? followingUri,
String? featuredUri,
String? avatarUrl,
String? headerUrl,
String? publicKeyId,
String? publicKey,
@Default(false) bool isBot,
@Default(false) bool isLocked,
@Default(true) bool discoverable,
@Default(false) bool manuallyApprovesFollowers,
Map<String, dynamic>? endpoints,
Map<String, dynamic>? publicKeyData,
Map<String, dynamic>? metadata,
DateTime? lastFetchedAt,
DateTime? lastActivityAt,
required SnActivityPubInstance instance,
required String instanceId,
bool? isFollowing,
}) = _SnActivityPubActor;
factory SnActivityPubActor.fromJson(Map<String, dynamic> json) =>
_$SnActivityPubActorFromJson(json);
}
@freezed
sealed class SnActivityPubFollowResponse with _$SnActivityPubFollowResponse {
const factory SnActivityPubFollowResponse({
required bool success,
required String message,
}) = _SnActivityPubFollowResponse;
factory SnActivityPubFollowResponse.fromJson(Map<String, dynamic> json) =>
_$SnActivityPubFollowResponseFromJson(json);
}
@freezed
sealed class SnActorStatusResponse with _$SnActorStatusResponse {
const factory SnActorStatusResponse({
required bool enabled,
@Default(0) int followerCount,
SnActivityPubActor? actor,
String? actorUri,
}) = _SnActorStatusResponse;
factory SnActorStatusResponse.fromJson(Map<String, dynamic> json) =>
_$SnActorStatusResponseFromJson(json);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,186 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'activitypub.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_SnActivityPubInstance _$SnActivityPubInstanceFromJson(
Map<String, dynamic> json,
) => _SnActivityPubInstance(
id: json['id'] as String,
domain: json['domain'] as String,
name: json['name'] as String?,
description: json['description'] as String?,
software: json['software'] as String?,
version: json['version'] as String?,
iconUrl: json['icon_url'] as String?,
thumbnailUrl: json['thumbnail_url'] as String?,
contactEmail: json['contact_email'] as String?,
contactAccountUsername: json['contact_account_username'] as String?,
activeUsers: (json['active_users'] as num?)?.toInt(),
isBlocked: json['is_blocked'] as bool? ?? false,
isSilenced: json['is_silenced'] as bool? ?? false,
blockReason: json['block_reason'] as String?,
metadata: json['metadata'] as Map<String, dynamic>?,
lastFetchedAt: json['last_fetched_at'] == null
? null
: DateTime.parse(json['last_fetched_at'] as String),
lastActivityAt: json['last_activity_at'] == null
? null
: DateTime.parse(json['last_activity_at'] as String),
metadataFetchedAt: json['metadata_fetched_at'] == null
? null
: DateTime.parse(json['metadata_fetched_at'] as String),
);
Map<String, dynamic> _$SnActivityPubInstanceToJson(
_SnActivityPubInstance instance,
) => <String, dynamic>{
'id': instance.id,
'domain': instance.domain,
'name': instance.name,
'description': instance.description,
'software': instance.software,
'version': instance.version,
'icon_url': instance.iconUrl,
'thumbnail_url': instance.thumbnailUrl,
'contact_email': instance.contactEmail,
'contact_account_username': instance.contactAccountUsername,
'active_users': instance.activeUsers,
'is_blocked': instance.isBlocked,
'is_silenced': instance.isSilenced,
'block_reason': instance.blockReason,
'metadata': instance.metadata,
'last_fetched_at': instance.lastFetchedAt?.toIso8601String(),
'last_activity_at': instance.lastActivityAt?.toIso8601String(),
'metadata_fetched_at': instance.metadataFetchedAt?.toIso8601String(),
};
_SnActivityPubUser _$SnActivityPubUserFromJson(Map<String, dynamic> json) =>
_SnActivityPubUser(
actorUri: json['actor_uri'] as String,
username: json['username'] as String,
displayName: json['display_name'] as String,
bio: json['bio'] as String,
avatarUrl: json['avatar_url'] as String,
followedAt: DateTime.parse(json['followed_at'] as String),
isLocal: json['is_local'] as bool,
instanceDomain: json['instance_domain'] as String,
);
Map<String, dynamic> _$SnActivityPubUserToJson(_SnActivityPubUser instance) =>
<String, dynamic>{
'actor_uri': instance.actorUri,
'username': instance.username,
'display_name': instance.displayName,
'bio': instance.bio,
'avatar_url': instance.avatarUrl,
'followed_at': instance.followedAt.toIso8601String(),
'is_local': instance.isLocal,
'instance_domain': instance.instanceDomain,
};
_SnActivityPubActor _$SnActivityPubActorFromJson(Map<String, dynamic> json) =>
_SnActivityPubActor(
id: json['id'] as String,
uri: json['uri'] as String,
type: json['type'] as String? ?? '',
displayName: json['display_name'] as String?,
username: json['username'] as String?,
summary: json['summary'] as String?,
inboxUri: json['inbox_uri'] as String?,
outboxUri: json['outbox_uri'] as String?,
followersUri: json['followers_uri'] as String?,
followingUri: json['following_uri'] as String?,
featuredUri: json['featured_uri'] as String?,
avatarUrl: json['avatar_url'] as String?,
headerUrl: json['header_url'] as String?,
publicKeyId: json['public_key_id'] as String?,
publicKey: json['public_key'] as String?,
isBot: json['is_bot'] as bool? ?? false,
isLocked: json['is_locked'] as bool? ?? false,
discoverable: json['discoverable'] as bool? ?? true,
manuallyApprovesFollowers:
json['manually_approves_followers'] as bool? ?? false,
endpoints: json['endpoints'] as Map<String, dynamic>?,
publicKeyData: json['public_key_data'] as Map<String, dynamic>?,
metadata: json['metadata'] as Map<String, dynamic>?,
lastFetchedAt: json['last_fetched_at'] == null
? null
: DateTime.parse(json['last_fetched_at'] as String),
lastActivityAt: json['last_activity_at'] == null
? null
: DateTime.parse(json['last_activity_at'] as String),
instance: SnActivityPubInstance.fromJson(
json['instance'] as Map<String, dynamic>,
),
instanceId: json['instance_id'] as String,
isFollowing: json['is_following'] as bool?,
);
Map<String, dynamic> _$SnActivityPubActorToJson(_SnActivityPubActor instance) =>
<String, dynamic>{
'id': instance.id,
'uri': instance.uri,
'type': instance.type,
'display_name': instance.displayName,
'username': instance.username,
'summary': instance.summary,
'inbox_uri': instance.inboxUri,
'outbox_uri': instance.outboxUri,
'followers_uri': instance.followersUri,
'following_uri': instance.followingUri,
'featured_uri': instance.featuredUri,
'avatar_url': instance.avatarUrl,
'header_url': instance.headerUrl,
'public_key_id': instance.publicKeyId,
'public_key': instance.publicKey,
'is_bot': instance.isBot,
'is_locked': instance.isLocked,
'discoverable': instance.discoverable,
'manually_approves_followers': instance.manuallyApprovesFollowers,
'endpoints': instance.endpoints,
'public_key_data': instance.publicKeyData,
'metadata': instance.metadata,
'last_fetched_at': instance.lastFetchedAt?.toIso8601String(),
'last_activity_at': instance.lastActivityAt?.toIso8601String(),
'instance': instance.instance.toJson(),
'instance_id': instance.instanceId,
'is_following': instance.isFollowing,
};
_SnActivityPubFollowResponse _$SnActivityPubFollowResponseFromJson(
Map<String, dynamic> json,
) => _SnActivityPubFollowResponse(
success: json['success'] as bool,
message: json['message'] as String,
);
Map<String, dynamic> _$SnActivityPubFollowResponseToJson(
_SnActivityPubFollowResponse instance,
) => <String, dynamic>{
'success': instance.success,
'message': instance.message,
};
_SnActorStatusResponse _$SnActorStatusResponseFromJson(
Map<String, dynamic> json,
) => _SnActorStatusResponse(
enabled: json['enabled'] as bool,
followerCount: (json['follower_count'] as num?)?.toInt() ?? 0,
actor: json['actor'] == null
? null
: SnActivityPubActor.fromJson(json['actor'] as Map<String, dynamic>),
actorUri: json['actor_uri'] as String?,
);
Map<String, dynamic> _$SnActorStatusResponseToJson(
_SnActorStatusResponse instance,
) => <String, dynamic>{
'enabled': instance.enabled,
'follower_count': instance.followerCount,
'actor': instance.actor?.toJson(),
'actor_uri': instance.actorUri,
};

View File

@@ -0,0 +1,231 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:material_symbols_icons/symbols.dart';
part 'route_item.freezed.dart';
@freezed
sealed class RouteItem with _$RouteItem {
const factory RouteItem({
required String name,
required String path,
required String description,
@Default([]) List<String> searchableAliases,
required IconData icon,
}) = _RouteItem;
}
final List<RouteItem> kAvailableRoutes = [
RouteItem(
name: 'dashboard'.tr(),
path: '/',
description: 'dashboardDescription'.tr(),
searchableAliases: ['dashboard', 'home'],
icon: Symbols.home,
),
RouteItem(
name: 'explore'.tr(),
path: '/explore',
description: 'exploreDescription'.tr(),
searchableAliases: ['explore', 'discover'],
icon: Symbols.explore,
),
RouteItem(
name: 'universalSearch'.tr(),
path: '/search',
description: 'universalSearchDescription'.tr(),
searchableAliases: ['search', 'universal', 'fediverse'],
icon: Symbols.search,
),
RouteItem(
name: 'postShuffle'.tr(),
path: '/posts/shuffle',
description: 'postShuffleDescription'.tr(),
searchableAliases: ['shuffle', 'random', 'posts'],
icon: Symbols.shuffle,
),
RouteItem(
name: 'postTagsCategories'.tr(),
path: '/posts/categories',
description: 'postTagsCategoriesDescription'.tr(),
searchableAliases: ['tags', 'categories', 'posts'],
icon: Symbols.category,
),
RouteItem(
name: 'discoverRealms'.tr(),
path: '/discovery/realms',
description: 'discoverRealmsDescription'.tr(),
searchableAliases: ['realms', 'groups', 'communities'],
icon: Symbols.public,
),
RouteItem(
name: 'chat'.tr(),
path: '/chat',
description: 'chatDescription'.tr(),
searchableAliases: ['chat', 'messages', 'conversations', 'dm'],
icon: Symbols.chat,
),
RouteItem(
name: 'realms'.tr(),
path: '/realms',
description: 'realmsDescription'.tr(),
searchableAliases: ['realms', 'groups', 'communities'],
icon: Symbols.group,
),
RouteItem(
name: 'account'.tr(),
path: '/account',
description: 'accountDescription'.tr(),
searchableAliases: ['account', 'me', 'profile', 'user'],
icon: Symbols.person,
),
RouteItem(
name: 'stickerMarketplace'.tr(),
path: '/stickers',
description: 'stickerMarketplaceDescription'.tr(),
searchableAliases: ['stickers', 'marketplace', 'emojis', 'emojis'],
icon: Symbols.emoji_emotions,
),
RouteItem(
name: 'webFeeds'.tr(),
path: '/feeds',
description: 'webFeedsDescription'.tr(),
searchableAliases: ['feeds', 'web feeds', 'rss', 'news'],
icon: Symbols.feed,
),
RouteItem(
name: 'wallet'.tr(),
path: '/account/wallet',
description: 'walletDescription'.tr(),
searchableAliases: [
'wallet',
'balance',
'money',
'source points',
'gold points',
'nsp',
'shd',
],
icon: Symbols.account_balance_wallet,
),
RouteItem(
name: 'relationships'.tr(),
path: '/account/relationships',
description: 'relationshipsDescription'.tr(),
searchableAliases: ['relationships', 'friends', 'block list', 'blocks'],
icon: Symbols.people,
),
RouteItem(
name: 'updateYourProfile'.tr(),
path: '/account/me/update',
description: 'updateYourProfileDescription'.tr(),
searchableAliases: ['profile', 'update', 'edit', 'my profile'],
icon: Symbols.edit,
),
RouteItem(
name: 'leveling'.tr(),
path: '/account/me/leveling',
description: 'levelingDescription'.tr(),
searchableAliases: [
'leveling',
'level',
'levels',
'subscriptions',
'social credits',
],
icon: Symbols.trending_up,
),
RouteItem(
name: 'accountSettings'.tr(),
path: '/account/me/settings',
description: 'accountSettingsDescription'.tr(),
searchableAliases: [
'settings',
'preferences',
'account',
'account settings',
],
icon: Symbols.settings,
),
RouteItem(
name: 'abuseReports'.tr(),
path: '/safety/reports/me',
description: 'abuseReportsDescription'.tr(),
searchableAliases: ['reports', 'abuse', 'safety'],
icon: Symbols.report,
),
RouteItem(
name: 'files'.tr(),
path: '/files',
description: 'filesDescription'.tr(),
searchableAliases: ['files', 'folders', 'storage', 'drive', 'cloud'],
icon: Symbols.folder,
),
RouteItem(
name: 'aiThought'.tr(),
path: '/thought',
description: 'aiThoughtTitle'.tr(),
searchableAliases: ['thought', 'ai', 'ai thought'],
icon: Symbols.psychology,
),
RouteItem(
name: 'creatorHub'.tr(),
path: '/creators',
description: 'creatorHubDescription'.tr(),
searchableAliases: ['creators', 'hub', 'creator hub', 'creators hub'],
icon: Symbols.create,
),
RouteItem(
name: 'developerPortal'.tr(),
path: '/developers',
description: 'developerPortalDescription'.tr(),
searchableAliases: [
'developers',
'dev',
'developer',
'developer hub',
'developers hub',
],
icon: Symbols.code,
),
RouteItem(
name: 'debugLogs'.tr(),
path: '/logs',
description: 'debugLogsDescription'.tr(),
searchableAliases: ['logs', 'debug', 'debug logs'],
icon: Symbols.bug_report,
),
RouteItem(
name: 'webArticlesStand'.tr(),
path: '/feeds/articles',
description: 'webArticlesStandDescription'.tr(),
searchableAliases: ['articles', 'stand', 'feed', 'web feed'],
icon: Symbols.article,
),
RouteItem(
name: 'appSettings'.tr(),
path: '/settings',
description: 'appSettingsDescription'.tr(),
searchableAliases: ['settings', 'preferences', 'app', 'app settings'],
icon: Symbols.settings,
),
RouteItem(
name: 'about'.tr(),
path: '/about',
description: 'about'.tr(),
searchableAliases: ['about', 'info'],
icon: Symbols.info,
),
];
@freezed
sealed class SpecialAction with _$SpecialAction {
const factory SpecialAction({
required String name,
required String description,
required IconData icon,
required VoidCallback action,
@Default([]) List<String> searchableAliases,
}) = _SpecialAction;
}

View File

@@ -0,0 +1,552 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'route_item.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$RouteItem {
String get name; String get path; String get description; List<String> get searchableAliases; IconData get icon;
/// Create a copy of RouteItem
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$RouteItemCopyWith<RouteItem> get copyWith => _$RouteItemCopyWithImpl<RouteItem>(this as RouteItem, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is RouteItem&&(identical(other.name, name) || other.name == name)&&(identical(other.path, path) || other.path == path)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.searchableAliases, searchableAliases)&&(identical(other.icon, icon) || other.icon == icon));
}
@override
int get hashCode => Object.hash(runtimeType,name,path,description,const DeepCollectionEquality().hash(searchableAliases),icon);
@override
String toString() {
return 'RouteItem(name: $name, path: $path, description: $description, searchableAliases: $searchableAliases, icon: $icon)';
}
}
/// @nodoc
abstract mixin class $RouteItemCopyWith<$Res> {
factory $RouteItemCopyWith(RouteItem value, $Res Function(RouteItem) _then) = _$RouteItemCopyWithImpl;
@useResult
$Res call({
String name, String path, String description, List<String> searchableAliases, IconData icon
});
}
/// @nodoc
class _$RouteItemCopyWithImpl<$Res>
implements $RouteItemCopyWith<$Res> {
_$RouteItemCopyWithImpl(this._self, this._then);
final RouteItem _self;
final $Res Function(RouteItem) _then;
/// Create a copy of RouteItem
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? path = null,Object? description = null,Object? searchableAliases = null,Object? icon = null,}) {
return _then(_self.copyWith(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,path: null == path ? _self.path : path // ignore: cast_nullable_to_non_nullable
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String,searchableAliases: null == searchableAliases ? _self.searchableAliases : searchableAliases // ignore: cast_nullable_to_non_nullable
as List<String>,icon: null == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable
as IconData,
));
}
}
/// Adds pattern-matching-related methods to [RouteItem].
extension RouteItemPatterns on RouteItem {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _RouteItem value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _RouteItem() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _RouteItem value) $default,){
final _that = this;
switch (_that) {
case _RouteItem():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _RouteItem value)? $default,){
final _that = this;
switch (_that) {
case _RouteItem() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, String path, String description, List<String> searchableAliases, IconData icon)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _RouteItem() when $default != null:
return $default(_that.name,_that.path,_that.description,_that.searchableAliases,_that.icon);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, String path, String description, List<String> searchableAliases, IconData icon) $default,) {final _that = this;
switch (_that) {
case _RouteItem():
return $default(_that.name,_that.path,_that.description,_that.searchableAliases,_that.icon);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, String path, String description, List<String> searchableAliases, IconData icon)? $default,) {final _that = this;
switch (_that) {
case _RouteItem() when $default != null:
return $default(_that.name,_that.path,_that.description,_that.searchableAliases,_that.icon);case _:
return null;
}
}
}
/// @nodoc
class _RouteItem implements RouteItem {
const _RouteItem({required this.name, required this.path, required this.description, final List<String> searchableAliases = const [], required this.icon}): _searchableAliases = searchableAliases;
@override final String name;
@override final String path;
@override final String description;
final List<String> _searchableAliases;
@override@JsonKey() List<String> get searchableAliases {
if (_searchableAliases is EqualUnmodifiableListView) return _searchableAliases;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_searchableAliases);
}
@override final IconData icon;
/// Create a copy of RouteItem
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$RouteItemCopyWith<_RouteItem> get copyWith => __$RouteItemCopyWithImpl<_RouteItem>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _RouteItem&&(identical(other.name, name) || other.name == name)&&(identical(other.path, path) || other.path == path)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._searchableAliases, _searchableAliases)&&(identical(other.icon, icon) || other.icon == icon));
}
@override
int get hashCode => Object.hash(runtimeType,name,path,description,const DeepCollectionEquality().hash(_searchableAliases),icon);
@override
String toString() {
return 'RouteItem(name: $name, path: $path, description: $description, searchableAliases: $searchableAliases, icon: $icon)';
}
}
/// @nodoc
abstract mixin class _$RouteItemCopyWith<$Res> implements $RouteItemCopyWith<$Res> {
factory _$RouteItemCopyWith(_RouteItem value, $Res Function(_RouteItem) _then) = __$RouteItemCopyWithImpl;
@override @useResult
$Res call({
String name, String path, String description, List<String> searchableAliases, IconData icon
});
}
/// @nodoc
class __$RouteItemCopyWithImpl<$Res>
implements _$RouteItemCopyWith<$Res> {
__$RouteItemCopyWithImpl(this._self, this._then);
final _RouteItem _self;
final $Res Function(_RouteItem) _then;
/// Create a copy of RouteItem
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? path = null,Object? description = null,Object? searchableAliases = null,Object? icon = null,}) {
return _then(_RouteItem(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,path: null == path ? _self.path : path // ignore: cast_nullable_to_non_nullable
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String,searchableAliases: null == searchableAliases ? _self._searchableAliases : searchableAliases // ignore: cast_nullable_to_non_nullable
as List<String>,icon: null == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable
as IconData,
));
}
}
/// @nodoc
mixin _$SpecialAction {
String get name; String get description; IconData get icon; VoidCallback get action; List<String> get searchableAliases;
/// Create a copy of SpecialAction
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SpecialActionCopyWith<SpecialAction> get copyWith => _$SpecialActionCopyWithImpl<SpecialAction>(this as SpecialAction, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SpecialAction&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.icon, icon) || other.icon == icon)&&(identical(other.action, action) || other.action == action)&&const DeepCollectionEquality().equals(other.searchableAliases, searchableAliases));
}
@override
int get hashCode => Object.hash(runtimeType,name,description,icon,action,const DeepCollectionEquality().hash(searchableAliases));
@override
String toString() {
return 'SpecialAction(name: $name, description: $description, icon: $icon, action: $action, searchableAliases: $searchableAliases)';
}
}
/// @nodoc
abstract mixin class $SpecialActionCopyWith<$Res> {
factory $SpecialActionCopyWith(SpecialAction value, $Res Function(SpecialAction) _then) = _$SpecialActionCopyWithImpl;
@useResult
$Res call({
String name, String description, IconData icon, VoidCallback action, List<String> searchableAliases
});
}
/// @nodoc
class _$SpecialActionCopyWithImpl<$Res>
implements $SpecialActionCopyWith<$Res> {
_$SpecialActionCopyWithImpl(this._self, this._then);
final SpecialAction _self;
final $Res Function(SpecialAction) _then;
/// Create a copy of SpecialAction
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? description = null,Object? icon = null,Object? action = null,Object? searchableAliases = null,}) {
return _then(_self.copyWith(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String,icon: null == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable
as IconData,action: null == action ? _self.action : action // ignore: cast_nullable_to_non_nullable
as VoidCallback,searchableAliases: null == searchableAliases ? _self.searchableAliases : searchableAliases // ignore: cast_nullable_to_non_nullable
as List<String>,
));
}
}
/// Adds pattern-matching-related methods to [SpecialAction].
extension SpecialActionPatterns on SpecialAction {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SpecialAction value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SpecialAction() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SpecialAction value) $default,){
final _that = this;
switch (_that) {
case _SpecialAction():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SpecialAction value)? $default,){
final _that = this;
switch (_that) {
case _SpecialAction() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, String description, IconData icon, VoidCallback action, List<String> searchableAliases)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SpecialAction() when $default != null:
return $default(_that.name,_that.description,_that.icon,_that.action,_that.searchableAliases);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, String description, IconData icon, VoidCallback action, List<String> searchableAliases) $default,) {final _that = this;
switch (_that) {
case _SpecialAction():
return $default(_that.name,_that.description,_that.icon,_that.action,_that.searchableAliases);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, String description, IconData icon, VoidCallback action, List<String> searchableAliases)? $default,) {final _that = this;
switch (_that) {
case _SpecialAction() when $default != null:
return $default(_that.name,_that.description,_that.icon,_that.action,_that.searchableAliases);case _:
return null;
}
}
}
/// @nodoc
class _SpecialAction implements SpecialAction {
const _SpecialAction({required this.name, required this.description, required this.icon, required this.action, final List<String> searchableAliases = const []}): _searchableAliases = searchableAliases;
@override final String name;
@override final String description;
@override final IconData icon;
@override final VoidCallback action;
final List<String> _searchableAliases;
@override@JsonKey() List<String> get searchableAliases {
if (_searchableAliases is EqualUnmodifiableListView) return _searchableAliases;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_searchableAliases);
}
/// Create a copy of SpecialAction
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SpecialActionCopyWith<_SpecialAction> get copyWith => __$SpecialActionCopyWithImpl<_SpecialAction>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SpecialAction&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.icon, icon) || other.icon == icon)&&(identical(other.action, action) || other.action == action)&&const DeepCollectionEquality().equals(other._searchableAliases, _searchableAliases));
}
@override
int get hashCode => Object.hash(runtimeType,name,description,icon,action,const DeepCollectionEquality().hash(_searchableAliases));
@override
String toString() {
return 'SpecialAction(name: $name, description: $description, icon: $icon, action: $action, searchableAliases: $searchableAliases)';
}
}
/// @nodoc
abstract mixin class _$SpecialActionCopyWith<$Res> implements $SpecialActionCopyWith<$Res> {
factory _$SpecialActionCopyWith(_SpecialAction value, $Res Function(_SpecialAction) _then) = __$SpecialActionCopyWithImpl;
@override @useResult
$Res call({
String name, String description, IconData icon, VoidCallback action, List<String> searchableAliases
});
}
/// @nodoc
class __$SpecialActionCopyWithImpl<$Res>
implements _$SpecialActionCopyWith<$Res> {
__$SpecialActionCopyWithImpl(this._self, this._then);
final _SpecialAction _self;
final $Res Function(_SpecialAction) _then;
/// Create a copy of SpecialAction
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? description = null,Object? icon = null,Object? action = null,Object? searchableAliases = null,}) {
return _then(_SpecialAction(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String,icon: null == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable
as IconData,action: null == action ? _self.action : action // ignore: cast_nullable_to_non_nullable
as VoidCallback,searchableAliases: null == searchableAliases ? _self._searchableAliases : searchableAliases // ignore: cast_nullable_to_non_nullable
as List<String>,
));
}
}
// dart format on

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/settings/tabs_screen.dart';
import 'package:island/core/services/responsive.dart';
class ConditionalBottomNav extends HookConsumerWidget {
final Widget child;
const ConditionalBottomNav({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentLocation = GoRouterState.of(context).uri.toString();
// Force rebuild when route changes
useEffect(() {
// This effect will run whenever currentLocation changes
return null;
}, [currentLocation]);
final routes = kTabRoutes.sublist(
0,
isWideScreen(context) ? null : kWideScreenRouteStart,
);
final shouldShowBottomNav = routes.contains(currentLocation);
return shouldShowBottomNav ? child : const SizedBox.shrink();
}
}

210
lib/core/network.dart Normal file
View File

@@ -0,0 +1,210 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio_smart_retry/dio_smart_retry.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:dio/dio.dart';
import 'package:image_picker/image_picker.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:island/auth/auth_models/auth.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:talker_dio_logger/talker_dio_logger.dart';
import 'package:island/talker.dart';
import 'config.dart';
part 'network.g.dart';
// Network status enum to track different states
enum NetworkStatus { online, notReady, maintenance, offline }
// Provider for network status using Riverpod v3 annotation
@riverpod
class NetworkStatusNotifier extends _$NetworkStatusNotifier {
@override
NetworkStatus build() {
return NetworkStatus.online;
}
void setOnline() {
state = NetworkStatus.online;
}
void setMaintenance() {
state = NetworkStatus.maintenance;
}
void setOffline() {
state = NetworkStatus.offline;
}
void setNotReady() {
state = NetworkStatus.notReady;
}
}
final imagePickerProvider = Provider((ref) => ImagePicker());
final userAgentProvider = FutureProvider<String>((ref) async {
// Helper function to sanitize strings for HTTP headers
String sanitizeForHeader(String input) {
// Remove or replace characters that are not allowed in HTTP headers
// Keep only ASCII printable characters (32-126) and replace others with underscore
return input.runes.map((rune) {
if (rune >= 32 && rune <= 126) {
return String.fromCharCode(rune);
} else {
return '_';
}
}).join();
}
final String platformInfo;
if (kIsWeb) {
final deviceInfo = await DeviceInfoPlugin().webBrowserInfo;
platformInfo = 'Web; ${sanitizeForHeader(deviceInfo.vendor ?? 'Unknown')}';
} else if (Platform.isAndroid) {
final deviceInfo = await DeviceInfoPlugin().androidInfo;
platformInfo =
'Android; ${sanitizeForHeader(deviceInfo.brand)} ${sanitizeForHeader(deviceInfo.model)}; ${sanitizeForHeader(deviceInfo.id)}';
} else if (Platform.isIOS) {
final deviceInfo = await DeviceInfoPlugin().iosInfo;
platformInfo =
'iOS; ${sanitizeForHeader(deviceInfo.model)}; ${sanitizeForHeader(deviceInfo.name)}';
} else if (Platform.isMacOS) {
final deviceInfo = await DeviceInfoPlugin().macOsInfo;
platformInfo =
'MacOS; ${sanitizeForHeader(deviceInfo.model)}; ${sanitizeForHeader(deviceInfo.hostName)}';
} else if (Platform.isWindows) {
final deviceInfo = await DeviceInfoPlugin().windowsInfo;
platformInfo =
'Windows NT; ${sanitizeForHeader(deviceInfo.productName)}; ${sanitizeForHeader(deviceInfo.computerName)}';
} else if (Platform.isLinux) {
final deviceInfo = await DeviceInfoPlugin().linuxInfo;
platformInfo = 'Linux; ${sanitizeForHeader(deviceInfo.prettyName)}';
} else {
platformInfo = 'Unknown';
}
final packageInfo = await PackageInfo.fromPlatform();
return 'Solian/${packageInfo.version}+${packageInfo.buildNumber} ($platformInfo)';
});
final apiClientProvider = Provider<Dio>((ref) {
final serverUrl = ref.watch(serverUrlProvider);
final dio = Dio(
BaseOptions(
baseUrl: serverUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
),
);
dio.interceptors.addAll([
InterceptorsWrapper(
onRequest:
(RequestOptions options, RequestInterceptorHandler handler) async {
try {
final token = await getToken(ref.watch(tokenProvider));
if (token != null) {
options.headers['Authorization'] = 'AtField $token';
}
} catch (err) {
// ignore
}
final userAgent = ref.read(userAgentProvider);
if (userAgent.value != null) {
options.headers['User-Agent'] = userAgent.value;
}
return handler.next(options);
},
onResponse: (response, handler) {
// Check for 503 status code (Service Unavailable/Maintenance)
if (response.statusCode == 503) {
final networkStatusNotifier = ref.read(
networkStatusProvider.notifier,
);
if (response.headers.value('X-NotReady') != null) {
networkStatusNotifier.setNotReady();
} else {
networkStatusNotifier.setMaintenance();
}
} else if (response.statusCode != null &&
response.statusCode! >= 200 &&
response.statusCode! < 300) {
// Set online status for successful responses
final networkStatusNotifier = ref.read(
networkStatusProvider.notifier,
);
networkStatusNotifier.setOnline();
}
return handler.next(response);
},
onError: (error, handler) {
// Handle network errors and set offline status
if (error.response?.statusCode == 503) {
final networkStatusNotifier = ref.read(
networkStatusProvider.notifier,
);
if (error.response?.headers.value('X-NotReady') != null) {
networkStatusNotifier.setNotReady();
} else {
networkStatusNotifier.setMaintenance();
}
}
return handler.next(error);
},
),
TalkerDioLogger(
talker: talker,
settings: const TalkerDioLoggerSettings(
printRequestHeaders: false,
printResponseHeaders: false,
printResponseMessage: false,
printRequestData: false,
printResponseData: false,
),
),
RetryInterceptor(
dio: dio,
retries: 3,
retryDelays: const [
Duration(milliseconds: 300),
Duration(milliseconds: 500),
Duration(milliseconds: 1000),
],
retryEvaluator: (err, _) => err.requestOptions.method == 'GET',
),
]);
return dio;
});
final tokenProvider = Provider<AppToken?>((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
final tokenString = prefs.getString(kTokenPairStoreKey);
if (tokenString == null) return null;
return AppToken.fromJson(jsonDecode(tokenString));
});
// Token refresh functionality removed as per backend changes
Future<String?> getToken(AppToken? token) async {
return token?.token;
}
Future<void> setToken(SharedPreferences prefs, String token) async {
final appToken = AppToken(token: token);
final tokenString = jsonEncode(appToken);
prefs.setString(kTokenPairStoreKey, tokenString);
}

63
lib/core/network.g.dart Normal file
View File

@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'network.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(NetworkStatusNotifier)
final networkStatusProvider = NetworkStatusNotifierProvider._();
final class NetworkStatusNotifierProvider
extends $NotifierProvider<NetworkStatusNotifier, NetworkStatus> {
NetworkStatusNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'networkStatusProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$networkStatusNotifierHash();
@$internal
@override
NetworkStatusNotifier create() => NetworkStatusNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(NetworkStatus value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<NetworkStatus>(value),
);
}
}
String _$networkStatusNotifierHash() =>
r'6f08e3067fa5265432f28f64e10775e3039506c3';
abstract class _$NetworkStatusNotifier extends $Notifier<NetworkStatus> {
NetworkStatus build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<NetworkStatus, NetworkStatus>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<NetworkStatus, NetworkStatus>,
NetworkStatus,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

View File

@@ -0,0 +1,99 @@
import 'dart:async';
import 'package:island/accounts/accounts_models/account.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:uuid/uuid.dart';
part 'notification.g.dart';
const kNotificationBaseDuration = Duration(seconds: 5);
const kNotificationStackedDuration = Duration(seconds: 1);
class NotificationItem {
final String id;
final SnNotification notification;
final DateTime createdAt;
final int index;
final Duration duration;
final bool dismissed;
NotificationItem({
String? id,
required this.notification,
DateTime? createdAt,
required this.index,
Duration? duration,
this.dismissed = false,
}) : id = id ?? const Uuid().v4(),
createdAt = createdAt ?? DateTime.now(),
duration =
duration ?? kNotificationBaseDuration + Duration(seconds: index);
NotificationItem copyWith({
String? id,
SnNotification? notification,
DateTime? createdAt,
int? index,
Duration? duration,
bool? dismissed,
}) {
return NotificationItem(
id: id ?? this.id,
notification: notification ?? this.notification,
createdAt: createdAt ?? this.createdAt,
index: index ?? this.index,
duration: duration ?? this.duration,
dismissed: dismissed ?? this.dismissed,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is NotificationItem && other.id == id;
}
@override
int get hashCode => id.hashCode;
}
@riverpod
class NotificationState extends _$NotificationState {
final Map<String, Timer> _timers = {};
@override
List<NotificationItem> build() {
return [];
}
void add(SnNotification notification, {Duration? duration}) {
final newItem = NotificationItem(
notification: notification,
index: state.length,
duration: duration,
);
state = [...state, newItem];
_timers[newItem.id] = Timer(newItem.duration, () => dismiss(newItem.id));
}
void dismiss(String id) {
_timers[id]?.cancel();
_timers.remove(id);
final index = state.indexWhere((item) => item.id == id);
if (index != -1) {
state = List.from(state)..[index] = state[index].copyWith(dismissed: true);
}
}
void remove(String id) {
state = state.where((item) => item.id != id).toList();
}
void clear() {
for (final timer in _timers.values) {
timer.cancel();
}
_timers.clear();
state = [];
}
}

View File

@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'notification.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(NotificationState)
final notificationStateProvider = NotificationStateProvider._();
final class NotificationStateProvider
extends $NotifierProvider<NotificationState, List<NotificationItem>> {
NotificationStateProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'notificationStateProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$notificationStateHash();
@$internal
@override
NotificationState create() => NotificationState();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<NotificationItem> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<NotificationItem>>(value),
);
}
}
String _$notificationStateHash() => r'4597cfc7c75dd0fd05dab65f78265a3ae10d23e7';
abstract class _$NotificationState extends $Notifier<List<NotificationItem>> {
List<NotificationItem> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<List<NotificationItem>, List<NotificationItem>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<List<NotificationItem>, List<NotificationItem>>,
List<NotificationItem>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

View File

@@ -0,0 +1,90 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/core/models/activitypub.dart';
import 'package:island/core/network.dart';
final activityPubServiceProvider = Provider<ActivityPubService>((ref) {
final client = ref.watch(apiClientProvider);
return ActivityPubService(client);
});
class ActivityPubService {
final Dio _client;
ActivityPubService(this._client);
Future<void> followRemoteUser(String targetActorUri) async {
final response = await _client.post(
'/sphere/activitypub/follow',
data: {'target_actor_uri': targetActorUri},
);
final followResponse = SnActivityPubFollowResponse.fromJson(response.data);
if (!followResponse.success) {
throw Exception(followResponse.message);
}
}
Future<void> unfollowRemoteUser(String targetActorUri) async {
final response = await _client.post(
'/sphere/activitypub/unfollow',
data: {'target_actor_uri': targetActorUri},
);
final followResponse = SnActivityPubFollowResponse.fromJson(response.data);
if (!followResponse.success) {
throw Exception(followResponse.message);
}
}
Future<List<SnActivityPubUser>> getFollowing({int limit = 50}) async {
final response = await _client.get(
'/sphere/activitypub/following',
queryParameters: {'limit': limit},
);
final users = (response.data as List<dynamic>)
.map((json) => SnActivityPubUser.fromJson(json))
.toList();
return users;
}
Future<List<SnActivityPubUser>> getFollowers({int limit = 50}) async {
final response = await _client.get(
'/sphere/activitypub/followers',
queryParameters: {'limit': limit},
);
final users = (response.data as List<dynamic>)
.map((json) => SnActivityPubUser.fromJson(json))
.toList();
return users;
}
Future<List<SnActivityPubActor>> searchUsers(
String query, {
int limit = 20,
}) async {
final response = await _client.get(
'/sphere/activitypub/search',
queryParameters: {'query': query, 'limit': limit},
);
final users = (response.data as List<dynamic>)
.map((json) => SnActivityPubActor.fromJson(json))
.toList();
return users;
}
Future<SnActorStatusResponse> getPublisherActorStatus(
String publisherName,
) async {
final response = await _client.get(
'/sphere/publishers/$publisherName/fediverse',
);
return SnActorStatusResponse.fromJson(response.data);
}
Future<void> enablePublisherActor(String publisherName) async {
await _client.post('/sphere/publishers/$publisherName/fediverse');
}
Future<void> disablePublisherActor(String publisherName) async {
await _client.delete('/sphere/publishers/$publisherName/fediverse');
}
}

View File

@@ -0,0 +1,536 @@
import 'dart:io';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/foundation.dart';
import 'package:island/talker.dart';
class AnalyticsService {
static final AnalyticsService _instance = AnalyticsService._internal();
factory AnalyticsService() => _instance;
AnalyticsService._internal();
FirebaseAnalytics? _analytics;
bool _enabled = true;
bool get _supportsAnalytics =>
kIsWeb || (Platform.isAndroid || Platform.isIOS || Platform.isMacOS);
void initialize() {
if (!_supportsAnalytics) return;
try {
_analytics = FirebaseAnalytics.instance;
} catch (e) {
talker.warning('[Analytics] Failed to init: $e');
_analytics = null;
}
}
void logEvent(String name, Map<String, Object>? parameters) {
if (!_enabled || !_supportsAnalytics) return;
final analytics = _analytics;
if (analytics == null) return;
try {
analytics.logEvent(name: name, parameters: parameters);
} catch (e) {
talker.warning('[Analytics] Failed to log event $name: $e');
}
}
void setEnabled(bool enabled) {
_enabled = enabled;
}
void setUserId(String? id) {
if (!_supportsAnalytics) return;
final analytics = _analytics;
if (analytics == null) return;
try {
analytics.setUserId(id: id);
} catch (e) {
talker.warning('[Analytics] Failed to set user ID: $e');
}
}
void logAppOpen() {
logEvent('app_open', null);
}
void logLogin(String authMethod) {
logEvent('login', {'auth_method': authMethod, 'platform': _getPlatform()});
}
void logLogout() {
logEvent('logout', null);
}
void logPostViewed(String postId, String postType, String viewSource) {
logEvent('post_viewed', {
'post_id': postId,
'post_type': postType,
'view_source': viewSource,
});
}
void logPostCreated(
String postType,
String visibility,
bool hasAttachments,
String publisherId,
) {
logEvent('post_created', {
'post_type': postType,
'visibility': visibility,
'has_attachments': hasAttachments ? 'yes' : 'no',
'publisher_id': publisherId,
});
}
void logPostReacted(
String postId,
String reactionSymbol,
int attitude,
bool isRemoving,
) {
logEvent('post_reacted', {
'post_id': postId,
'reaction_symbol': reactionSymbol,
'attitude': attitude,
'is_removing': isRemoving ? 'yes' : 'no',
});
}
void logPostReplied(
String postId,
String parentId,
int characterCount,
bool hasAttachments,
) {
logEvent('post_replied', {
'post_id': postId,
'parent_id': parentId,
'character_count': characterCount,
'has_attachments': hasAttachments,
});
}
void logPostShared(String postId, String shareMethod, String postType) {
logEvent('post_shared', {
'post_id': postId,
'share_method': shareMethod,
'post_type': postType,
});
}
void logPostEdited(String postId, int contentChangeDelta) {
logEvent('post_edited', {
'post_id': postId,
'content_change_delta': contentChangeDelta,
});
}
void logPostDeleted(String postId, String postType, int timeSinceCreation) {
logEvent('post_deleted', {
'post_id': postId,
'post_type': postType,
'time_since_creation': timeSinceCreation,
});
}
void logPostPinned(String postId, String pinMode, String realmId) {
logEvent('post_pinned', {
'post_id': postId,
'pin_mode': pinMode,
'realm_id': realmId,
});
}
void logPostAwarded(
String postId,
double amount,
String attitude,
bool hasMessage,
) {
logEvent('post_awarded', {
'post_id': postId,
'amount': amount,
'attitude': attitude,
'has_message': hasMessage ? 'yes' : 'no',
'currency': 'NSP',
});
}
void logPostTranslated(
String postId,
String sourceLanguage,
String targetLanguage,
) {
logEvent('post_translated', {
'post_id': postId,
'source_language': sourceLanguage,
'target_language': targetLanguage,
});
}
void logPostForwarded(
String postId,
String originalPostId,
String publisherId,
) {
logEvent('post_forwarded', {
'post_id': postId,
'original_post_id': originalPostId,
'publisher_id': publisherId,
});
}
void logMessageSent(
String channelId,
String messageType,
bool hasAttachments,
int attachmentCount,
) {
logEvent('message_sent', {
'channel_id': channelId,
'message_type': messageType,
'has_attachments': hasAttachments,
'attachment_count': attachmentCount,
});
}
void logMessageReceived(
String channelId,
String messageType,
bool isMentioned,
) {
logEvent('message_received', {
'channel_id': channelId,
'message_type': messageType,
'is_mentioned': isMentioned,
});
}
void logMessageReplied(
String channelId,
String originalMessageId,
int replyDepth,
) {
logEvent('message_replied', {
'channel_id': channelId,
'original_message_id': originalMessageId,
'reply_depth': replyDepth,
});
}
void logMessageEdited(
String channelId,
String messageId,
int contentChangeDelta,
) {
logEvent('message_edited', {
'channel_id': channelId,
'message_id': messageId,
'content_change_delta': contentChangeDelta,
});
}
void logMessageDeleted(
String channelId,
String messageId,
String messageType,
bool isOwn,
) {
logEvent('message_deleted', {
'channel_id': channelId,
'message_id': messageId,
'message_type': messageType,
'is_own': isOwn,
});
}
void logChatRoomOpened(String channelId, String roomType) {
logEvent('chat_room_opened', {
'channel_id': channelId,
'room_type': roomType,
});
}
void logChatJoined(String channelId, String roomType, bool isPublic) {
logEvent('chat_joined', {
'channel_id': channelId,
'room_type': roomType,
'is_public': isPublic,
});
}
void logChatLeft(String channelId, String roomType) {
logEvent('chat_left', {'channel_id': channelId, 'room_type': roomType});
}
void logChatInvited(String channelId, String invitedUserId) {
logEvent('chat_invited', {
'channel_id': channelId,
'invited_user_id': invitedUserId,
});
}
void logFileUploaded(
String fileType,
String fileSizeCategory,
String uploadSource,
) {
logEvent('file_uploaded', {
'file_type': fileType,
'file_size_category': fileSizeCategory,
'upload_source': uploadSource,
});
}
void logFileDownloaded(
String fileType,
String fileSizeCategory,
String downloadMethod,
) {
logEvent('file_downloaded', {
'file_type': fileType,
'file_size_category': fileSizeCategory,
'download_method': downloadMethod,
});
}
void logFileDeleted(
String fileType,
String fileSizeCategory,
String deleteSource,
bool isBatchDelete,
int batchCount,
) {
logEvent('file_deleted', {
'file_type': fileType,
'file_size_category': fileSizeCategory,
'delete_source': deleteSource,
'is_batch_delete': isBatchDelete,
'batch_count': batchCount,
});
}
void logStickerUsed(String packId, String stickerSlug, String context) {
logEvent('sticker_used', {
'pack_id': packId,
'sticker_slug': stickerSlug,
'context': context,
});
}
void logStickerPackAdded(String packId, String packName, int stickerCount) {
logEvent('sticker_pack_added', {
'pack_id': packId,
'pack_name': packName,
'sticker_count': stickerCount,
});
}
void logStickerPackViewed(String packId, int stickerCount, bool isOwned) {
logEvent('sticker_pack_viewed', {
'pack_id': packId,
'sticker_count': stickerCount,
'is_owned': isOwned,
});
}
void logSearchPerformed(
String searchType,
String query,
int resultCount,
bool hasFilters,
) {
logEvent('search_performed', {
'search_type': searchType,
'query': query,
'result_count': resultCount,
'has_filters': hasFilters,
});
}
void logFeedSubscribed(String feedId, String feedUrl, int articleCount) {
logEvent('feed_subscribed', {
'feed_id': feedId,
'feed_url': feedUrl,
'article_count': articleCount,
});
}
void logFeedUnsubscribed(String feedId, String feedUrl) {
logEvent('feed_unsubscribed', {'feed_id': feedId, 'feed_url': feedUrl});
}
void logWalletTransfer(
double amount,
String currency,
String payeeId,
bool hasRemark,
) {
logEvent('wallet_transfer', {
'amount': amount,
'currency': currency,
'payee_id': payeeId,
'has_remark': hasRemark,
});
}
void logWalletBalanceChecked(List<String> currenciesViewed) {
logEvent('wallet_balance_checked', {
'currencies_viewed': currenciesViewed.join(','),
});
}
void logWalletOpened(String activeTab) {
logEvent('wallet_opened', {'active_tab': activeTab});
}
void logRealmJoined(String realmSlug, String realmType) {
logEvent('realm_joined', {
'realm_slug': realmSlug,
'realm_type': realmType,
});
}
void logRealmLeft(String realmSlug) {
logEvent('realm_left', {'realm_slug': realmSlug});
}
void logFriendAdded(String friendId, String pickerMethod) {
logEvent('friend_added', {
'friend_id': friendId,
'picker_method': pickerMethod,
});
}
void logFriendRemoved(String relationshipId, String relationshipType) {
logEvent('friend_removed', {
'relationship_id': relationshipId,
'relationship_type': relationshipType,
});
}
void logUserBlocked(String blockedUserId, String previousRelationship) {
logEvent('user_blocked', {
'blocked_user_id': blockedUserId,
'previous_relationship': previousRelationship,
});
}
void logUserUnblocked(String unblockedUserId) {
logEvent('user_unblocked', {'unblocked_user_id': unblockedUserId});
}
void logFriendRequestAccepted(String requesterId) {
logEvent('friend_request_accepted', {'requester_id': requesterId});
}
void logFriendRequestDeclined(String requesterId) {
logEvent('friend_request_declined', {'requester_id': requesterId});
}
void logThemeChanged(String oldMode, String newMode) {
logEvent('theme_changed', {'old_mode': oldMode, 'new_mode': newMode});
}
void logLanguageChanged(String oldLanguage, String newLanguage) {
logEvent('language_changed', {
'old_language': oldLanguage,
'new_language': newLanguage,
});
}
void logAiQuerySent(
int messageLength,
String contextType,
int attachedPostsCount,
) {
logEvent('ai_query_sent', {
'message_length': messageLength,
'context_type': contextType,
'attached_posts_count': attachedPostsCount,
});
}
void logAiResponseReceived(int responseThoughtCount, String sequenceId) {
logEvent('ai_response_received', {
'response_thought_count': responseThoughtCount,
'sequence_id': sequenceId,
});
}
void logShuffleViewed(int postIndex, int totalPostsLoaded) {
logEvent('shuffle_viewed', {
'post_index': postIndex,
'total_posts_loaded': totalPostsLoaded,
});
}
void logDraftSaved(String draftId, String postType, bool hasContent) {
logEvent('draft_saved', {
'draft_id': draftId,
'post_type': postType,
'has_content': hasContent,
});
}
void logDraftDeleted(String draftId, String postType) {
logEvent('draft_deleted', {'draft_id': draftId, 'post_type': postType});
}
void logCategoryViewed(String categorySlug, String categoryId) {
logEvent('category_viewed', {
'category_slug': categorySlug,
'category_id': categoryId,
});
}
void logTagViewed(String tagSlug, String tagId) {
logEvent('tag_viewed', {'tag_slug': tagSlug, 'tag_id': tagId});
}
void logCategorySubscribed(String categorySlug, String categoryId) {
logEvent('category_subscribed', {
'category_slug': categorySlug,
'category_id': categoryId,
});
}
void logTagSubscribed(String tagSlug, String tagId) {
logEvent('tag_subscribed', {'tag_slug': tagSlug, 'tag_id': tagId});
}
void logNotificationViewed() {
logEvent('notification_viewed', null);
}
void logNotificationActioned(String actionType, String notificationType) {
logEvent('notification_actioned', {
'action_type': actionType,
'notification_type': notificationType,
});
}
void logProfileUpdated(List<String> fieldsUpdated) {
logEvent('profile_updated', {'fields_updated': fieldsUpdated.join(',')});
}
void logAvatarChanged(String imageSource, bool isCropped) {
logEvent('avatar_changed', {
'image_source': imageSource,
'is_cropped': isCropped,
});
}
String _getPlatform() {
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
if (Platform.isMacOS) return 'macos';
if (Platform.isWindows) return 'windows';
if (Platform.isLinux) return 'linux';
return 'web';
}
}

View File

@@ -0,0 +1,7 @@
import 'package:flutter/widgets.dart';
extension ColorInversion on Color {
Color get invert {
return Color.fromARGB(alpha, 255 - red, 255 - green, 255 - blue);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/widgets.dart';
import 'package:image/image.dart' as img;
import 'package:island/talker.dart';
import 'package:material_color_utilities/material_color_utilities.dart' as mcu;
class ColorExtractionService {
/// Extracts dominant colors from an image provider.
/// Returns a list of colors suitable for UI theming.
static Future<List<Color>> getColorsFromImage(ImageProvider provider) async {
try {
if (provider is FileImage) {
final bytes = await provider.file.readAsBytes();
final image = img.decodeImage(bytes);
if (image == null) return [];
final Map<int, int> colorToCount = {};
for (int y = 0; y < image.height; y++) {
for (int x = 0; x < image.width; x++) {
final pixel = image.getPixel(x, y);
final r = pixel.r.toInt();
final g = pixel.g.toInt();
final b = pixel.b.toInt();
final a = pixel.a.toInt();
if (a == 0) continue;
final argb = (a << 24) | (r << 16) | (g << 8) | b;
colorToCount[argb] = (colorToCount[argb] ?? 0) + 1;
}
}
final List<int> filteredResults = mcu.Score.score(
colorToCount,
desired: 1,
filter: true,
);
final List<int> scoredResults = mcu.Score.score(
colorToCount,
desired: 4,
filter: false,
);
return <dynamic>{
...filteredResults,
...scoredResults,
}.toList().map((argb) => Color(argb)).toList();
} else {
return [];
}
} catch (e, stackTrace) {
talker.error('Error getting colors from image...', e, stackTrace);
return [];
}
}
}

View File

@@ -0,0 +1,53 @@
import 'package:event_bus/event_bus.dart';
/// Global event bus instance for the application
final eventBus = EventBus();
/// Event fired when a post is successfully created
class PostCreatedEvent {
final String? postId;
final String? title;
final String? content;
const PostCreatedEvent({this.postId, this.title, this.content});
}
/// Event fired when chat rooms need to be refreshed
class ChatRoomsRefreshEvent {
const ChatRoomsRefreshEvent();
}
/// Event fired when OIDC auth callback is received
class OidcAuthCallbackEvent {
final String challengeId;
const OidcAuthCallbackEvent(this.challengeId);
}
/// Event fired to trigger the command palette
class CommandPaletteTriggerEvent {
const CommandPaletteTriggerEvent();
}
/// Event fired to show the compose post sheet
class ShowComposeSheetEvent {
const ShowComposeSheetEvent();
}
/// Event fired to show the notification sheet
class ShowNotificationSheetEvent {
const ShowNotificationSheetEvent();
}
/// Event fired to show the thought sheet
class ShowThoughtSheetEvent {
final String? initialMessage;
final List<Map<String, dynamic>> attachedMessages;
final List<String> attachedPosts;
const ShowThoughtSheetEvent({
this.initialMessage,
this.attachedMessages = const [],
this.attachedPosts = const [],
});
}

View File

@@ -0,0 +1,36 @@
import 'dart:ui';
import 'package:croppy/croppy.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
Future<XFile?> cropImage(
BuildContext context, {
required XFile image,
List<CropAspectRatio?>? allowedAspectRatios,
bool replacePath = true,
}) async {
if (!context.mounted) return null;
final imageBytes = await image.readAsBytes();
if (!context.mounted) return null;
final result = await showMaterialImageCropper(
context,
imageProvider: MemoryImage(imageBytes),
showLoadingIndicatorOnSubmit: true,
allowedAspectRatios: allowedAspectRatios,
);
if (result == null) return null; // Cancelled operation
final croppedFile = result.uiImage;
final croppedBytes = await croppedFile.toByteData(
format: ImageByteFormat.png,
);
if (croppedBytes == null) {
return image;
}
croppedFile.dispose();
return XFile.fromData(
croppedBytes.buffer.asUint8List(),
path: !replacePath ? image.path : null,
mimeType: image.mimeType,
);
}

View File

@@ -0,0 +1,60 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Conditional imports based on platform
import 'notify.windows.dart' as windows_notify;
import 'notify.universal.dart' as universal_notify;
// Platform-specific delegation
Future<void> initializeLocalNotifications() async {
if (kIsWeb) {
// No local notifications on web
return;
}
if (Platform.isWindows) {
return windows_notify.initializeLocalNotifications();
} else {
return universal_notify.initializeLocalNotifications();
}
}
StreamSubscription? setupNotificationListener(
BuildContext context,
WidgetRef ref,
) {
if (kIsWeb) {
// No notification listener on web
return null;
}
if (Platform.isWindows) {
return windows_notify.setupNotificationListener(context, ref);
} else {
return universal_notify.setupNotificationListener(context, ref);
}
}
Future<void> subscribePushNotification(
Dio apiClient, {
bool detailedErrors = false,
}) async {
if (kIsWeb) {
// No push notification subscription on web
return;
}
if (Platform.isWindows) {
return windows_notify.subscribePushNotification(
apiClient,
detailedErrors: detailedErrors,
);
} else {
return universal_notify.subscribePushNotification(
apiClient,
detailedErrors: detailedErrors,
);
}
}

View File

@@ -0,0 +1,197 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:island/core/audio.dart';
import 'package:island/core/config.dart';
import 'package:island/core/notification.dart';
import 'package:island/route.dart';
import 'package:island/accounts/accounts_models/account.dart';
import 'package:island/core/websocket.dart';
import 'package:island/talker.dart';
import 'package:url_launcher/url_launcher_string.dart';
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
AppLifecycleState _appLifecycleState = AppLifecycleState.resumed;
void _onAppLifecycleChanged(AppLifecycleState state) {
_appLifecycleState = state;
}
Future<void> initializeLocalNotifications() async {
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
const DarwinInitializationSettings initializationSettingsIOS =
DarwinInitializationSettings();
const DarwinInitializationSettings initializationSettingsMacOS =
DarwinInitializationSettings();
const LinuxInitializationSettings initializationSettingsLinux =
LinuxInitializationSettings(defaultActionName: 'Open notification');
const WindowsInitializationSettings initializationSettingsWindows =
WindowsInitializationSettings(
appName: 'Island',
appUserModelId: 'dev.solsynth.solian',
guid: 'dev.solsynth.solian',
);
const InitializationSettings initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsIOS,
macOS: initializationSettingsMacOS,
linux: initializationSettingsLinux,
windows: initializationSettingsWindows,
);
await flutterLocalNotificationsPlugin.initialize(
settings: initializationSettings,
onDidReceiveNotificationResponse: (NotificationResponse response) async {
final payload = response.payload;
if (payload != null) {
if (payload.startsWith('/')) {
// In-app routes
rootNavigatorKey.currentContext?.push(payload);
} else {
// External URLs
launchUrlString(payload);
}
}
},
);
WidgetsBinding.instance.addObserver(
LifecycleEventHandler(onAppLifecycleChanged: _onAppLifecycleChanged),
);
}
class LifecycleEventHandler extends WidgetsBindingObserver {
final void Function(AppLifecycleState) onAppLifecycleChanged;
LifecycleEventHandler({required this.onAppLifecycleChanged});
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
onAppLifecycleChanged(state);
}
}
StreamSubscription<WebSocketPacket> setupNotificationListener(
BuildContext context,
WidgetRef ref,
) {
final settings = ref.watch(appSettingsProvider);
final ws = ref.watch(websocketProvider);
return ws.dataStream.listen((pkt) async {
if (pkt.type == "notifications.new") {
final notification = SnNotification.fromJson(pkt.data!);
if (_appLifecycleState == AppLifecycleState.resumed) {
talker.info(
'[Notification] Showing in-app notification: ${notification.title}',
);
if (settings.notifyWithHaptic) {
HapticFeedback.heavyImpact();
}
playNotificationSfx(ref);
ref.read(notificationStateProvider.notifier).add(notification);
} else {
// App is in background, show system notification (only on supported platforms)
if (!kIsWeb && !Platform.isIOS) {
talker.info(
'[Notification] Showing system notification: ${notification.title}',
);
// Use flutter_local_notifications for universal platforms
const AndroidNotificationDetails androidNotificationDetails =
AndroidNotificationDetails(
'channel_id',
'channel_name',
channelDescription: 'channel_description',
importance: Importance.max,
priority: Priority.high,
ticker: 'ticker',
);
const NotificationDetails notificationDetails = NotificationDetails(
android: androidNotificationDetails,
);
await flutterLocalNotificationsPlugin.show(
id: 0,
title: notification.title,
body: notification.content,
notificationDetails: notificationDetails,
payload: notification.meta['action_uri'] as String?,
);
} else {
talker.info(
'[Notification] Skipping system notification for unsupported platform: ${notification.title}',
);
}
}
}
});
}
Future<void> subscribePushNotification(
Dio apiClient, {
bool detailedErrors = false,
}) async {
if (!kIsWeb && Platform.isLinux) {
return;
}
await FirebaseMessaging.instance.requestPermission(
alert: true,
badge: true,
sound: true,
);
String? deviceToken;
if (kIsWeb) {
deviceToken = await FirebaseMessaging.instance.getToken(
vapidKey:
"BFN2mkqyeI6oi4d2PAV4pfNyG3Jy0FBEblmmPrjmP0r5lHOPrxrcqLIWhM21R_cicF-j4Xhtr1kyDyDgJYRPLgU",
);
} else if (Platform.isAndroid) {
deviceToken = await FirebaseMessaging.instance.getToken();
} else if (Platform.isIOS) {
deviceToken = await FirebaseMessaging.instance.getAPNSToken();
}
FirebaseMessaging.instance.onTokenRefresh
.listen((fcmToken) {
_putTokenToRemote(apiClient, fcmToken, 1);
})
.onError((err) {
talker.error("Failed to get firebase cloud messaging push token: $err");
});
if (deviceToken != null) {
_putTokenToRemote(
apiClient,
deviceToken,
!kIsWeb && (Platform.isIOS || Platform.isMacOS) ? 0 : 1,
);
} else if (detailedErrors) {
throw Exception("Failed to get device token for push notifications.");
}
}
Future<void> _putTokenToRemote(
Dio apiClient,
String token,
int provider,
) async {
await apiClient.put(
"/ring/notifications/subscription",
data: {"provider": provider, "device_token": token},
);
}

View File

@@ -0,0 +1,179 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/core/audio.dart';
import 'package:island/core/config.dart';
import 'package:island/core/notification.dart';
import 'package:island/accounts/accounts_models/account.dart';
import 'package:island/core/websocket.dart';
import 'package:island/talker.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:windows_notification/windows_notification.dart' as winty;
import 'package:windows_notification/notification_message.dart';
// Windows notification instance
winty.WindowsNotification? windowsNotification;
AppLifecycleState _appLifecycleState = AppLifecycleState.resumed;
void _onAppLifecycleChanged(AppLifecycleState state) {
_appLifecycleState = state;
}
Future<void> initializeLocalNotifications() async {
// Initialize Windows notification for Windows platform
windowsNotification = winty.WindowsNotification(applicationId: "Solian");
WidgetsBinding.instance.addObserver(
LifecycleEventHandler(onAppLifecycleChanged: _onAppLifecycleChanged),
);
}
class LifecycleEventHandler extends WidgetsBindingObserver {
final void Function(AppLifecycleState) onAppLifecycleChanged;
LifecycleEventHandler({required this.onAppLifecycleChanged});
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
onAppLifecycleChanged(state);
}
}
StreamSubscription<WebSocketPacket> setupNotificationListener(
BuildContext context,
WidgetRef ref,
) {
final settings = ref.watch(appSettingsProvider);
final ws = ref.watch(websocketProvider);
return ws.dataStream.listen((pkt) async {
if (pkt.type == "notifications.new") {
final notification = SnNotification.fromJson(pkt.data!);
if (_appLifecycleState == AppLifecycleState.resumed) {
talker.info(
'[Notification] Showing in-app notification: ${notification.title}',
);
if (settings.notifyWithHaptic) {
HapticFeedback.heavyImpact();
}
playNotificationSfx(ref);
ref.read(notificationStateProvider.notifier).add(notification);
} else {
// App is in background, show Windows system notification
talker.info(
'[Notification] Showing Windows system notification: ${notification.title}',
);
if (windowsNotification != null) {
final serverUrl = ref.read(serverUrlProvider);
final pfp = notification.meta['pfp'] as String?;
final img = notification.meta['images'] as List<dynamic>?;
final actionUrl = notification.meta['action_uri'] as String?;
// Download and cache images
String? imagePath;
String? largeImagePath;
if (pfp != null) {
try {
final file = await DefaultCacheManager().getSingleFile(
'$serverUrl/drive/files/$pfp',
);
imagePath = file.path;
} catch (e) {
talker.error('Failed to download pfp image: $e');
}
}
if (img != null && img.isNotEmpty) {
try {
final file = await DefaultCacheManager().getSingleFile(
'$serverUrl/drive/files/${img.firstOrNull}',
);
largeImagePath = file.path;
} catch (e) {
talker.error('Failed to download large image: $e');
}
}
// Use Windows notification for Windows platform
final notificationMessage = NotificationMessage.fromPluginTemplate(
notification.id, // unique id
notification.title,
[
notification.subtitle,
notification.content,
].where((e) => e.isNotEmpty).join('\n'),
group: notification.topic,
image: imagePath,
largeImage: largeImagePath,
launch: actionUrl != null ? 'solian://$actionUrl' : null,
);
await windowsNotification!.showNotificationPluginTemplate(
notificationMessage,
);
}
}
}
});
}
Future<void> subscribePushNotification(
Dio apiClient, {
bool detailedErrors = false,
}) async {
if (!kIsWeb && Platform.isLinux) {
return;
}
await FirebaseMessaging.instance.requestPermission(
alert: true,
badge: true,
sound: true,
);
String? deviceToken;
if (kIsWeb) {
deviceToken = await FirebaseMessaging.instance.getToken(
vapidKey:
"BFN2mkqyeI6oi4d2PAV4pfNyG3Jy0FBEblmmPrjmP0r5lHOPrxrcqLIWhM21R_cicF-j4Xhtr1kyDyDgJYRPLgU",
);
} else if (Platform.isAndroid) {
deviceToken = await FirebaseMessaging.instance.getToken();
} else if (Platform.isIOS) {
deviceToken = await FirebaseMessaging.instance.getAPNSToken();
}
FirebaseMessaging.instance.onTokenRefresh
.listen((fcmToken) {
_putTokenToRemote(apiClient, fcmToken, 1);
})
.onError((err) {
talker.error("Failed to get firebase cloud messaging push token: $err");
});
if (deviceToken != null) {
_putTokenToRemote(
apiClient,
deviceToken,
!kIsWeb && (Platform.isIOS || Platform.isMacOS) ? 0 : 1,
);
} else if (detailedErrors) {
throw Exception("Failed to get device token for push notifications.");
}
}
Future<void> _putTokenToRemote(
Dio apiClient,
String token,
int provider,
) async {
await apiClient.put(
"/ring/notifications/subscription",
data: {"provider": provider, "device_token": token},
);
}

View File

@@ -0,0 +1,89 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:island/route.dart';
import 'package:island/core/services/event_bus.dart';
import 'package:island/talker.dart';
import 'package:quick_actions/quick_actions.dart';
class QuickActionsService {
static final QuickActionsService _instance = QuickActionsService._internal();
factory QuickActionsService() => _instance;
QuickActionsService._internal();
final QuickActions _quickActions = const QuickActions();
bool _initialized = false;
Future<void> initialize() async {
if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) {
talker.warning(
'[QuickActions] Quick Actions only supported on Android and iOS',
);
return;
}
if (_initialized) {
talker.info('[QuickActions] Already initialized');
return;
}
try {
talker.info('[QuickActions] Initializing Quick Actions...');
// TODO Add icons for these
final shortcuts = <ShortcutItem>[
const ShortcutItem(type: 'compose_post', localizedTitle: 'New Post'),
const ShortcutItem(type: 'explore', localizedTitle: 'Explore'),
const ShortcutItem(type: 'chats', localizedTitle: 'Chats'),
const ShortcutItem(
type: 'notifications',
localizedTitle: 'Notifications',
),
];
await _quickActions.initialize(_handleShortcut);
await _quickActions.setShortcutItems(shortcuts);
_initialized = true;
talker.info('[QuickActions] Quick Actions initialized successfully');
} catch (e, stack) {
talker.error('[QuickActions] Initialization failed', e, stack);
rethrow;
}
}
void _handleShortcut(String type) {
talker.info('[QuickActions] Shortcut tapped: $type');
final context = rootNavigatorKey.currentContext;
if (context == null) {
talker.warning('[QuickActions] Context not available, skipping action');
return;
}
switch (type) {
case 'compose_post':
eventBus.fire(const ShowComposeSheetEvent());
break;
case 'explore':
context.go('/explore');
break;
case 'chats':
context.go('/chat');
break;
case 'notifications':
context.go('/notifications');
break;
default:
talker.warning('[QuickActions] Unknown shortcut type: $type');
}
}
void dispose() {
_initialized = false;
}
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter/widgets.dart';
const kWideScreenWidth = 768.0;
const kWiderScreenWidth = 1024.0;
const kWidescreenWidth = 1280.0;
bool isWideScreen(BuildContext context) {
return MediaQuery.of(context).size.width > kWideScreenWidth;
}
bool isWiderScreen(BuildContext context) {
return MediaQuery.of(context).size.width > kWiderScreenWidth;
}
bool isWidestScreen(BuildContext context) {
return MediaQuery.of(context).size.width > kWidescreenWidth;
}

View File

@@ -0,0 +1,123 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:island/core/widgets/share/share_sheet.dart';
import 'package:share_plus/share_plus.dart';
class SharingIntentService {
static final SharingIntentService _instance =
SharingIntentService._internal();
factory SharingIntentService() => _instance;
SharingIntentService._internal();
StreamSubscription<List<SharedMediaFile>>? _intentSub;
BuildContext? _context;
/// Initialize the sharing intent service
void initialize(BuildContext context) {
if (kIsWeb || !(Platform.isIOS || Platform.isAndroid)) return;
debugPrint("SharingIntentService: Initializing with context");
_context = context;
_setupSharingListeners();
}
/// Setup listeners for sharing intents
void _setupSharingListeners() {
debugPrint("SharingIntentService: Setting up sharing listeners");
// Listen to media sharing coming from outside the app while the app is in memory
_intentSub = ReceiveSharingIntent.instance.getMediaStream().listen(
(List<SharedMediaFile> value) {
debugPrint(
"SharingIntentService: Media stream received ${value.length} files",
);
if (value.isNotEmpty) {
_handleSharedContent(value);
}
},
onError: (err) {
debugPrint("SharingIntentService: Stream error: $err");
},
);
// Get the media sharing coming from outside the app while the app is closed
ReceiveSharingIntent.instance.getInitialMedia().then((
List<SharedMediaFile> value,
) {
debugPrint(
"SharingIntentService: Initial media received ${value.length} files",
);
if (value.isNotEmpty) {
_handleSharedContent(value);
// Tell the library that we are done processing the intent
ReceiveSharingIntent.instance.reset();
}
});
}
/// Handle shared media files
void _handleSharedContent(List<SharedMediaFile> sharedFiles) {
if (_context == null) {
debugPrint(
"SharingIntentService: Context is null, cannot handle shared content",
);
return;
}
debugPrint(
"SharingIntentService: Received ${sharedFiles.length} shared files",
);
for (final file in sharedFiles) {
debugPrint(
"SharingIntentService: File path: ${file.path}, type: ${file.type}",
);
}
// Convert SharedMediaFile to XFile for files
final List<XFile> files =
sharedFiles
.where(
(file) =>
file.type == SharedMediaType.file ||
file.type == SharedMediaType.video ||
file.type == SharedMediaType.image,
)
.map((file) => XFile(file.path, name: file.path.split('/').last))
.toList();
// Extract links from shared content
final List<String> links =
sharedFiles
.where((file) => file.type == SharedMediaType.url)
.map((file) => file.path)
.toList();
// Show ShareSheet with the shared files
if (files.isNotEmpty) {
showShareSheet(context: _context!, content: ShareContent.files(files));
} else if (links.isNotEmpty) {
showShareSheet(
context: _context!,
content: ShareContent.link(links.first),
);
} else {
showShareSheet(
context: _context!,
content: ShareContent.text(
sharedFiles
.where((file) => file.type == SharedMediaType.text)
.map((text) => text.message)
.join('\n'),
),
);
}
}
/// Dispose of resources
void dispose() {
_intentSub?.cancel();
_context = null;
}
}

103
lib/core/services/time.dart Normal file
View File

@@ -0,0 +1,103 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/widgets.dart';
import 'package:relative_time/relative_time.dart';
extension DurationFormatter on Duration {
String formatDuration() {
final isNegative = inMicroseconds < 0;
final positiveDuration = isNegative ? -this : this;
final hours = positiveDuration.inHours.toString().padLeft(2, '0');
final minutes = (positiveDuration.inMinutes % 60).toString().padLeft(
2,
'0',
);
final seconds = (positiveDuration.inSeconds % 60).toString().padLeft(
2,
'0',
);
return '${isNegative ? '-' : ''}$hours:$minutes:$seconds';
}
String formatShortDuration() {
final isNegative = inMicroseconds < 0;
final positiveDuration = isNegative ? -this : this;
final hours = positiveDuration.inHours;
final minutes = (positiveDuration.inMinutes % 60).toString().padLeft(
2,
'0',
);
final seconds = (positiveDuration.inSeconds % 60).toString().padLeft(
2,
'0',
);
final milliseconds = (positiveDuration.inMilliseconds % 1000)
.toString()
.padLeft(3, '0');
String result;
if (hours > 0) {
result =
'${isNegative ? '-' : ''}${hours.toString().padLeft(2, '0')}:$minutes:$seconds.$milliseconds';
} else {
result = '${isNegative ? '-' : ''}$minutes:$seconds.$milliseconds';
}
return result;
}
String formatOffset() {
final isNegative = inMicroseconds < 0;
final positiveDuration = isNegative ? -this : this;
final hours = positiveDuration.inHours.toString().padLeft(2, '0');
final minutes = (positiveDuration.inMinutes % 60).toString().padLeft(
2,
'0',
);
return '${isNegative ? '-' : '+'}$hours:$minutes';
}
String formatOffsetLocal() {
// Get the local timezone offset
final localOffset = DateTime.now().timeZoneOffset;
// Add the local offset to the input duration
final totalOffset = this - localOffset;
final isNegative = totalOffset.inMicroseconds < 0;
final positiveDuration = isNegative ? -totalOffset : totalOffset;
final hours = positiveDuration.inHours.toString().padLeft(2, '0');
final minutes = (positiveDuration.inMinutes % 60).toString().padLeft(
2,
'0',
);
return '${isNegative ? '-' : '+'}$hours:$minutes';
}
}
extension DateTimeFormatter on DateTime {
String formatSystem() {
return DateFormat.yMd().add_jm().format(toLocal());
}
String formatCustom(String pattern) {
return DateFormat(pattern).format(toLocal());
}
String formatCustomGlobal(String pattern) {
return DateFormat(pattern).format(this);
}
String formatWithLocale(String locale) {
return DateFormat.yMd().add_jm().format(toLocal()).toString();
}
String formatRelative(BuildContext context) {
return RelativeTime(context).format(toLocal());
}
}

View File

@@ -0,0 +1 @@
export 'timezone/native.dart' if (dart.library.html) 'timezone/web.dart';

View File

@@ -0,0 +1,22 @@
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:timezone/standalone.dart' as tz;
import 'package:timezone/data/latest_all.dart' as tzdb;
Future<void> initializeTzdb() async {
tzdb.initializeTimeZones();
}
(Duration offset, DateTime now) getTzInfo(String name) {
final location = tz.getLocation(name);
final now = tz.TZDateTime.now(location);
final offset = now.timeZoneOffset;
return (offset, now);
}
Future<String> getMachineTz() async {
return (await FlutterTimezone.getLocalTimezone()).identifier;
}
List<String> getAvailableTz() {
return tz.timeZoneDatabase.locations.keys.toList();
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:timezone/browser.dart' as tz;
Future<void> initializeTzdb() async {
await tz.initializeTimeZone();
}
(Duration offset, DateTime now) getTzInfo(String name) {
final location = tz.getLocation(name);
final now = tz.TZDateTime.now(location);
final offset = now.timeZoneOffset;
return (offset, now);
}
Future<String> getMachineTz() async {
return (await FlutterTimezone.getLocalTimezone()).identifier;
}
List<String> getAvailableTz() {
return tz.timeZoneDatabase.locations.keys.toList();
}

View File

@@ -0,0 +1,67 @@
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/core/config.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'tour.g.dart';
part 'tour.freezed.dart';
const kAppTourStatusKey = "app_tour_statuses";
const List<Tour> kAllTours = [
// Tour(id: 'technical_review_intro', isStartup: true),
];
@freezed
sealed class Tour with _$Tour {
const Tour._();
const factory Tour({required String id, required bool isStartup}) = _Tour;
Widget get widget => switch (id) {
// 'technical_review_intro' => const TechicalReviewIntroWidget(),
_ => throw UnimplementedError(),
};
}
@riverpod
class TourStatusNotifier extends _$TourStatusNotifier {
@override
Map<String, bool> build() {
final prefs = ref.watch(sharedPreferencesProvider);
final storedJson = prefs.getString(kAppTourStatusKey);
if (storedJson != null) {
try {
final Map<String, dynamic> stored = jsonDecode(storedJson);
return Map<String, bool>.from(stored);
} catch (_) {
return {for (final id in kAllTours.map((e) => e.id)) id: false};
}
}
return {for (final id in kAllTours.map((e) => e.id)) id: false};
}
bool isTourShown(String tourId) => state[tourId] ?? false;
Future<void> _saveState(Map<String, bool> newState) async {
state = newState;
final prefs = ref.read(sharedPreferencesProvider);
await prefs.setString(kAppTourStatusKey, jsonEncode(newState));
}
Future<Widget?> showTour(String tourId) async {
if (!isTourShown(tourId)) {
final newState = {...state, tourId: true};
await _saveState(newState);
return kAllTours.firstWhere((e) => e.id == tourId).widget;
}
return null;
}
Future<void> resetTours() async {
final newState = {for (final id in kAllTours.map((e) => e.id)) id: false};
await _saveState(newState);
}
}

View File

@@ -0,0 +1,268 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'tour.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$Tour {
String get id; bool get isStartup;
/// Create a copy of Tour
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$TourCopyWith<Tour> get copyWith => _$TourCopyWithImpl<Tour>(this as Tour, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is Tour&&(identical(other.id, id) || other.id == id)&&(identical(other.isStartup, isStartup) || other.isStartup == isStartup));
}
@override
int get hashCode => Object.hash(runtimeType,id,isStartup);
@override
String toString() {
return 'Tour(id: $id, isStartup: $isStartup)';
}
}
/// @nodoc
abstract mixin class $TourCopyWith<$Res> {
factory $TourCopyWith(Tour value, $Res Function(Tour) _then) = _$TourCopyWithImpl;
@useResult
$Res call({
String id, bool isStartup
});
}
/// @nodoc
class _$TourCopyWithImpl<$Res>
implements $TourCopyWith<$Res> {
_$TourCopyWithImpl(this._self, this._then);
final Tour _self;
final $Res Function(Tour) _then;
/// Create a copy of Tour
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? isStartup = null,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,isStartup: null == isStartup ? _self.isStartup : isStartup // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// Adds pattern-matching-related methods to [Tour].
extension TourPatterns on Tour {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _Tour value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _Tour() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _Tour value) $default,){
final _that = this;
switch (_that) {
case _Tour():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _Tour value)? $default,){
final _that = this;
switch (_that) {
case _Tour() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, bool isStartup)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _Tour() when $default != null:
return $default(_that.id,_that.isStartup);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, bool isStartup) $default,) {final _that = this;
switch (_that) {
case _Tour():
return $default(_that.id,_that.isStartup);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, bool isStartup)? $default,) {final _that = this;
switch (_that) {
case _Tour() when $default != null:
return $default(_that.id,_that.isStartup);case _:
return null;
}
}
}
/// @nodoc
class _Tour extends Tour {
const _Tour({required this.id, required this.isStartup}): super._();
@override final String id;
@override final bool isStartup;
/// Create a copy of Tour
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$TourCopyWith<_Tour> get copyWith => __$TourCopyWithImpl<_Tour>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Tour&&(identical(other.id, id) || other.id == id)&&(identical(other.isStartup, isStartup) || other.isStartup == isStartup));
}
@override
int get hashCode => Object.hash(runtimeType,id,isStartup);
@override
String toString() {
return 'Tour(id: $id, isStartup: $isStartup)';
}
}
/// @nodoc
abstract mixin class _$TourCopyWith<$Res> implements $TourCopyWith<$Res> {
factory _$TourCopyWith(_Tour value, $Res Function(_Tour) _then) = __$TourCopyWithImpl;
@override @useResult
$Res call({
String id, bool isStartup
});
}
/// @nodoc
class __$TourCopyWithImpl<$Res>
implements _$TourCopyWith<$Res> {
__$TourCopyWithImpl(this._self, this._then);
final _Tour _self;
final $Res Function(_Tour) _then;
/// Create a copy of Tour
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? isStartup = null,}) {
return _then(_Tour(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,isStartup: null == isStartup ? _self.isStartup : isStartup // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View File

@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tour.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(TourStatusNotifier)
final tourStatusProvider = TourStatusNotifierProvider._();
final class TourStatusNotifierProvider
extends $NotifierProvider<TourStatusNotifier, Map<String, bool>> {
TourStatusNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tourStatusProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tourStatusNotifierHash();
@$internal
@override
TourStatusNotifier create() => TourStatusNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Map<String, bool> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Map<String, bool>>(value),
);
}
}
String _$tourStatusNotifierHash() =>
r'ee712e1f8010311df8f24838814ab5c451f9e593';
abstract class _$TourStatusNotifier extends $Notifier<Map<String, bool>> {
Map<String, bool> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<Map<String, bool>, Map<String, bool>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<Map<String, bool>, Map<String, bool>>,
Map<String, bool>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

View File

@@ -0,0 +1,3 @@
export 'udid.native.dart'
if (dart.library.html) 'udid.web.dart'
if (dart.library.io) 'udid.native.dart';

View File

@@ -0,0 +1,29 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_udid/flutter_udid.dart';
String? _cachedUdid;
Future<String> getUdid() async {
if (_cachedUdid != null) {
return _cachedUdid!;
}
_cachedUdid = await FlutterUdid.consistentUdid;
return _cachedUdid!;
}
Future<String> getDeviceName() async {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
return androidInfo.device;
} else if (Platform.isIOS) {
IosDeviceInfo iosInfo = await deviceInfo.iosInfo;
return iosInfo.name;
} else if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
return Platform.localHostname;
} else {
return 'unknown'.tr();
}
}

View File

@@ -0,0 +1,26 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:web/web.dart';
Future<String> getUdid() async {
final userAgent = window.navigator.userAgent;
final bytes = utf8.encode(userAgent);
final hash = sha256.convert(bytes);
return hash.toString();
}
Future<String> getDeviceName() async {
final userAgent = window.navigator.userAgent;
if (userAgent.contains('Chrome') && !userAgent.contains('Edg')) {
return 'Chrome';
} else if (userAgent.contains('Firefox')) {
return 'Firefox';
} else if (userAgent.contains('Safari') && !userAgent.contains('Chrome')) {
return 'Safari';
} else if (userAgent.contains('Edg')) {
return 'Edge';
} else {
return 'Browser';
}
}

View File

@@ -0,0 +1,725 @@
import 'dart:async';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_app_update/azhon_app_update.dart';
import 'package:flutter_app_update/update_model.dart';
import 'package:island/core/widgets/content/markdown.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import 'package:process_run/process_run.dart';
import 'package:collection/collection.dart'; // Added for firstWhereOrNull
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:island/talker.dart';
/// Data model for a GitHub release we care about
class GithubReleaseInfo {
final String tagName;
final String name;
final String body;
final String htmlUrl;
final DateTime createdAt;
final List<GithubReleaseAsset> assets;
const GithubReleaseInfo({
required this.tagName,
required this.name,
required this.body,
required this.htmlUrl,
required this.createdAt,
this.assets = const [],
});
}
/// Data model for a GitHub release asset
class GithubReleaseAsset {
final String name;
final String browserDownloadUrl;
const GithubReleaseAsset({
required this.name,
required this.browserDownloadUrl,
});
factory GithubReleaseAsset.fromJson(Map<String, dynamic> json) {
return GithubReleaseAsset(
name: json['name'] as String,
browserDownloadUrl: json['browser_download_url'] as String,
);
}
}
/// Parses version and build number from "x.y.z+build"
class _ParsedVersion implements Comparable<_ParsedVersion> {
final int major;
final int minor;
final int patch;
final int build;
const _ParsedVersion(this.major, this.minor, this.patch, this.build);
static _ParsedVersion? tryParse(String input) {
// Expect format like 0.0.0+00 (build after '+'). Allow missing build as 0.
final partsPlus = input.split('+');
final core = partsPlus[0].trim();
final buildStr = partsPlus.length > 1 ? partsPlus[1].trim() : '0';
final coreParts = core.split('.');
if (coreParts.length != 3) return null;
final major = int.tryParse(coreParts[0]) ?? 0;
final minor = int.tryParse(coreParts[1]) ?? 0;
final patch = int.tryParse(coreParts[2]) ?? 0;
final build = int.tryParse(buildStr) ?? 0;
return _ParsedVersion(major, minor, patch, build);
}
/// Normalize Android build numbers by removing architecture-based offsets
/// Android adds 1000 for x86, 2000 for ARMv7, 4000 for ARMv8
int get normalizedBuild {
// Check if build number has an architecture offset
// We detect this by checking if the build % 1000 is the base build
if (build >= 4000) {
// Likely ARMv8 (arm64-v8a) with +4000 offset
return build % 4000;
} else if (build >= 2000) {
// Likely ARMv7 (armeabi-v7a) with +2000 offset
return build % 2000;
} else if (build >= 1000) {
// Likely x86/x86_64 with +1000 offset
return build % 1000;
}
// No offset, return as-is
return build;
}
@override
int compareTo(_ParsedVersion other) {
if (major != other.major) return major.compareTo(other.major);
if (minor != other.minor) return minor.compareTo(other.minor);
if (patch != other.patch) return patch.compareTo(other.patch);
// Use normalized build numbers for comparison to handle Android arch offsets
return normalizedBuild.compareTo(other.normalizedBuild);
}
@override
String toString() => '$major.$minor.$patch+$build';
}
class UpdateService {
UpdateService({Dio? dio, this.useProxy = false})
: _dio =
dio ??
Dio(
BaseOptions(
headers: {
// Identify the app to GitHub; avoids some rate-limits and adds clarity
'Accept': 'application/vnd.github+json',
'User-Agent': 'solian-update-checker',
},
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
),
);
final Dio _dio;
final bool useProxy;
static const _proxyBaseUrl = 'https://ghfast.top/';
static const _releasesLatestApi =
'https://api.github.com/repos/solsynth/solian/releases/latest';
/// Checks GitHub for the latest release and compares against the current app version.
/// If update is available, shows a bottom sheet with changelog and an action to open release page.
Future<void> checkForUpdates(BuildContext context) async {
talker.info('[Update] Checking for updates...');
try {
final release = await fetchLatestRelease();
if (release == null) {
talker.info('[Update] No latest release found or could not fetch.');
return;
}
talker.info('[Update] Fetched latest release: ${release.tagName}');
final info = await PackageInfo.fromPlatform();
final localVersionStr = '${info.version}+${info.buildNumber}';
talker.info('[Update] Local app version: $localVersionStr');
final latest = _ParsedVersion.tryParse(release.tagName);
final local = _ParsedVersion.tryParse(localVersionStr);
if (latest == null || local == null) {
talker.info(
'[Update] Failed to parse versions. Latest: ${release.tagName}, Local: $localVersionStr',
);
// If parsing fails, do nothing silently
return;
}
talker.info('[Update] Parsed versions. Latest: $latest, Local: $local');
final needsUpdate = latest.compareTo(local) > 0;
if (!needsUpdate) {
talker.info('[Update] App is up to date. No update needed.');
return;
}
talker.info('[Update] Update available! Latest: $latest, Local: $local');
if (!context.mounted) {
talker.info('[Update] Context not mounted, cannot show update sheet.');
return;
}
// Delay to ensure UI is ready (if called at startup)
await Future.delayed(const Duration(milliseconds: 100));
if (context.mounted) {
await showUpdateSheet(context, release);
talker.info('[Update] Update sheet shown.');
}
} catch (e) {
talker.error('[Update] Error checking for updates: $e');
// Ignore errors (network, api, etc.)
return;
}
}
/// Manually show the update sheet with a provided release.
/// Useful for About page or testing.
Future<void> showUpdateSheet(
BuildContext context,
GithubReleaseInfo release,
) async {
if (!context.mounted) return;
await showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (ctx) {
String? androidUpdateUrl;
String? windowsUpdateUrl;
if (Platform.isAndroid) {
androidUpdateUrl = _getAndroidUpdateUrl(release.assets);
}
if (Platform.isWindows) {
windowsUpdateUrl = _getWindowsUpdateUrl();
}
return _UpdateSheet(
release: release,
onOpen: () async {
final uri = Uri.parse(release.htmlUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
},
androidUpdateUrl: androidUpdateUrl,
windowsUpdateUrl: windowsUpdateUrl,
useProxy: useProxy, // Pass the useProxy flag
);
},
);
}
String? _getAndroidUpdateUrl(List<GithubReleaseAsset> assets) {
final arm64 = assets.firstWhereOrNull(
(asset) => asset.name == 'app-arm64-v8a-release.apk',
);
final armeabi = assets.firstWhereOrNull(
(asset) => asset.name == 'app-armeabi-v7a-release.apk',
);
final x86_64 = assets.firstWhereOrNull(
(asset) => asset.name == 'app-x86_64-release.apk',
);
// Prioritize arm64, then armeabi, then x86_64
if (arm64 != null) {
return 'https://fs.solsynth.dev/d/official/solian/${arm64.name}';
} else if (armeabi != null) {
return 'https://fs.solsynth.dev/d/official/solian/${armeabi.name}';
} else if (x86_64 != null) {
return 'https://fs.solsynth.dev/d/official/solian/${x86_64.name}';
}
return null;
}
String _getWindowsUpdateUrl() {
return 'https://fs.solsynth.dev/d/official/solian/build-output-windows-installer.zip';
}
/// Performs automatic Windows update: download, extract, and install
Future<void> performAutomaticWindowsUpdate(
BuildContext context,
String url,
) async {
if (!context.mounted) return;
showDialog(
context: context,
barrierDismissible: false,
builder:
(context) => _WindowsUpdateDialog(
updateUrl: url,
onComplete: () {
// Close the update sheet
Navigator.of(context).pop();
},
),
);
}
/// Fetch the latest release info from GitHub.
/// Public so other screens (e.g., About) can manually trigger update checks.
Future<GithubReleaseInfo?> fetchLatestRelease() async {
final apiEndpoint =
useProxy
? '$_proxyBaseUrl${Uri.encodeComponent(_releasesLatestApi)}'
: _releasesLatestApi;
talker.info(
'[Update] Fetching latest release from GitHub API: $apiEndpoint (Proxy: $useProxy)',
);
final resp = await _dio.get(apiEndpoint);
if (resp.statusCode != 200) {
talker.error(
'[Update] Failed to fetch latest release. Status code: ${resp.statusCode}',
);
return null;
}
final data = resp.data as Map<String, dynamic>;
talker.info('[Update] Successfully fetched release data.');
final tagName = (data['tag_name'] ?? '').toString();
final name = (data['name'] ?? tagName).toString();
final body = (data['body'] ?? '').toString();
final htmlUrl = (data['html_url'] ?? '').toString();
final createdAtStr = (data['created_at'] ?? '').toString();
final createdAt = DateTime.tryParse(createdAtStr) ?? DateTime.now();
final assetsData =
(data['assets'] as List<dynamic>?)
?.map((e) => GithubReleaseAsset.fromJson(e as Map<String, dynamic>))
.toList() ??
[];
if (tagName.isEmpty || htmlUrl.isEmpty) {
talker.error(
'[Update] Missing tag_name or html_url in release data. TagName: "$tagName", HtmlUrl: "$htmlUrl"',
);
return null;
}
talker.info('[Update] Returning GithubReleaseInfo for tag: $tagName');
return GithubReleaseInfo(
tagName: tagName,
name: name,
body: body,
htmlUrl: htmlUrl,
createdAt: createdAt,
assets: assetsData,
);
}
}
class _WindowsUpdateDialog extends StatefulWidget {
const _WindowsUpdateDialog({
required this.updateUrl,
required this.onComplete,
});
final String updateUrl;
final VoidCallback onComplete;
@override
State<_WindowsUpdateDialog> createState() => _WindowsUpdateDialogState();
}
class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
final ValueNotifier<double?> progressNotifier = ValueNotifier<double?>(null);
final ValueNotifier<String> messageNotifier = ValueNotifier<String>(
'Downloading installer...',
);
@override
void initState() {
super.initState();
_startUpdate();
}
Future<void> _startUpdate() async {
try {
// Step 1: Download
final zipPath = await _downloadWindowsInstaller(
widget.updateUrl,
onProgress: (received, total) {
if (total == -1) {
progressNotifier.value = null;
} else {
progressNotifier.value = received / total;
}
},
);
if (zipPath == null) {
_showError('Failed to download installer');
return;
}
// Step 2: Extract
messageNotifier.value = 'Extracting installer...';
progressNotifier.value = null; // Indeterminate for extraction
final extractDir = await _extractWindowsInstaller(zipPath);
if (extractDir == null) {
_showError('Failed to extract installer');
return;
}
// Step 3: Run installer
messageNotifier.value = 'Running installer...';
final success = await _runWindowsInstaller(extractDir);
if (!mounted) return;
if (success) {
messageNotifier.value = 'Update Complete';
progressNotifier.value = 1.0;
await Future.delayed(const Duration(seconds: 2));
if (mounted) {
Navigator.of(context).pop();
widget.onComplete();
}
} else {
_showError('Failed to run installer');
}
// Cleanup
try {
await File(zipPath).delete();
await Directory(extractDir).delete(recursive: true);
} catch (e) {
talker.error('[Update] Error cleaning up temporary files: $e');
}
} catch (e) {
_showError('Update failed: $e');
}
}
void _showError(String message) {
if (!mounted) return;
Navigator.of(context).pop();
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Update Failed'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Installing Update'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ValueListenableBuilder<double?>(
valueListenable: progressNotifier,
builder: (context, progress, child) {
return LinearProgressIndicator(value: progress);
},
),
const SizedBox(height: 16),
ValueListenableBuilder<String>(
valueListenable: messageNotifier,
builder: (context, message, child) {
return Text(message);
},
),
],
),
);
}
/// Downloads the Windows installer ZIP file
Future<String?> _downloadWindowsInstaller(
String url, {
void Function(int received, int total)? onProgress,
}) async {
try {
talker.info('[Update] Starting Windows installer download from: $url');
final tempDir = await getTemporaryDirectory();
final fileName =
'solian-installer-${DateTime.now().millisecondsSinceEpoch}.zip';
final filePath = path.join(tempDir.path, fileName);
final response = await Dio().download(
url,
filePath,
onReceiveProgress: (received, total) {
if (total != -1) {
talker.info(
'[Update] Download progress: ${(received / total * 100).toStringAsFixed(1)}%',
);
}
onProgress?.call(received, total);
},
);
if (response.statusCode == 200) {
talker.info(
'[Update] Windows installer downloaded successfully to: $filePath',
);
return filePath;
} else {
talker.error(
'[Update] Failed to download Windows installer. Status: ${response.statusCode}',
);
return null;
}
} catch (e) {
talker.error('[Update] Error downloading Windows installer: $e');
return null;
}
}
/// Extracts the ZIP file to a temporary directory
Future<String?> _extractWindowsInstaller(String zipPath) async {
try {
talker.info('[Update] Extracting Windows installer from: $zipPath');
final tempDir = await getTemporaryDirectory();
final extractDir = path.join(
tempDir.path,
'solian-installer-${DateTime.now().millisecondsSinceEpoch}',
);
final zipFile = File(zipPath);
final bytes = await zipFile.readAsBytes();
final archive = ZipDecoder().decodeBytes(bytes);
for (final file in archive) {
final filename = file.name;
if (file.isFile) {
final data = file.content as List<int>;
final filePath = path.join(extractDir, filename);
await Directory(path.dirname(filePath)).create(recursive: true);
await File(filePath).writeAsBytes(data);
} else {
final dirPath = path.join(extractDir, filename);
await Directory(dirPath).create(recursive: true);
}
}
talker.info(
'[Update] Windows installer extracted successfully to: $extractDir',
);
return extractDir;
} catch (e) {
talker.error('[Update] Error extracting Windows installer: $e');
return null;
}
}
/// Runs the setup.exe file
Future<bool> _runWindowsInstaller(String extractDir) async {
try {
talker.info('[Update] Running Windows installer from: $extractDir');
final dir = Directory(extractDir);
final exeFiles =
dir
.listSync()
.where((f) => f is File && f.path.endsWith('.exe'))
.toList();
if (exeFiles.isEmpty) {
talker.info('[Update] No .exe file found in extracted directory');
return false;
}
final setupExePath = exeFiles.first.path;
talker.info('[Update] Found installer executable: $setupExePath');
final shell = Shell();
final results = await shell.run(setupExePath);
final result = results.first;
if (result.exitCode == 0) {
talker.info('[Update] Windows installer completed successfully');
return true;
} else {
talker.error(
'[Update] Windows installer failed with exit code: ${result.exitCode}',
);
talker.error('[Update] Installer output: ${result.stdout}');
talker.error('[Update] Installer errors: ${result.stderr}');
return false;
}
} catch (e) {
talker.error('[Update] Error running Windows installer: $e');
return false;
}
}
}
class _UpdateSheet extends StatefulWidget {
const _UpdateSheet({
required this.release,
required this.onOpen,
this.androidUpdateUrl,
this.windowsUpdateUrl,
this.useProxy = false,
});
final String? androidUpdateUrl;
final String? windowsUpdateUrl;
final bool useProxy;
final GithubReleaseInfo release;
final VoidCallback onOpen;
@override
State<_UpdateSheet> createState() => _UpdateSheetState();
}
class _UpdateSheetState extends State<_UpdateSheet> {
late bool _useProxy;
@override
void initState() {
super.initState();
_useProxy = widget.useProxy;
}
Future<void> _installUpdate(String url) async {
String downloadUrl = url;
if (_useProxy) {
final fileName = url.split('/').last;
downloadUrl = 'https://fs.solsynth.dev/d/rainyun02/solian/$fileName';
}
UpdateModel model = UpdateModel(
downloadUrl,
"solian-update-${widget.release.tagName}.apk",
"launcher_icon",
'https://apps.apple.com/us/app/solian/id6499032345',
);
AzhonAppUpdate.update(model);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SheetScaffold(
titleText: 'updateAvailable'.tr(),
child: Padding(
padding: EdgeInsets.only(
bottom: 16 + MediaQuery.of(context).padding.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.release.name,
style: theme.textTheme.titleMedium,
).bold(),
Text(widget.release.tagName).fontSize(12),
],
).padding(vertical: 16, horizontal: 16),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
child: MarkdownTextContent(
content:
widget.release.body.isEmpty
? 'noChangelogProvided'.tr()
: widget.release.body,
),
),
),
if (!kIsWeb && Platform.isAndroid)
SwitchListTile(
title: Text('useSecondarySourceForDownload'.tr()),
value: _useProxy,
onChanged: (value) {
setState(() {
_useProxy = value;
});
},
).padding(horizontal: 8),
Column(
children: [
Row(
spacing: 8,
children: [
if (!kIsWeb &&
Platform.isAndroid &&
widget.androidUpdateUrl != null)
Expanded(
child: FilledButton.icon(
onPressed: () {
talker.info(widget.androidUpdateUrl!);
_installUpdate(widget.androidUpdateUrl!);
},
icon: const Icon(Symbols.update),
label: Text('installUpdate'.tr()),
),
),
if (!kIsWeb &&
Platform.isWindows &&
widget.windowsUpdateUrl != null)
Expanded(
child: FilledButton.icon(
onPressed: () {
// Access the UpdateService instance to call the automatic update method
final updateService = UpdateService(
useProxy: widget.useProxy,
);
updateService.performAutomaticWindowsUpdate(
context,
widget.windowsUpdateUrl!,
);
},
icon: const Icon(Symbols.update),
label: Text('installUpdate'.tr()),
),
),
Expanded(
child: FilledButton.icon(
onPressed: widget.onOpen,
icon: const Icon(Icons.open_in_new),
label: Text('openReleasePage'.tr()),
),
),
],
),
],
).padding(horizontal: 16),
],
),
),
);
}
}

View File

@@ -0,0 +1,24 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class WidgetSyncService {
static const _channel = MethodChannel('dev.solsynth.solian/widget');
static final _instance = WidgetSyncService._internal();
factory WidgetSyncService() => _instance;
WidgetSyncService._internal();
bool get _isSupported => !kIsWeb && (Platform.isAndroid || Platform.isIOS);
Future<void> syncToWidget() async {
if (!_isSupported) return;
try {
await _channel.invokeMethod('syncToWidget');
} catch (e) {
debugPrint('Failed to sync to widget: $e');
}
}
}

124
lib/core/theme.dart Normal file
View File

@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:island/core/config.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'theme.g.dart';
@riverpod
ThemeSet theme(Ref ref) {
final settings = ref.watch(appSettingsProvider);
return createAppThemeSet(settings);
}
class ThemeSet {
ThemeData light;
ThemeData dark;
ThemeSet({required this.light, required this.dark});
}
ThemeSet createAppThemeSet(AppSettings settings) {
return ThemeSet(
light: createAppTheme(Brightness.light, settings),
dark: createAppTheme(Brightness.dark, settings),
);
}
ThemeData createAppTheme(Brightness brightness, AppSettings settings) {
final seedColor = settings.appColorScheme != null
? Color(settings.appColorScheme!)
: Colors.indigo;
var colorScheme = ColorScheme.fromSeed(
seedColor: seedColor,
brightness: brightness,
);
final customColors = settings.customColors;
if (customColors != null) {
colorScheme = colorScheme.copyWith(
primary: customColors.primary != null
? Color(customColors.primary!)
: null,
secondary: customColors.secondary != null
? Color(customColors.secondary!)
: null,
tertiary: customColors.tertiary != null
? Color(customColors.tertiary!)
: null,
surface: customColors.surface != null
? Color(customColors.surface!)
: null,
background: customColors.background != null
? Color(customColors.background!)
: null,
error: customColors.error != null ? Color(customColors.error!) : null,
);
}
final hasAppBarTransparent = settings.appBarTransparent;
final inUseFonts =
settings.customFonts?.split(',').map((ele) => ele.trim()).toList() ??
['Nunito'];
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
brightness: brightness,
fontFamily: inUseFonts.firstOrNull,
fontFamilyFallback: inUseFonts.sublist(1),
iconTheme: IconThemeData(
fill: 0,
weight: 400,
opticalSize: 20,
color: colorScheme.onSurface,
),
appBarTheme: AppBarTheme(
centerTitle: true,
elevation: hasAppBarTransparent ? 0 : null,
backgroundColor: hasAppBarTransparent
? Colors.transparent
: colorScheme.primary,
foregroundColor: hasAppBarTransparent
? colorScheme.onSurface
: colorScheme.onPrimary,
),
cardTheme: CardThemeData(
color: colorScheme.surfaceContainer.withOpacity(
settings.cardTransparency,
),
elevation: settings.cardTransparency < 1 ? 0 : null,
),
pageTransitionsTheme: PageTransitionsTheme(
builders: {
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.macOS: ZoomPageTransitionsBuilder(),
TargetPlatform.fuchsia: ZoomPageTransitionsBuilder(),
TargetPlatform.linux: ZoomPageTransitionsBuilder(),
TargetPlatform.windows: ZoomPageTransitionsBuilder(),
},
),
progressIndicatorTheme: ProgressIndicatorThemeData(year2023: false),
sliderTheme: SliderThemeData(year2023: false),
);
}
extension HexColor on Color {
/// String is in the format "aabbcc" or "ffaabbcc" with an optional leading "#".
static Color fromHex(String hexString) {
final buffer = StringBuffer();
if (hexString.length == 6 || hexString.length == 7) buffer.write('ff');
buffer.write(hexString.replaceFirst('#', ''));
return Color(int.parse(buffer.toString(), radix: 16));
}
/// Prefixes a hash sign if [leadingHashSign] is set to `true` (default is `true`).
String toHex({bool leadingHashSign = true}) =>
'${leadingHashSign ? '#' : ''}'
'${alpha.toRadixString(16).padLeft(2, '0')}'
'${red.toRadixString(16).padLeft(2, '0')}'
'${green.toRadixString(16).padLeft(2, '0')}'
'${blue.toRadixString(16).padLeft(2, '0')}';
}

51
lib/core/theme.g.dart Normal file
View File

@@ -0,0 +1,51 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'theme.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(theme)
final themeProvider = ThemeProvider._();
final class ThemeProvider
extends $FunctionalProvider<ThemeSet, ThemeSet, ThemeSet>
with $Provider<ThemeSet> {
ThemeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'themeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$themeHash();
@$internal
@override
$ProviderElement<ThemeSet> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
ThemeSet create(Ref ref) {
return theme(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ThemeSet value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ThemeSet>(value),
);
}
}
String _$themeHash() => r'5b41b68e2fc59431bb195ff75f63383982f7730f';

View File

@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:styled_widget/styled_widget.dart';
class TechicalReviewIntroWidget extends StatelessWidget {
const TechicalReviewIntroWidget({super.key});
@override
Widget build(BuildContext context) {
return SheetScaffold(
titleText: '技术性预览',
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('👋').fontSize(32),
Text('你好呀~').fontSize(24),
Text('欢迎来使用 Solar Network 3.0 的技术性预览版。'),
const Gap(24),
Text('技术性预览的初衷是让我们更顺滑的将 3.0 发布出来,帮助我们一点一点的迁移数据。'),
const Gap(24),
Text('同时,既然是测试版,肯定有一系列的 Bug 和问题,请多多包涵,也欢迎积极反馈到 GitHub 上。'),
Text('目前帐号数据已经迁移完毕,其他数据将在未来逐渐迁移。还请耐心等待,不要重复创建以免未来数据冲突。'),
const Gap(24),
Text('最后,感谢你愿意参与技术性预览,祝你使用愉快!'),
const Gap(16),
Text('关掉这个对话框就开始探索吧!').fontSize(11),
],
).padding(horizontal: 20, vertical: 24),
),
);
}
}

36
lib/core/tour/tour.dart Normal file
View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/core/services/tour.dart';
const List<String> kStartTours = ['technical_review_intro'];
class TourTriggerWidget extends HookConsumerWidget {
final Widget child;
const TourTriggerWidget({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tourStatus = ref.watch(tourStatusProvider.notifier);
useEffect(() {
Future(() async {
for (final tour in kStartTours) {
final widget = await tourStatus.showTour(tour);
if (widget != null) {
if (!context.mounted) return;
await showModalBottomSheet(
isScrollControlled: true,
useRootNavigator: true,
context: context,
builder: (context) => widget,
);
}
}
});
return null;
}, [tourStatus]);
return child;
}
}

42
lib/core/translate.dart Normal file
View File

@@ -0,0 +1,42 @@
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/core/network.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'translate.freezed.dart';
part 'translate.g.dart';
@freezed
sealed class TranslateQuery with _$TranslateQuery {
const factory TranslateQuery({required String text, required String lang}) =
_TranslateQuery;
}
@riverpod
Future<String> translateString(Ref ref, TranslateQuery query) async {
final client = ref.watch(apiClientProvider);
final response = await client.post(
'/sphere/translate',
queryParameters: {'to': query.lang},
data: jsonEncode(query.text),
);
return response.data as String;
}
@riverpod
String? detectStringLanguage(Ref ref, String text) {
bool isChinese(String text) {
final chineseRegex = RegExp(r'[\u4e00-\u9fff]');
return chineseRegex.hasMatch(text);
}
bool isEnglish(String text) {
final englishRegex = RegExp(r'[a-zA-Z]');
return englishRegex.hasMatch(text) && !isChinese(text);
}
if (isChinese(text)) return "zh";
if (isEnglish(text)) return "en";
return null;
}

View File

@@ -0,0 +1,268 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'translate.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$TranslateQuery {
String get text; String get lang;
/// Create a copy of TranslateQuery
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$TranslateQueryCopyWith<TranslateQuery> get copyWith => _$TranslateQueryCopyWithImpl<TranslateQuery>(this as TranslateQuery, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is TranslateQuery&&(identical(other.text, text) || other.text == text)&&(identical(other.lang, lang) || other.lang == lang));
}
@override
int get hashCode => Object.hash(runtimeType,text,lang);
@override
String toString() {
return 'TranslateQuery(text: $text, lang: $lang)';
}
}
/// @nodoc
abstract mixin class $TranslateQueryCopyWith<$Res> {
factory $TranslateQueryCopyWith(TranslateQuery value, $Res Function(TranslateQuery) _then) = _$TranslateQueryCopyWithImpl;
@useResult
$Res call({
String text, String lang
});
}
/// @nodoc
class _$TranslateQueryCopyWithImpl<$Res>
implements $TranslateQueryCopyWith<$Res> {
_$TranslateQueryCopyWithImpl(this._self, this._then);
final TranslateQuery _self;
final $Res Function(TranslateQuery) _then;
/// Create a copy of TranslateQuery
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? text = null,Object? lang = null,}) {
return _then(_self.copyWith(
text: null == text ? _self.text : text // ignore: cast_nullable_to_non_nullable
as String,lang: null == lang ? _self.lang : lang // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [TranslateQuery].
extension TranslateQueryPatterns on TranslateQuery {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _TranslateQuery value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _TranslateQuery() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _TranslateQuery value) $default,){
final _that = this;
switch (_that) {
case _TranslateQuery():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _TranslateQuery value)? $default,){
final _that = this;
switch (_that) {
case _TranslateQuery() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String text, String lang)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _TranslateQuery() when $default != null:
return $default(_that.text,_that.lang);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String text, String lang) $default,) {final _that = this;
switch (_that) {
case _TranslateQuery():
return $default(_that.text,_that.lang);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String text, String lang)? $default,) {final _that = this;
switch (_that) {
case _TranslateQuery() when $default != null:
return $default(_that.text,_that.lang);case _:
return null;
}
}
}
/// @nodoc
class _TranslateQuery implements TranslateQuery {
const _TranslateQuery({required this.text, required this.lang});
@override final String text;
@override final String lang;
/// Create a copy of TranslateQuery
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$TranslateQueryCopyWith<_TranslateQuery> get copyWith => __$TranslateQueryCopyWithImpl<_TranslateQuery>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _TranslateQuery&&(identical(other.text, text) || other.text == text)&&(identical(other.lang, lang) || other.lang == lang));
}
@override
int get hashCode => Object.hash(runtimeType,text,lang);
@override
String toString() {
return 'TranslateQuery(text: $text, lang: $lang)';
}
}
/// @nodoc
abstract mixin class _$TranslateQueryCopyWith<$Res> implements $TranslateQueryCopyWith<$Res> {
factory _$TranslateQueryCopyWith(_TranslateQuery value, $Res Function(_TranslateQuery) _then) = __$TranslateQueryCopyWithImpl;
@override @useResult
$Res call({
String text, String lang
});
}
/// @nodoc
class __$TranslateQueryCopyWithImpl<$Res>
implements _$TranslateQueryCopyWith<$Res> {
__$TranslateQueryCopyWithImpl(this._self, this._then);
final _TranslateQuery _self;
final $Res Function(_TranslateQuery) _then;
/// Create a copy of TranslateQuery
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? text = null,Object? lang = null,}) {
return _then(_TranslateQuery(
text: null == text ? _self.text : text // ignore: cast_nullable_to_non_nullable
as String,lang: null == lang ? _self.lang : lang // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on

157
lib/core/translate.g.dart Normal file
View File

@@ -0,0 +1,157 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'translate.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(translateString)
final translateStringProvider = TranslateStringFamily._();
final class TranslateStringProvider
extends $FunctionalProvider<AsyncValue<String>, String, FutureOr<String>>
with $FutureModifier<String>, $FutureProvider<String> {
TranslateStringProvider._({
required TranslateStringFamily super.from,
required TranslateQuery super.argument,
}) : super(
retry: null,
name: r'translateStringProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$translateStringHash();
@override
String toString() {
return r'translateStringProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<String> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<String> create(Ref ref) {
final argument = this.argument as TranslateQuery;
return translateString(ref, argument);
}
@override
bool operator ==(Object other) {
return other is TranslateStringProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$translateStringHash() => r'51d638cf07cbf3ffa9469298f5bd9c667bc0ccb7';
final class TranslateStringFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<String>, TranslateQuery> {
TranslateStringFamily._()
: super(
retry: null,
name: r'translateStringProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
TranslateStringProvider call(TranslateQuery query) =>
TranslateStringProvider._(argument: query, from: this);
@override
String toString() => r'translateStringProvider';
}
@ProviderFor(detectStringLanguage)
final detectStringLanguageProvider = DetectStringLanguageFamily._();
final class DetectStringLanguageProvider
extends $FunctionalProvider<String?, String?, String?>
with $Provider<String?> {
DetectStringLanguageProvider._({
required DetectStringLanguageFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'detectStringLanguageProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$detectStringLanguageHash();
@override
String toString() {
return r'detectStringLanguageProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<String?> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
String? create(Ref ref) {
final argument = this.argument as String;
return detectStringLanguage(ref, argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String?>(value),
);
}
@override
bool operator ==(Object other) {
return other is DetectStringLanguageProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$detectStringLanguageHash() =>
r'24fbf52edbbffcc8dc4f09f7206f82d69728e703';
final class DetectStringLanguageFamily extends $Family
with $FunctionalFamilyOverride<String?, String> {
DetectStringLanguageFamily._()
: super(
retry: null,
name: r'detectStringLanguageProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
DetectStringLanguageProvider call(String text) =>
DetectStringLanguageProvider._(argument: text, from: this);
@override
String toString() => r'detectStringLanguageProvider';
}

View File

@@ -0,0 +1,22 @@
String getAbuseReportTypeString(int type) {
switch (type) {
case 0:
return 'Copyright';
case 1:
return 'Harassment';
case 2:
return 'Impersonation';
case 3:
return 'Offensive Content';
case 4:
return 'Spam';
case 5:
return 'Privacy Violation';
case 6:
return 'Illegal Content';
case 7:
return 'Other';
default:
return 'Unknown';
}
}

View File

@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:island/accounts/accounts_models/account.dart';
String? getActivityTitle(String? label, Map<String, dynamic>? meta) {
if (meta == null) return label;
if (meta['assets']?['large_text'] is String) {
return meta['assets']?['large_text'];
}
return label;
}
String? getActivitySubtitle(Map<String, dynamic>? meta) {
if (meta == null) return null;
if (meta['assets']?['small_text'] is String) {
return meta['assets']?['small_text'];
}
return null;
}
InlineSpan getActivityFullMessage(SnAccountStatus? status) {
if (status?.meta == null) return TextSpan(text: 'No activity details available');
final meta = status!.meta!;
final List<InlineSpan> spans = [];
if (meta.containsKey('assets') && meta['assets'] is Map) {
final assets = meta['assets'] as Map<String, dynamic>;
if (assets.containsKey('large_text')) {
spans.add(TextSpan(text: assets['large_text'], style: TextStyle(fontWeight: FontWeight.bold)));
}
if (assets.containsKey('small_text')) {
if (spans.isNotEmpty) spans.add(TextSpan(text: '\n'));
spans.add(TextSpan(text: assets['small_text']));
}
}
String normalText = '';
if (meta.containsKey('details')) {
normalText += 'Details: ${meta['details']}\n';
}
if (meta.containsKey('state')) {
normalText += 'State: ${meta['state']}\n';
}
if (meta.containsKey('timestamps') && meta['timestamps'] is Map) {
final ts = meta['timestamps'] as Map<String, dynamic>;
if (ts.containsKey('start') && ts['start'] is int) {
final start = DateTime.fromMillisecondsSinceEpoch(ts['start'] * 1000);
normalText += 'Started: ${start.toLocal()}\n';
}
if (ts.containsKey('end') && ts['end'] is int) {
final end = DateTime.fromMillisecondsSinceEpoch(ts['end'] * 1000);
normalText += 'Ends: ${end.toLocal()}\n';
}
}
if (meta.containsKey('party') && meta['party'] is Map) {
final party = meta['party'] as Map<String, dynamic>;
if (party.containsKey('size') && party['size'] is List && party['size'].length >= 2) {
final size = party['size'] as List;
normalText += 'Party: ${size[0]}/${size[1]}\n';
}
}
if (meta.containsKey('instance')) {
normalText += 'Instance: ${meta['instance']}\n';
}
// Add other keys if present
meta.forEach((key, value) {
if (!['details', 'state', 'timestamps', 'assets', 'party', 'secrets', 'instance'].contains(key)) {
normalText += '$key: $value\n';
}
});
if (normalText.isNotEmpty) {
if (spans.isNotEmpty) spans.add(TextSpan(text: '\n'));
spans.add(TextSpan(text: normalText.trimRight()));
}
return TextSpan(children: spans);
}
Widget buildActivityDetails(SnAccountStatus? status) {
if (status?.meta == null) return Text('No activity details available');
final meta = status!.meta!;
final List<Widget> children = [];
if (meta.containsKey('assets') && meta['assets'] is Map) {
final assets = meta['assets'] as Map<String, dynamic>;
if (assets.containsKey('large_text')) {
children.add(Text(assets['large_text']));
}
if (assets.containsKey('small_text')) {
children.add(Text(assets['small_text']));
}
}
if (meta.containsKey('details')) {
children.add(Text('Details: ${meta['details']}'));
}
if (meta.containsKey('state')) {
children.add(Text('State: ${meta['state']}'));
}
if (meta.containsKey('timestamps') && meta['timestamps'] is Map) {
final ts = meta['timestamps'] as Map<String, dynamic>;
if (ts.containsKey('start') && ts['start'] is int) {
final start = DateTime.fromMillisecondsSinceEpoch(ts['start'] * 1000);
children.add(Text('Started: ${start.toLocal()}'));
}
if (ts.containsKey('end') && ts['end'] is int) {
final end = DateTime.fromMillisecondsSinceEpoch(ts['end'] * 1000);
children.add(Text('Ends: ${end.toLocal()}'));
}
}
if (meta.containsKey('party') && meta['party'] is Map) {
final party = meta['party'] as Map<String, dynamic>;
if (party.containsKey('size') && party['size'] is List && party['size'].length >= 2) {
final size = party['size'] as List;
children.add(Text('Party: ${size[0]}/${size[1]}'));
}
}
if (meta.containsKey('instance')) {
children.add(Text('Instance: ${meta['instance']}'));
}
// Add other keys if present
children.addAll(meta.entries.where((e) => !['details', 'state', 'timestamps', 'assets', 'party', 'secrets', 'instance'].contains(e.key)).map((e) => Text('${e.key}: ${e.value}')));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: children,
);
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
/// Returns an appropriate icon widget for the given file based on its MIME type
Widget getFileIcon(
SnCloudFile file, {
required double size,
bool tinyPreview = true,
}) {
final itemType = file.mimeType?.split('/').firstOrNull;
final mimeType = file.mimeType ?? '';
final extension = file.name.split('.').lastOrNull?.toLowerCase() ?? '';
// For images, show the actual image thumbnail
if (itemType == 'image' && tinyPreview) {
return CloudImageWidget(file: file);
}
// Return icon based on MIME type or file extension
final icon = switch ((itemType, mimeType, extension)) {
('image', _, _) => Symbols.image,
('audio', _, _) => Symbols.audio_file,
('video', _, _) => Symbols.video_file,
('application', 'application/pdf', _) => Symbols.picture_as_pdf,
('application', 'application/zip', _) => Symbols.archive,
('application', 'application/x-rar-compressed', _) => Symbols.archive,
(
'application',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
_,
) ||
('application', 'application/msword', _) => Symbols.description,
(
'application',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
_,
) ||
('application', 'application/vnd.ms-excel', _) => Symbols.table_chart,
(
'application',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
_,
) ||
('application', 'application/vnd.ms-powerpoint', _) => Symbols.slideshow,
('text', _, _) => Symbols.article,
('application', _, 'js') ||
('application', _, 'dart') ||
('application', _, 'py') ||
('application', _, 'java') ||
('application', _, 'cpp') ||
('application', _, 'c') ||
('application', _, 'cs') => Symbols.code,
('application', _, 'json') ||
('application', _, 'xml') => Symbols.data_object,
(_, _, 'md') => Symbols.article,
(_, _, 'html') => Symbols.web,
(_, _, 'css') => Symbols.css,
_ => Symbols.description, // Default icon
};
return Icon(icon, size: size, fill: 1).center();
}

View File

@@ -0,0 +1,15 @@
String formatFileSize(int bytes) {
if (bytes <= 0) return '0 B';
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
if (bytes < 1024 * 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
if (bytes < 1024 * 1024 * 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024 * 1024 * 1024)).toStringAsFixed(2)} TB';
}
return '${(bytes / (1024 * 1024 * 1024 * 1024 * 1024)).toStringAsFixed(2)} PB';
}

View File

@@ -0,0 +1,30 @@
String _upperCamelToLowerSnake(String input) {
final regex = RegExp(r'(?<=[a-z0-9])([A-Z])');
return input
.replaceAllMapped(regex, (match) => '_${match.group(0)}')
.toLowerCase();
}
Map<String, dynamic> convertMapKeysToSnakeCase(Map<String, dynamic> input) {
final result = <String, dynamic>{};
input.forEach((key, value) {
final newKey = _upperCamelToLowerSnake(key);
if (value is Map<String, dynamic>) {
result[newKey] = convertMapKeysToSnakeCase(value);
} else if (value is List) {
result[newKey] =
value.map((item) {
if (item is Map<String, dynamic>) {
return convertMapKeysToSnakeCase(item);
}
return item;
}).toList();
} else {
result[newKey] = value;
}
});
return result;
}

View File

@@ -0,0 +1,71 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/posts/posts_models/post.dart';
import 'package:island/core/config.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/posts/posts_widgets/post/post_item_screenshot.dart';
import 'package:island/posts/posts_widgets/post/post_shared.dart';
import 'package:path_provider/path_provider.dart' show getTemporaryDirectory;
import 'package:screenshot/screenshot.dart';
import 'package:share_plus/share_plus.dart';
import 'package:island/core/services/analytics_service.dart';
/// Shares a post as a screenshot image
Future<void> sharePostAsScreenshot(
BuildContext context,
WidgetRef ref,
SnPost post,
) async {
if (kIsWeb) return;
final screenshotController = ScreenshotController();
showLoadingModal(context);
await screenshotController
.captureFromWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(
ref.watch(sharedPreferencesProvider),
),
repliesProvider(
post.id,
).overrideWithValue(ref.watch(repliesProvider(post.id))),
],
child: Directionality(
textDirection: TextDirection.ltr,
child: SizedBox(
width: 520,
child: PostItemScreenshot(item: post, isFullPost: true),
),
),
),
context: context,
pixelRatio: MediaQuery.of(context).devicePixelRatio,
delay: const Duration(seconds: 1),
)
.then((Uint8List? image) async {
if (image == null) return;
final directory = await getTemporaryDirectory();
final imagePath = await File('${directory.path}/image.png').create();
await imagePath.writeAsBytes(image);
if (!context.mounted) return;
hideLoadingModal(context);
final box = context.findRenderObject() as RenderBox?;
await Share.shareXFiles([
XFile(imagePath.path),
], sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size);
})
.catchError((err) {
if (context.mounted) hideLoadingModal(context);
showErrorAlert(err);
})
.whenComplete(() {
final postTypeStr = post.type == 0 ? 'regular' : 'article';
AnalyticsService().logPostShared(post.id, 'screenshot', postTypeStr);
});
}

14
lib/core/utils/text.dart Normal file
View File

@@ -0,0 +1,14 @@
extension StringExtension on String {
String capitalizeEachWord() {
if (isEmpty) return this;
return split(' ')
.map(
(word) =>
word.isNotEmpty
? '${word[0].toUpperCase()}${word.substring(1).toLowerCase()}'
: '',
)
.join(' ');
}
}

247
lib/core/websocket.dart Normal file
View File

@@ -0,0 +1,247 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/core/config.dart';
import 'package:island/core/network.dart';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:island/talker.dart';
part 'websocket.freezed.dart';
part 'websocket.g.dart';
@freezed
sealed class WebSocketState with _$WebSocketState {
const factory WebSocketState.connected() = _Connected;
const factory WebSocketState.connecting() = _Connecting;
const factory WebSocketState.disconnected() = _Disconnected;
const factory WebSocketState.serverDown() = _ServerDown;
const factory WebSocketState.duplicateDevice() = _DuplicateDevice;
const factory WebSocketState.error(String message) = _Error;
}
@freezed
sealed class WebSocketPacket with _$WebSocketPacket {
const factory WebSocketPacket({
required String type,
required Map<String, dynamic>? data,
String? endpoint,
String? errorMessage,
}) = _WebSocketPacket;
factory WebSocketPacket.fromJson(Map<String, dynamic> json) =>
_$WebSocketPacketFromJson(json);
}
final websocketProvider = Provider<WebSocketService>((ref) {
return WebSocketService();
});
class WebSocketService {
late Ref _ref;
WebSocketChannel? _channel;
final StreamController<WebSocketPacket> _streamController =
StreamController<WebSocketPacket>.broadcast();
final StreamController<WebSocketState> _statusStreamController =
StreamController<WebSocketState>.broadcast();
Timer? _reconnectTimer;
Timer? _heartbeatTimer;
DateTime? _heartbeatAt;
Duration? heartbeatDelay;
// Reconnection tracking
int _reconnectCount = 0;
DateTime? _reconnectWindowStart;
static const int _maxReconnectsPerMinute = 5;
Stream<WebSocketPacket> get dataStream => _streamController.stream;
Stream<WebSocketState> get statusStream => _statusStreamController.stream;
Future<void> connect(Ref ref) async {
_ref = ref;
_statusStreamController.sink.add(WebSocketState.connecting());
final baseUrl = ref.watch(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider));
final url = '$baseUrl/ws'.replaceFirst('http', 'ws');
talker.info('[WebSocket] Trying connecting to $url');
try {
if (kIsWeb) {
_channel = WebSocketChannel.connect(Uri.parse('$url?tk=$token'));
} else {
_channel = IOWebSocketChannel.connect(
Uri.parse(url),
headers: {'Authorization': 'AtField $token'},
);
}
await _channel!.ready;
_statusStreamController.sink.add(WebSocketState.connected());
_scheduleHeartbeat();
_channel!.stream.listen(
(data) {
final dataStr = data is Uint8List
? utf8.decode(data)
: data.toString();
final packet = WebSocketPacket.fromJson(jsonDecode(dataStr));
if (packet.type == 'error.dupe') {
_statusStreamController.sink.add(WebSocketState.duplicateDevice());
_channel!.sink.close();
return;
}
_streamController.sink.add(packet);
talker.info(
"[WebSocket] Received packet: ${packet.type} ${packet.errorMessage}",
);
if (packet.type == 'pong' && _heartbeatAt != null) {
var now = DateTime.now();
heartbeatDelay = now.difference(_heartbeatAt!);
talker.info(
"[WebSocket] Server respond last heartbeat for ${heartbeatDelay!.inMilliseconds} ms",
);
}
},
onDone: () {
talker.info(
'[WebSocket] Connection closed, attempting to reconnect...',
);
_scheduleReconnect();
_statusStreamController.sink.add(WebSocketState.disconnected());
},
onError: (error) {
talker.error(
'[WebSocket] Error occurred: $error, attempting to reconnect...',
);
_scheduleReconnect();
_statusStreamController.sink.add(
WebSocketState.error(error.toString()),
);
},
);
} catch (err) {
talker.error('[WebSocket] Failed to connect: $err');
_scheduleReconnect();
}
}
void _scheduleReconnect() {
// Check if we've exceeded the reconnect limit
final now = DateTime.now();
if (_reconnectWindowStart == null ||
now.difference(_reconnectWindowStart!).inMinutes >= 1) {
// Reset window if it's been more than 1 minute since the window started
_reconnectWindowStart = now;
_reconnectCount = 0;
}
_reconnectCount++;
if (_reconnectCount > _maxReconnectsPerMinute) {
talker.error(
'[WebSocket] Reconnect limit exceeded: $_maxReconnectsPerMinute reconnections in the last minute. Stopping auto-reconnect.',
);
_statusStreamController.sink.add(WebSocketState.serverDown());
return;
}
_reconnectTimer?.cancel();
_reconnectTimer = Timer(const Duration(milliseconds: 500), () {
_statusStreamController.sink.add(WebSocketState.connecting());
connect(_ref);
});
}
void manualReconnect() {
_statusStreamController.sink.add(WebSocketState.connecting());
talker.info('[WebSocket] Manual reconnect triggered by user');
_reconnectTimer?.cancel();
_reconnectTimer = Timer(const Duration(milliseconds: 500), () {
_statusStreamController.sink.add(WebSocketState.connecting());
connect(_ref);
});
}
void _scheduleHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer.periodic(const Duration(seconds: 60), (_) {
_beatTheHeart();
});
}
void _beatTheHeart() {
_heartbeatAt = DateTime.now();
talker.info('[WebSocket] We\'re beating the heart! $_heartbeatAt');
sendMessage(jsonEncode(WebSocketPacket(type: 'ping', data: null)));
}
WebSocketChannel? get ws => _channel;
void sendMessage(String message) {
_channel!.sink.add(message);
}
void close() {
_reconnectTimer?.cancel();
_channel?.sink.close();
}
}
final websocketStateProvider =
NotifierProvider<WebSocketStateNotifier, WebSocketState>(
WebSocketStateNotifier.new,
);
class WebSocketStateNotifier extends Notifier<WebSocketState> {
Timer? _reconnectTimer;
@override
WebSocketState build() {
ref.onDispose(() {
_reconnectTimer?.cancel();
});
return const WebSocketState.disconnected();
}
Future<void> connect() async {
state = const WebSocketState.connecting();
try {
final service = ref.read(websocketProvider);
await service.connect(ref);
state = const WebSocketState.connected();
service.statusStream.listen((event) {
state = event;
});
} catch (err) {
state = WebSocketState.error('Failed to connect: $err');
_scheduleReconnect();
}
}
void _scheduleReconnect() {
_reconnectTimer?.cancel();
_reconnectTimer = Timer(const Duration(milliseconds: 500), () {
connect();
});
}
void sendMessage(String message) {
final service = ref.read(websocketProvider);
service.sendMessage(message);
}
void close() {
final service = ref.read(websocketProvider);
service.close();
_reconnectTimer?.cancel();
state = const WebSocketState.disconnected();
}
void manualReconnect() {
final service = ref.read(websocketProvider);
service.manualReconnect();
}
}

View File

@@ -0,0 +1,752 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'websocket.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$WebSocketState implements DiagnosticableTreeMixin {
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'WebSocketState'))
;
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is WebSocketState);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'WebSocketState()';
}
}
/// @nodoc
class $WebSocketStateCopyWith<$Res> {
$WebSocketStateCopyWith(WebSocketState _, $Res Function(WebSocketState) __);
}
/// Adds pattern-matching-related methods to [WebSocketState].
extension WebSocketStatePatterns on WebSocketState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( _Connected value)? connected,TResult Function( _Connecting value)? connecting,TResult Function( _Disconnected value)? disconnected,TResult Function( _ServerDown value)? serverDown,TResult Function( _DuplicateDevice value)? duplicateDevice,TResult Function( _Error value)? error,required TResult orElse(),}){
final _that = this;
switch (_that) {
case _Connected() when connected != null:
return connected(_that);case _Connecting() when connecting != null:
return connecting(_that);case _Disconnected() when disconnected != null:
return disconnected(_that);case _ServerDown() when serverDown != null:
return serverDown(_that);case _DuplicateDevice() when duplicateDevice != null:
return duplicateDevice(_that);case _Error() when error != null:
return error(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( _Connected value) connected,required TResult Function( _Connecting value) connecting,required TResult Function( _Disconnected value) disconnected,required TResult Function( _ServerDown value) serverDown,required TResult Function( _DuplicateDevice value) duplicateDevice,required TResult Function( _Error value) error,}){
final _that = this;
switch (_that) {
case _Connected():
return connected(_that);case _Connecting():
return connecting(_that);case _Disconnected():
return disconnected(_that);case _ServerDown():
return serverDown(_that);case _DuplicateDevice():
return duplicateDevice(_that);case _Error():
return error(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( _Connected value)? connected,TResult? Function( _Connecting value)? connecting,TResult? Function( _Disconnected value)? disconnected,TResult? Function( _ServerDown value)? serverDown,TResult? Function( _DuplicateDevice value)? duplicateDevice,TResult? Function( _Error value)? error,}){
final _that = this;
switch (_that) {
case _Connected() when connected != null:
return connected(_that);case _Connecting() when connecting != null:
return connecting(_that);case _Disconnected() when disconnected != null:
return disconnected(_that);case _ServerDown() when serverDown != null:
return serverDown(_that);case _DuplicateDevice() when duplicateDevice != null:
return duplicateDevice(_that);case _Error() when error != null:
return error(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function()? connected,TResult Function()? connecting,TResult Function()? disconnected,TResult Function()? serverDown,TResult Function()? duplicateDevice,TResult Function( String message)? error,required TResult orElse(),}) {final _that = this;
switch (_that) {
case _Connected() when connected != null:
return connected();case _Connecting() when connecting != null:
return connecting();case _Disconnected() when disconnected != null:
return disconnected();case _ServerDown() when serverDown != null:
return serverDown();case _DuplicateDevice() when duplicateDevice != null:
return duplicateDevice();case _Error() when error != null:
return error(_that.message);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function() connected,required TResult Function() connecting,required TResult Function() disconnected,required TResult Function() serverDown,required TResult Function() duplicateDevice,required TResult Function( String message) error,}) {final _that = this;
switch (_that) {
case _Connected():
return connected();case _Connecting():
return connecting();case _Disconnected():
return disconnected();case _ServerDown():
return serverDown();case _DuplicateDevice():
return duplicateDevice();case _Error():
return error(_that.message);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function()? connected,TResult? Function()? connecting,TResult? Function()? disconnected,TResult? Function()? serverDown,TResult? Function()? duplicateDevice,TResult? Function( String message)? error,}) {final _that = this;
switch (_that) {
case _Connected() when connected != null:
return connected();case _Connecting() when connecting != null:
return connecting();case _Disconnected() when disconnected != null:
return disconnected();case _ServerDown() when serverDown != null:
return serverDown();case _DuplicateDevice() when duplicateDevice != null:
return duplicateDevice();case _Error() when error != null:
return error(_that.message);case _:
return null;
}
}
}
/// @nodoc
class _Connected with DiagnosticableTreeMixin implements WebSocketState {
const _Connected();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'WebSocketState.connected'))
;
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Connected);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'WebSocketState.connected()';
}
}
/// @nodoc
class _Connecting with DiagnosticableTreeMixin implements WebSocketState {
const _Connecting();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'WebSocketState.connecting'))
;
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Connecting);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'WebSocketState.connecting()';
}
}
/// @nodoc
class _Disconnected with DiagnosticableTreeMixin implements WebSocketState {
const _Disconnected();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'WebSocketState.disconnected'))
;
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Disconnected);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'WebSocketState.disconnected()';
}
}
/// @nodoc
class _ServerDown with DiagnosticableTreeMixin implements WebSocketState {
const _ServerDown();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'WebSocketState.serverDown'))
;
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ServerDown);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'WebSocketState.serverDown()';
}
}
/// @nodoc
class _DuplicateDevice with DiagnosticableTreeMixin implements WebSocketState {
const _DuplicateDevice();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'WebSocketState.duplicateDevice'))
;
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _DuplicateDevice);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'WebSocketState.duplicateDevice()';
}
}
/// @nodoc
class _Error with DiagnosticableTreeMixin implements WebSocketState {
const _Error(this.message);
final String message;
/// Create a copy of WebSocketState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ErrorCopyWith<_Error> get copyWith => __$ErrorCopyWithImpl<_Error>(this, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'WebSocketState.error'))
..add(DiagnosticsProperty('message', message));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Error&&(identical(other.message, message) || other.message == message));
}
@override
int get hashCode => Object.hash(runtimeType,message);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'WebSocketState.error(message: $message)';
}
}
/// @nodoc
abstract mixin class _$ErrorCopyWith<$Res> implements $WebSocketStateCopyWith<$Res> {
factory _$ErrorCopyWith(_Error value, $Res Function(_Error) _then) = __$ErrorCopyWithImpl;
@useResult
$Res call({
String message
});
}
/// @nodoc
class __$ErrorCopyWithImpl<$Res>
implements _$ErrorCopyWith<$Res> {
__$ErrorCopyWithImpl(this._self, this._then);
final _Error _self;
final $Res Function(_Error) _then;
/// Create a copy of WebSocketState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? message = null,}) {
return _then(_Error(
null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
mixin _$WebSocketPacket implements DiagnosticableTreeMixin {
String get type; Map<String, dynamic>? get data; String? get endpoint; String? get errorMessage;
/// Create a copy of WebSocketPacket
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$WebSocketPacketCopyWith<WebSocketPacket> get copyWith => _$WebSocketPacketCopyWithImpl<WebSocketPacket>(this as WebSocketPacket, _$identity);
/// Serializes this WebSocketPacket to a JSON map.
Map<String, dynamic> toJson();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'WebSocketPacket'))
..add(DiagnosticsProperty('type', type))..add(DiagnosticsProperty('data', data))..add(DiagnosticsProperty('endpoint', endpoint))..add(DiagnosticsProperty('errorMessage', errorMessage));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is WebSocketPacket&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.endpoint, endpoint) || other.endpoint == endpoint)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,type,const DeepCollectionEquality().hash(data),endpoint,errorMessage);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'WebSocketPacket(type: $type, data: $data, endpoint: $endpoint, errorMessage: $errorMessage)';
}
}
/// @nodoc
abstract mixin class $WebSocketPacketCopyWith<$Res> {
factory $WebSocketPacketCopyWith(WebSocketPacket value, $Res Function(WebSocketPacket) _then) = _$WebSocketPacketCopyWithImpl;
@useResult
$Res call({
String type, Map<String, dynamic>? data, String? endpoint, String? errorMessage
});
}
/// @nodoc
class _$WebSocketPacketCopyWithImpl<$Res>
implements $WebSocketPacketCopyWith<$Res> {
_$WebSocketPacketCopyWithImpl(this._self, this._then);
final WebSocketPacket _self;
final $Res Function(WebSocketPacket) _then;
/// Create a copy of WebSocketPacket
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? data = freezed,Object? endpoint = freezed,Object? errorMessage = freezed,}) {
return _then(_self.copyWith(
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,endpoint: freezed == endpoint ? _self.endpoint : endpoint // ignore: cast_nullable_to_non_nullable
as String?,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// Adds pattern-matching-related methods to [WebSocketPacket].
extension WebSocketPacketPatterns on WebSocketPacket {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _WebSocketPacket value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _WebSocketPacket() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _WebSocketPacket value) $default,){
final _that = this;
switch (_that) {
case _WebSocketPacket():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _WebSocketPacket value)? $default,){
final _that = this;
switch (_that) {
case _WebSocketPacket() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String type, Map<String, dynamic>? data, String? endpoint, String? errorMessage)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _WebSocketPacket() when $default != null:
return $default(_that.type,_that.data,_that.endpoint,_that.errorMessage);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String type, Map<String, dynamic>? data, String? endpoint, String? errorMessage) $default,) {final _that = this;
switch (_that) {
case _WebSocketPacket():
return $default(_that.type,_that.data,_that.endpoint,_that.errorMessage);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String type, Map<String, dynamic>? data, String? endpoint, String? errorMessage)? $default,) {final _that = this;
switch (_that) {
case _WebSocketPacket() when $default != null:
return $default(_that.type,_that.data,_that.endpoint,_that.errorMessage);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _WebSocketPacket with DiagnosticableTreeMixin implements WebSocketPacket {
const _WebSocketPacket({required this.type, required final Map<String, dynamic>? data, this.endpoint, this.errorMessage}): _data = data;
factory _WebSocketPacket.fromJson(Map<String, dynamic> json) => _$WebSocketPacketFromJson(json);
@override final String type;
final Map<String, dynamic>? _data;
@override Map<String, dynamic>? get data {
final value = _data;
if (value == null) return null;
if (_data is EqualUnmodifiableMapView) return _data;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(value);
}
@override final String? endpoint;
@override final String? errorMessage;
/// Create a copy of WebSocketPacket
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$WebSocketPacketCopyWith<_WebSocketPacket> get copyWith => __$WebSocketPacketCopyWithImpl<_WebSocketPacket>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$WebSocketPacketToJson(this, );
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'WebSocketPacket'))
..add(DiagnosticsProperty('type', type))..add(DiagnosticsProperty('data', data))..add(DiagnosticsProperty('endpoint', endpoint))..add(DiagnosticsProperty('errorMessage', errorMessage));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _WebSocketPacket&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other._data, _data)&&(identical(other.endpoint, endpoint) || other.endpoint == endpoint)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,type,const DeepCollectionEquality().hash(_data),endpoint,errorMessage);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'WebSocketPacket(type: $type, data: $data, endpoint: $endpoint, errorMessage: $errorMessage)';
}
}
/// @nodoc
abstract mixin class _$WebSocketPacketCopyWith<$Res> implements $WebSocketPacketCopyWith<$Res> {
factory _$WebSocketPacketCopyWith(_WebSocketPacket value, $Res Function(_WebSocketPacket) _then) = __$WebSocketPacketCopyWithImpl;
@override @useResult
$Res call({
String type, Map<String, dynamic>? data, String? endpoint, String? errorMessage
});
}
/// @nodoc
class __$WebSocketPacketCopyWithImpl<$Res>
implements _$WebSocketPacketCopyWith<$Res> {
__$WebSocketPacketCopyWithImpl(this._self, this._then);
final _WebSocketPacket _self;
final $Res Function(_WebSocketPacket) _then;
/// Create a copy of WebSocketPacket
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? data = freezed,Object? endpoint = freezed,Object? errorMessage = freezed,}) {
return _then(_WebSocketPacket(
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,data: freezed == data ? _self._data : data // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,endpoint: freezed == endpoint ? _self.endpoint : endpoint // ignore: cast_nullable_to_non_nullable
as String?,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
// dart format on

23
lib/core/websocket.g.dart Normal file
View File

@@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'websocket.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_WebSocketPacket _$WebSocketPacketFromJson(Map<String, dynamic> json) =>
_WebSocketPacket(
type: json['type'] as String,
data: json['data'] as Map<String, dynamic>?,
endpoint: json['endpoint'] as String?,
errorMessage: json['error_message'] as String?,
);
Map<String, dynamic> _$WebSocketPacketToJson(_WebSocketPacket instance) =>
<String, dynamic>{
'type': instance.type,
'data': instance.data,
'endpoint': instance.endpoint,
'error_message': instance.errorMessage,
};

View File

@@ -0,0 +1,715 @@
import 'dart:convert';
import 'dart:io';
import 'package:cross_file/cross_file.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:island/core/services/image.dart';
import 'package:video_thumbnail/video_thumbnail.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/core/network.dart';
import 'package:island/drive/drive_service.dart';
import 'package:island/core/utils/format.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/core/widgets/content/sensitive.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:super_context_menu/super_context_menu.dart';
class SensitiveMarksSelector extends StatefulWidget {
final List<int> initial;
final ValueChanged<List<int>>? onChanged;
const SensitiveMarksSelector({
super.key,
required this.initial,
this.onChanged,
});
@override
State<SensitiveMarksSelector> createState() => SensitiveMarksSelectorState();
}
class SensitiveMarksSelectorState extends State<SensitiveMarksSelector> {
late List<int> _selected;
List<int> get current => _selected;
@override
void initState() {
super.initState();
_selected = [...widget.initial];
}
void _toggle(int value) {
setState(() {
if (_selected.contains(value)) {
_selected.remove(value);
} else {
_selected.add(value);
}
});
widget.onChanged?.call([..._selected]);
}
@override
Widget build(BuildContext context) {
// Build a list of all categories in fixed order as int list indices
final categories = kSensitiveCategoriesOrdered;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Wrap(
spacing: 8,
children: [
for (var i = 0; i < categories.length; i++)
FilterChip(
label: Text(categories[i].i18nKey.tr()),
avatar: Text(categories[i].symbol),
selected: _selected.contains(i),
onSelected: (_) => _toggle(i),
),
],
),
],
);
}
}
class AttachmentPreview extends HookConsumerWidget {
final UniversalFile item;
final double? progress;
final bool isUploading;
final Function(int)? onMove;
final Function? onDelete;
final Function? onInsert;
final Function(UniversalFile)? onUpdate;
final Function? onRequestUpload;
final bool isCompact;
final String? thumbnailId;
final Function(String?)? onSetThumbnail;
final bool bordered;
const AttachmentPreview({
super.key,
required this.item,
this.progress,
this.isUploading = false,
this.onRequestUpload,
this.onMove,
this.onDelete,
this.onUpdate,
this.onInsert,
this.isCompact = false,
this.thumbnailId,
this.onSetThumbnail,
this.bordered = false,
});
// GlobalKey for selector
static final GlobalKey<SensitiveMarksSelectorState> _sensitiveSelectorKey =
GlobalKey<SensitiveMarksSelectorState>();
String _getDisplayName() {
return item.displayName ??
(item.data is XFile
? (item.data as XFile).name
: item.isOnCloud
? item.data.name
: '');
}
Future<void> _showRenameSheet(BuildContext context, WidgetRef ref) async {
final nameController = TextEditingController(text: _getDisplayName());
String? errorMessage;
await showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => SheetScaffold(
heightFactor: 0.6,
titleText: 'rename'.tr(),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
child: TextField(
controller: nameController,
decoration: InputDecoration(
labelText: 'fileName'.tr(),
border: const OutlineInputBorder(),
errorText: errorMessage,
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('cancel'.tr()),
),
const Gap(8),
TextButton(
onPressed: () async {
final newName = nameController.text.trim();
if (newName.isEmpty) {
errorMessage = 'fieldCannotBeEmpty'.tr();
return;
}
if (item.isOnCloud) {
try {
showLoadingModal(context);
final apiClient = ref.watch(apiClientProvider);
await apiClient.patch(
'/drive/files/${item.data.id}/name',
data: jsonEncode(newName),
);
final newData = item.data;
newData.name = newName;
onUpdate?.call(
item.copyWith(data: newData, displayName: newName),
);
if (context.mounted) Navigator.pop(context);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
} else {
// Local file rename
onUpdate?.call(item.copyWith(displayName: newName));
if (context.mounted) Navigator.pop(context);
}
},
child: Text('rename'.tr()),
),
],
).padding(horizontal: 16, vertical: 8),
],
),
),
);
}
Future<void> _showSensitiveDialog(BuildContext context, WidgetRef ref) async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => SheetScaffold(
heightFactor: 0.6,
titleText: 'markAsSensitive'.tr(),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
child: Column(
children: [
// Sensitive categories checklist
SensitiveMarksSelector(
key: _sensitiveSelectorKey,
initial: (item.data.sensitiveMarks ?? [])
.map((e) => e as int)
.cast<int>()
.toList(),
onChanged: (marks) {
// Update local data immediately (optimistic)
final newData = item.data;
newData.sensitiveMarks = marks;
final updatedFile = item.copyWith(data: newData);
onUpdate?.call(item.copyWith(data: updatedFile));
},
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('cancel'.tr()),
),
const Gap(8),
TextButton(
onPressed: () async {
try {
showLoadingModal(context);
final apiClient = ref.watch(apiClientProvider);
// Use the current selections from stateful selector via GlobalKey
final selectorState = _sensitiveSelectorKey.currentState;
final marks = selectorState?.current ?? <int>[];
await apiClient.put(
'/drive/files/${item.data.id}/marks',
data: jsonEncode({'sensitive_marks': marks}),
);
final newData = item.data as SnCloudFile;
final updatedFile = item.copyWith(
data: newData.copyWith(sensitiveMarks: marks),
);
onUpdate?.call(updatedFile);
if (context.mounted) Navigator.pop(context);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
},
child: Text('confirm'.tr()),
),
],
).padding(horizontal: 16, vertical: 8),
],
),
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
var ratio = item.isOnCloud
? (item.data.fileMeta?['ratio'] is num
? item.data.fileMeta!['ratio'].toDouble()
: null)
: null;
final innerContentWidget = Stack(
fit: StackFit.expand,
children: [
HookBuilder(
key: ValueKey(item.hashCode),
builder: (context) {
final fallbackIcon = switch (item.type) {
UniversalFileType.video => Symbols.video_file,
UniversalFileType.audio => Symbols.audio_file,
UniversalFileType.image => Symbols.image,
_ => Symbols.insert_drive_file,
};
final mimeType = FileUploader.getMimeType(item);
if (item.isOnCloud) {
return CloudFileWidget(item: item.data);
} else if (item.data is XFile) {
final file = item.data as XFile;
if (file.path.isEmpty) {
return FutureBuilder<Uint8List>(
future: file.readAsBytes(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return Image.memory(snapshot.data!);
}
return const Center(child: CircularProgressIndicator());
},
);
}
switch (item.type) {
case UniversalFileType.image:
return kIsWeb
? Image.network(file.path)
: Image.file(File(file.path));
case UniversalFileType.video:
if (!kIsWeb) {
final thumbnailFuture = useMemoized(
() => VideoThumbnail.thumbnailData(
video: file.path,
imageFormat: ImageFormat.JPEG,
maxWidth: 320,
quality: 50,
),
[file.path],
);
return FutureBuilder<Uint8List?>(
future: thumbnailFuture,
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return Stack(
children: [
Image.memory(snapshot.data!),
Positioned.fill(
child: Center(
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
shape: BoxShape.circle,
),
child: const Icon(
Symbols.play_arrow,
color: Colors.white,
size: 32,
),
),
),
),
],
);
}
return const Center(child: CircularProgressIndicator());
},
);
}
break;
default:
break;
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(fallbackIcon),
const Gap(6),
Text(
_getDisplayName(),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
Text(mimeType, style: TextStyle(fontSize: 10)),
const Gap(1),
FutureBuilder(
future: file.length(),
builder: (context, snapshot) {
if (snapshot.hasData) {
final size = snapshot.data as int;
return Text(formatFileSize(size)).fontSize(11);
}
return const SizedBox.shrink();
},
),
],
).padding(vertical: 32);
} else if (item is List<int> || item is Uint8List) {
switch (item.type) {
case UniversalFileType.image:
return Image.memory(item.data);
default:
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(fallbackIcon),
const Gap(6),
Text(mimeType, style: TextStyle(fontSize: 10)),
const Gap(1),
Text(formatFileSize(item.data.length)).fontSize(11),
],
);
}
}
return Placeholder();
},
),
if (isUploading && progress != null && (progress ?? 0) > 0)
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.3),
padding: EdgeInsets.symmetric(horizontal: 40, vertical: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'${(progress! * 100).toStringAsFixed(2)}%',
style: TextStyle(color: Colors.white),
),
Gap(6),
Center(
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: progress),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
builder: (context, value, child) =>
LinearProgressIndicator(value: value),
),
),
],
),
),
),
if (isUploading && (progress == null || progress == 0))
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.3),
padding: EdgeInsets.symmetric(horizontal: 40, vertical: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'processing'.tr(),
style: TextStyle(color: Colors.white),
),
Gap(6),
Center(child: LinearProgressIndicator(value: null)),
],
),
),
),
if (thumbnailId != null &&
item.isOnCloud &&
(item.data as SnCloudFile).id == thumbnailId)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.primary,
width: 3,
),
borderRadius: BorderRadius.circular(8),
),
),
),
if (thumbnailId != null &&
item.isOnCloud &&
(item.data as SnCloudFile).id == thumbnailId)
Positioned(
bottom: 8,
right: 8,
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Symbols.image,
size: 16,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
);
final contentWidget = Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
border: bordered
? Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
width: 1,
)
: null,
borderRadius: BorderRadius.circular(8),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
children: [
if (ratio != null)
AspectRatio(
aspectRatio: ratio,
child: innerContentWidget,
).center()
else
IntrinsicHeight(child: innerContentWidget).center(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Colors.black.withOpacity(0.5),
child: Material(
color: Colors.transparent,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (onDelete != null)
InkWell(
borderRadius: BorderRadius.circular(8),
child: Icon(
item.isLink ? Symbols.link_off : Symbols.delete,
size: 14,
color: Colors.white,
).padding(horizontal: 8, vertical: 6),
onTap: () {
onDelete?.call();
},
),
if (onDelete != null && onMove != null)
SizedBox(
height: 26,
child: const VerticalDivider(
width: 0.3,
color: Colors.white,
thickness: 0.3,
),
).padding(horizontal: 2),
if (onMove != null)
InkWell(
borderRadius: BorderRadius.circular(8),
child: const Icon(
Symbols.keyboard_arrow_up,
size: 14,
color: Colors.white,
).padding(horizontal: 8, vertical: 6),
onTap: () {
onMove?.call(-1);
},
),
if (onMove != null)
InkWell(
borderRadius: BorderRadius.circular(8),
child: const Icon(
Symbols.keyboard_arrow_down,
size: 14,
color: Colors.white,
).padding(horizontal: 8, vertical: 6),
onTap: () {
onMove?.call(1);
},
),
if (onInsert != null)
InkWell(
borderRadius: BorderRadius.circular(8),
child: const Icon(
Symbols.add,
size: 14,
color: Colors.white,
).padding(horizontal: 8, vertical: 6),
onTap: () {
onInsert?.call();
},
),
],
),
),
),
),
if (onRequestUpload != null)
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: item.isOnCloud
? null
: () => onRequestUpload?.call(),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Colors.black.withOpacity(0.5),
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: (item.isOnCloud)
? Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.cloud,
size: 16,
color: Colors.white,
),
if (!isCompact) const Gap(8),
if (!isCompact)
Text(
'attachmentOnCloud'.tr(),
style: TextStyle(color: Colors.white),
),
],
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.cloud_off,
size: 16,
color: Colors.white,
),
if (!isCompact) const Gap(8),
if (!isCompact)
Text(
'attachmentOnDevice'.tr(),
style: TextStyle(color: Colors.white),
),
],
),
),
),
),
],
).padding(horizontal: 12, vertical: 8),
],
),
),
);
return ContextMenuWidget(
menuProvider: (MenuRequest request) => Menu(
children: [
if (item.isOnDevice && item.type == UniversalFileType.image)
MenuAction(
title: 'crop'.tr(),
image: MenuImage.icon(Symbols.crop),
callback: () async {
final result = await cropImage(
context,
image: item.data,
replacePath: true,
);
if (result == null) return;
onUpdate?.call(item.copyWith(data: result));
},
),
if (item.isOnDevice)
MenuAction(
title: 'rename'.tr(),
image: MenuImage.icon(Symbols.edit),
callback: () async {
await _showRenameSheet(context, ref);
},
),
if (item.isOnCloud)
MenuAction(
title: 'rename'.tr(),
image: MenuImage.icon(Symbols.edit),
callback: () async {
await _showRenameSheet(context, ref);
},
),
if (item.isOnCloud)
MenuAction(
title: 'markAsSensitive'.tr(),
image: MenuImage.icon(Symbols.no_adult_content),
callback: () async {
await _showSensitiveDialog(context, ref);
},
),
if (item.isOnCloud &&
item.type == UniversalFileType.image &&
onSetThumbnail != null)
MenuAction(
title: thumbnailId == (item.data as SnCloudFile).id
? 'unsetAsThumbnail'.tr()
: 'setAsThumbnail'.tr(),
image: MenuImage.icon(Symbols.image),
callback: () {
final isCurrentlyThumbnail =
thumbnailId == (item.data as SnCloudFile).id;
if (isCurrentlyThumbnail) {
onSetThumbnail?.call(null);
} else {
onSetThumbnail?.call((item.data as SnCloudFile).id);
}
},
),
],
),
child: contentWidget,
);
}
}

View File

@@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/core/config.dart';
import 'package:island/core/network.dart';
import 'package:island/core/services/time.dart';
import 'package:island/talker.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:media_kit/media_kit.dart';
import 'package:styled_widget/styled_widget.dart';
class UniversalAudio extends ConsumerStatefulWidget {
final String uri;
final String filename;
final bool autoplay;
const UniversalAudio({
super.key,
required this.uri,
required this.filename,
this.autoplay = false,
});
@override
ConsumerState<UniversalAudio> createState() => _UniversalAudioState();
}
class _UniversalAudioState extends ConsumerState<UniversalAudio> {
Player? _player;
Duration _duration = Duration(seconds: 1);
Duration _duartionBuffered = Duration(seconds: 1);
Duration _position = Duration(seconds: 0);
bool _sliderWorking = false;
Duration _sliderPosition = Duration(seconds: 0);
void _openAudio() async {
final url = widget.uri;
MediaKit.ensureInitialized();
_player = Player();
_player!.stream.position.listen((value) {
_position = value;
if (!_sliderWorking) _sliderPosition = _position;
setState(() {});
});
_player!.stream.buffer.listen((value) {
_duartionBuffered = value;
setState(() {});
});
_player!.stream.duration.listen((value) {
_duration = value;
setState(() {});
});
String? uri;
final inCacheInfo = await DefaultCacheManager().getFileFromCache(url);
if (inCacheInfo == null) {
talker.info('[MediaPlayer] Miss cache: $url');
final serverUrl = ref.read(serverUrlProvider);
final token = ref.read(tokenProvider);
final authHeaders = url.startsWith(serverUrl) && token != null
? {'Authorization': 'AtField ${token.token}'}
: null;
DefaultCacheManager().downloadFile(
url,
authHeaders: authHeaders,
);
uri = url;
} else {
uri = inCacheInfo.file.path;
talker.info('[MediaPlayer] Hit cache: $url');
}
final serverUrl = ref.read(serverUrlProvider);
final token = ref.read(tokenProvider);
final Map<String, String>? httpHeaders = uri.startsWith(serverUrl) && token != null
? {'Authorization': 'AtField ${token.token}'}
: null;
_player!.open(Media(uri, httpHeaders: httpHeaders), play: widget.autoplay);
}
@override
void initState() {
super.initState();
_openAudio();
}
@override
void dispose() {
super.dispose();
_player?.dispose();
}
@override
Widget build(BuildContext context) {
if (_player == null) {
return Center(child: CircularProgressIndicator());
}
return Card(
color: Theme.of(context).colorScheme.surfaceContainerLowest,
child: Row(
children: [
IconButton.filled(
onPressed: () {
_player!.playOrPause().then((_) {
if (mounted) setState(() {});
});
},
icon:
_player!.state.playing
? const Icon(Symbols.pause, fill: 1, color: Colors.white)
: const Icon(
Symbols.play_arrow,
fill: 1,
color: Colors.white,
),
),
const Gap(20),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child:
(_player!.state.playing || _sliderWorking)
? SizedBox(
width: double.infinity,
key: const ValueKey('playing'),
child: Text(
'${_position.formatShortDuration()} / ${_duration.formatShortDuration()}',
),
)
: SizedBox(
width: double.infinity,
key: const ValueKey('filename'),
child: Text(
widget.filename.isEmpty
? 'Audio'
: widget.filename,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
Slider(
value: _sliderPosition.inMilliseconds.toDouble(),
secondaryTrackValue:
_duartionBuffered.inMilliseconds.toDouble(),
max: _duration.inMilliseconds.toDouble(),
onChangeStart: (_) {
_sliderWorking = true;
},
onChanged: (value) {
_sliderPosition = Duration(milliseconds: value.toInt());
setState(() {});
},
onChangeEnd: (value) {
_sliderPosition = Duration(milliseconds: value.toInt());
_sliderWorking = false;
_player!.seek(_sliderPosition);
},
year2023: true,
padding: EdgeInsets.zero,
),
],
),
),
],
).padding(horizontal: 24, vertical: 16),
);
}
}

View File

@@ -0,0 +1,610 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/core/config.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/core/widgets/content/cloud_file_lightbox.dart';
import 'package:island/core/widgets/content/sensitive.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:uuid/uuid.dart';
class CloudFileList extends HookConsumerWidget {
final List<SnCloudFile> files;
final double maxHeight;
final double maxWidth;
final double? minWidth;
final bool disableZoomIn;
final bool disableConstraint;
final EdgeInsets? padding;
final bool isColumn;
const CloudFileList({
super.key,
required this.files,
this.maxHeight = 560,
this.maxWidth = double.infinity,
this.minWidth,
this.disableZoomIn = false,
this.disableConstraint = false,
this.padding,
this.isColumn = false,
});
double calculateAspectRatio() {
final ratios = <double>[];
// Collect all valid ratios
for (final file in files) {
final meta = file.fileMeta;
if (meta is Map<String, dynamic> && meta.containsKey('ratio')) {
final ratioValue = meta['ratio'];
if (ratioValue is num && ratioValue > 0) {
ratios.add(ratioValue.toDouble());
} else if (ratioValue is String) {
try {
final parsed = double.parse(ratioValue);
if (parsed > 0) ratios.add(parsed);
} catch (_) {
// Skip invalid string ratios
}
}
}
}
if (ratios.isEmpty) {
// Default to 4:3 aspect ratio when no valid ratios found
return 4 / 3;
}
if (ratios.length == 1) {
return ratios.first;
}
// Group similar ratios and find the most common one
final commonRatios = <double, int>{};
// Common aspect ratios to round to (with tolerance)
const tolerance = 0.05;
final standardRatios = [
1.0,
4 / 3,
3 / 2,
16 / 9,
5 / 3,
5 / 4,
7 / 5,
9 / 16,
2 / 3,
3 / 4,
4 / 5,
];
for (final ratio in ratios) {
// Find the closest standard ratio within tolerance
double closestRatio = ratio;
double minDiff = double.infinity;
for (final standard in standardRatios) {
final diff = (ratio - standard).abs();
if (diff < minDiff && diff <= tolerance) {
minDiff = diff;
closestRatio = standard;
}
}
// If no standard ratio is close enough, keep original
if (minDiff == double.infinity || minDiff > tolerance) {
closestRatio = ratio;
}
commonRatios[closestRatio] = (commonRatios[closestRatio] ?? 0) + 1;
}
// Find the most frequent ratio(s)
int maxCount = 0;
final mostFrequent = <double>[];
for (final entry in commonRatios.entries) {
if (entry.value > maxCount) {
maxCount = entry.value;
mostFrequent.clear();
mostFrequent.add(entry.key);
} else if (entry.value == maxCount) {
mostFrequent.add(entry.key);
}
}
// If only one most frequent ratio, return it
if (mostFrequent.length == 1) {
return mostFrequent.first;
}
// If multiple ratios have the same highest frequency, use median of them
mostFrequent.sort();
final mid = mostFrequent.length ~/ 2;
return mostFrequent.length.isEven
? (mostFrequent[mid - 1] + mostFrequent[mid]) / 2
: mostFrequent[mid];
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final heroTags = useMemoized(
() => List.generate(
files.length,
(index) => 'cloud-files#${const Uuid().v4()}',
),
[files],
);
if (files.isEmpty) return const SizedBox.shrink();
if (isColumn) {
final children = <Widget>[];
const maxFiles = 2;
final filesToShow = files.take(maxFiles).toList();
for (var i = 0; i < filesToShow.length; i++) {
final file = filesToShow[i];
final isImage = file.mimeType?.startsWith('image') ?? false;
final isAudio = file.mimeType?.startsWith('audio') ?? false;
final widgetItem = ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: _CloudFileListEntry(
file: file,
heroTag: heroTags[i],
isImage: isImage,
disableZoomIn: disableZoomIn,
onTap: () {
if (!isImage) {
return;
}
if (!disableZoomIn) {
context.pushTransparentRoute(
CloudFileLightbox(item: file, heroTag: heroTags[i]),
rootNavigator: true,
);
}
},
),
);
Widget item;
if (isAudio) {
item = SizedBox(height: 120, child: widgetItem);
} else {
item = AspectRatio(
aspectRatio: file.fileMeta?['ratio'] as double? ?? 1.0,
child: widgetItem,
);
}
children.add(item);
if (i < filesToShow.length - 1) {
children.add(const Gap(8));
}
}
if (files.length > maxFiles) {
children.add(const Gap(8));
children.add(
Text(
'filesListAdditional'.plural(files.length - filesToShow.length),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
),
);
}
return Padding(
padding: padding ?? EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
);
}
if (files.length == 1) {
final isImage = files.first.mimeType?.startsWith('image') ?? false;
final isAudio = files.first.mimeType?.startsWith('audio') ?? false;
final ratio = files.first.fileMeta?['ratio'] as num?;
final widgetItem = ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: _CloudFileListEntry(
file: files.first,
heroTag: heroTags.first,
isImage: isImage,
disableZoomIn: disableZoomIn,
onTap: () {
if (!isImage) {
return;
}
if (!disableZoomIn) {
context.pushTransparentRoute(
CloudFileLightbox(item: files.first, heroTag: heroTags.first),
rootNavigator: true,
);
}
},
),
);
return Container(
padding: padding,
constraints: BoxConstraints(
maxHeight: disableConstraint ? double.infinity : maxHeight,
minWidth: minWidth ?? 0,
maxWidth: files.length == 1 ? maxWidth : double.infinity,
),
child: (ratio == null && isImage)
? IntrinsicWidth(child: IntrinsicHeight(child: widgetItem))
: (ratio == null && isAudio)
? IntrinsicHeight(child: widgetItem)
: AspectRatio(
aspectRatio: ratio?.toDouble() ?? 1,
child: widgetItem,
),
);
}
final allImages = !files.any(
(e) => e.mimeType == null || !e.mimeType!.startsWith('image'),
);
if (allImages) {
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth),
child: AspectRatio(
aspectRatio: calculateAspectRatio(),
child: Padding(
padding: padding ?? EdgeInsets.zero,
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth.isFinite
? constraints.maxWidth
: MediaQuery.of(context).size.width;
final itemExtent = math.min(
math.min(availableWidth * 0.75, maxWidth * 0.75).toDouble(),
640.0,
);
return CarouselView(
itemSnapping: true,
itemExtent: itemExtent,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(16)),
),
children: [
for (var i = 0; i < files.length; i++)
Stack(
children: [
_CloudFileListEntry(
file: files[i],
heroTag: heroTags[i],
isImage:
files[i].mimeType?.startsWith('image') ?? false,
disableZoomIn: disableZoomIn,
),
Positioned(
bottom: 12,
left: 16,
child: Text('${i + 1}/${files.length}')
.textColor(Colors.white)
.textShadow(
color: Colors.black54,
offset: Offset(1, 1),
blurRadius: 3,
),
),
],
),
],
onTap: (i) {
if (!(files[i].mimeType?.startsWith('image') ?? false)) {
return;
}
if (!disableZoomIn) {
context.pushTransparentRoute(
CloudFileLightbox(item: files[i], heroTag: heroTags[i]),
rootNavigator: true,
);
}
},
);
},
),
),
),
);
}
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth),
child: AspectRatio(
aspectRatio: calculateAspectRatio(),
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: files.length,
padding: padding,
itemBuilder: (context, index) {
return AspectRatio(
aspectRatio: files[index].fileMeta?['ratio'] is num
? files[index].fileMeta!['ratio'].toDouble()
: 1.0,
child: Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: _CloudFileListEntry(
file: files[index],
heroTag: heroTags[index],
isImage:
files[index].mimeType?.startsWith('image') ?? false,
disableZoomIn: disableZoomIn,
onTap: () {
if (!(files[index].mimeType?.startsWith('image') ??
false)) {
return;
}
if (!disableZoomIn) {
context.pushTransparentRoute(
CloudFileLightbox(
item: files[index],
heroTag: heroTags[index],
),
rootNavigator: true,
);
}
},
),
),
Positioned(
bottom: 12,
left: 16,
child: Text('${index + 1}/${files.length}')
.textColor(Colors.white)
.textShadow(
color: Colors.black54,
offset: Offset(1, 1),
blurRadius: 3,
),
),
],
),
);
},
separatorBuilder: (_, _) => const Gap(8),
),
),
);
}
}
class _CloudFileListEntry extends HookConsumerWidget {
final SnCloudFile file;
final String heroTag;
final bool isImage;
final bool disableZoomIn;
final VoidCallback? onTap;
const _CloudFileListEntry({
required this.file,
required this.heroTag,
required this.isImage,
required this.disableZoomIn,
this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final dataSaving = ref.watch(
appSettingsProvider.select((s) => s.dataSavingMode),
);
final showMature = useState(false);
final showDataSaving = useState(!dataSaving);
final lockedByDS = dataSaving && !showDataSaving.value;
final lockedByMature = file.sensitiveMarks.isNotEmpty && !showMature.value;
final meta = file.fileMeta is Map ? file.fileMeta as Map : const {};
final fit = BoxFit.cover;
Widget bg = const SizedBox.shrink();
if (isImage) {
if (meta['blur'] is String) {
bg = BlurHash(hash: meta['blur'] as String);
} else if (!lockedByDS && !lockedByMature) {
bg = ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
child: CloudFileWidget(
fit: BoxFit.cover,
item: file,
noBlurhash: true,
useInternalGate: false,
),
);
} else {
bg = const ColoredBox(color: Colors.black26);
}
}
final bool fullyUnlocked = !lockedByDS && !lockedByMature;
Widget fg = fullyUnlocked
? (isImage
? CloudFileWidget(
item: file,
heroTag: heroTag,
noBlurhash: true,
fit: fit,
useInternalGate: false,
)
: CloudFileWidget(
item: file,
heroTag: heroTag,
fit: fit,
useInternalGate: false,
))
: const SizedBox.shrink();
Widget overlays = AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: lockedByDS
? _DataSavingOverlay(key: const ValueKey('ds'))
: (file.sensitiveMarks.isNotEmpty && !showMature.value
? _SensitiveOverlay(
key: const ValueKey('sensitive-blur'),
file: file,
)
: const SizedBox.shrink(key: ValueKey('none'))),
);
Widget hideButton = const SizedBox.shrink();
if (file.sensitiveMarks.isNotEmpty && showMature.value) {
hideButton = Positioned(
top: 3,
left: 4,
child: IconButton(
iconSize: 16,
constraints: const BoxConstraints(),
icon: const Icon(
Icons.visibility_off,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black,
blurRadius: 5.0,
offset: Offset(1.0, 1.0),
),
],
),
tooltip: 'Blur content',
onPressed: () => showMature.value = false,
),
);
}
final content = Stack(
fit: StackFit.expand,
children: [
if (isImage) Positioned.fill(child: bg),
fg,
overlays,
hideButton,
],
);
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(16)),
onTap: () {
if (lockedByDS) {
showDataSaving.value = true;
} else if (lockedByMature) {
showMature.value = true;
} else {
onTap?.call();
}
},
child: content,
);
}
}
class _SensitiveOverlay extends StatelessWidget {
final SnCloudFile file;
const _SensitiveOverlay({required this.file, super.key});
@override
Widget build(BuildContext context) {
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 64, sigmaY: 64),
child: Container(
color: Colors.transparent,
child: Center(
child: _OverlayCard(
icon: Icons.warning,
title: file.sensitiveMarks
.map((e) => SensitiveCategory.values[e].i18nKey.tr())
.join(' · '),
subtitle: 'Sensitive Content',
hint: 'Tap to Reveal',
),
),
),
);
}
}
class _DataSavingOverlay extends StatelessWidget {
const _DataSavingOverlay({super.key});
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.black38,
child: Center(
child: _OverlayCard(
icon: Symbols.image,
title: 'Data Saving Mode',
subtitle: '',
hint: 'Tap to Load',
),
),
);
}
}
class _OverlayCard extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final String hint;
const _OverlayCard({
required this.icon,
required this.title,
required this.subtitle,
required this.hint,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(12),
),
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white, size: 24),
const Gap(4),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
Text(
subtitle,
style: const TextStyle(color: Colors.white, fontSize: 13),
),
const Gap(4),
Text(hint, style: const TextStyle(color: Colors.white, fontSize: 11)),
],
),
);
}
}

View File

@@ -0,0 +1,158 @@
import 'package:dismissible_page/dismissible_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/core/config.dart';
import 'package:island/drive/drive_service.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/core/widgets/content/exif_info_overlay.dart';
import 'package:island/core/widgets/content/file_action_button.dart';
import 'package:island/core/widgets/content/file_info_sheet.dart';
import 'package:island/core/widgets/content/image_control_overlay.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:photo_view/photo_view.dart';
class CloudFileLightbox extends HookConsumerWidget {
final SnCloudFile item;
final String heroTag;
const CloudFileLightbox({
super.key,
required this.item,
required this.heroTag,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final serverUrl = ref.watch(serverUrlProvider);
final photoViewController = useMemoized(() => PhotoViewController(), []);
final rotation = useState(0);
final hasExifData = ExifInfoOverlay.precheck(item);
final showOriginal = useState(false);
final showExif = useState(hasExifData);
void saveToGallery() {
FileDownloadService(ref).saveToGallery(item);
}
void showInfoSheet() {
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder: (context) => FileInfoSheet(item: item),
);
}
return DismissiblePage(
isFullScreen: true,
backgroundColor: Colors.transparent,
direction: DismissiblePageDismissDirection.down,
onDismissed: () {
Navigator.of(context).pop();
},
child: Stack(
children: [
Positioned.fill(
child: PhotoView(
backgroundDecoration: BoxDecoration(
color: Colors.black.withOpacity(0.9),
),
controller: photoViewController,
heroAttributes: PhotoViewHeroAttributes(tag: heroTag),
imageProvider: CloudImageWidget.provider(
file: item,
serverUrl: serverUrl,
original: showOriginal.value,
),
// Apply rotation transformation
customSize: MediaQuery.of(context).size,
basePosition: Alignment.center,
filterQuality: FilterQuality.high,
),
),
// Close button and save button
Positioned(
top: MediaQuery.of(context).padding.top + 16,
right: 16,
left: 16,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
if (!kIsWeb)
FileActionButton.save(
onPressed: saveToGallery,
shadows: WhiteShadows.standard,
),
IconButton(
onPressed: () {
showOriginal.value = !showOriginal.value;
},
icon: Icon(
showOriginal.value ? Symbols.hd : Symbols.sd,
color: Colors.white,
shadows: WhiteShadows.standard,
),
),
],
),
FileActionButton.close(
onPressed: () => Navigator.of(context).pop(),
shadows: WhiteShadows.standard,
),
],
),
),
// EXIF Info Overlay (positioned below the top buttons)
if (showExif.value)
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 60,
left: 16,
right: 16,
child: ExifInfoOverlay(item: item),
),
ImageControlOverlay(
photoViewController: photoViewController,
rotation: rotation,
showOriginal: showOriginal.value,
onToggleQuality: () {
showOriginal.value = !showOriginal.value;
},
showExifInfo: showExif.value,
onToggleExif: () {
showExif.value = !showExif.value;
},
hasExifData: hasExifData,
extraButtons: [
FileActionButton.info(
onPressed: showInfoSheet,
shadows: WhiteShadows.standard,
),
if (item.url != null)
FileActionButton.more(
onPressed: () {
final router = GoRouter.of(context);
Navigator.of(context).pop(context);
Future(() {
router.pushNamed(
'fileDetail',
pathParameters: {'id': item.id},
extra: item,
);
});
},
shadows: WhiteShadows.standard,
),
],
showExtraOnLeft: true,
),
],
),
);
}
}

View File

@@ -0,0 +1,309 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/drive/drive_service.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/core/widgets/content/attachment_preview.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class CloudFilePicker extends HookConsumerWidget {
final bool allowMultiple;
final Set<UniversalFileType> allowedTypes;
const CloudFilePicker({
super.key,
this.allowMultiple = false,
this.allowedTypes = const {
UniversalFileType.image,
UniversalFileType.video,
UniversalFileType.file,
},
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final files = useState<List<UniversalFile>>([]);
final uploadPosition = useState<int?>(null);
final uploadProgress = useState<double?>(null);
final uploadOverallProgress = useMemoized<double?>(() {
if (uploadPosition.value == null || uploadProgress.value == null) {
return null;
}
// Calculate completed files (100% each) + current file progress
final completedProgress = uploadPosition.value! * 100.0;
final currentProgress = uploadProgress.value!;
// Calculate overall progress as percentage
return (completedProgress + currentProgress) /
(files.value.length * 100.0);
}, [uploadPosition.value, uploadProgress.value, files.value.length]);
Future<void> startUpload() async {
if (files.value.isEmpty) return;
List<SnCloudFile> result = List.empty(growable: true);
uploadProgress.value = 0;
uploadPosition.value = 0;
try {
for (var idx = 0; idx < files.value.length; idx++) {
uploadPosition.value = idx;
final file = files.value[idx];
final cloudFile = await FileUploader.createCloudFile(
fileData: file,
ref: ref,
onProgress: (progress, _) {
uploadProgress.value = progress;
},
).future;
if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...');
}
result.add(cloudFile);
}
if (context.mounted) Navigator.pop(context, result);
} catch (err) {
showErrorAlert(err);
}
}
void pickFile() async {
showLoadingModal(context);
final result = await FilePicker.platform.pickFiles(
allowMultiple: allowMultiple,
);
if (result == null) {
if (context.mounted) hideLoadingModal(context);
return;
}
final newFiles = result.files.map((e) {
final xfile = e.bytes != null
? XFile.fromData(e.bytes!, name: e.name)
: XFile(e.path!);
return UniversalFile(data: xfile, type: UniversalFileType.file);
}).toList();
if (!allowMultiple) {
files.value = newFiles;
if (context.mounted) {
hideLoadingModal(context);
startUpload();
}
return;
}
files.value = [...files.value, ...newFiles];
if (context.mounted) hideLoadingModal(context);
}
void pickImage() async {
showLoadingModal(context);
final ImagePicker picker = ImagePicker();
List<XFile> results;
if (allowMultiple) {
results = await picker.pickMultiImage();
} else {
final XFile? result = await picker.pickImage(
source: ImageSource.gallery,
);
results = result != null ? [result] : [];
}
if (results.isEmpty) {
if (context.mounted) hideLoadingModal(context);
return;
}
final newFiles = results
.map(
(xfile) =>
UniversalFile(data: xfile, type: UniversalFileType.image),
)
.toList();
if (!allowMultiple) {
files.value = newFiles;
if (context.mounted) {
hideLoadingModal(context);
startUpload();
}
return;
}
files.value = [...files.value, ...newFiles];
if (context.mounted) hideLoadingModal(context);
}
void pickVideo() async {
showLoadingModal(context);
final result = await FilePicker.platform.pickFiles(
allowMultiple: allowMultiple,
type: FileType.video,
);
if (result == null || result.files.isEmpty) {
if (context.mounted) hideLoadingModal(context);
return;
}
final newFiles = result.files.map((e) {
final xfile = e.bytes != null
? XFile.fromData(e.bytes!, name: e.name)
: XFile(e.path!);
return UniversalFile(data: xfile, type: UniversalFileType.video);
}).toList();
if (!allowMultiple) {
files.value = newFiles;
if (context.mounted) {
hideLoadingModal(context);
startUpload();
}
return;
}
files.value = [...files.value, ...newFiles];
if (context.mounted) hideLoadingModal(context);
}
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.5,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
child: Row(
children: [
Text(
'pickFile'.tr(),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
),
const Spacer(),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => Navigator.pop(context),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
),
],
),
),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
child: Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (uploadOverallProgress != null)
Column(
spacing: 6,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('uploadingProgress')
.tr(
args: [
((uploadPosition.value ?? 0) + 1).toString(),
files.value.length.toString(),
],
)
.opacity(0.85),
LinearProgressIndicator(
value: uploadOverallProgress,
color: Theme.of(context).colorScheme.primary,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceVariant,
),
],
),
if (files.value.isNotEmpty)
Align(
alignment: Alignment.centerLeft,
child: ElevatedButton.icon(
onPressed: startUpload,
icon: const Icon(Symbols.play_arrow),
label: Text('uploadAll'.tr()),
),
),
if (files.value.isNotEmpty)
SizedBox(
height: 280,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: files.value.length,
itemBuilder: (context, idx) {
return AttachmentPreview(
onDelete: uploadOverallProgress != null
? null
: () {
files.value = [
...files.value.where(
(e) => e != files.value[idx],
),
];
},
item: files.value[idx],
progress: null,
);
},
separatorBuilder: (_, _) => const Gap(8),
),
),
Card(
color: Theme.of(context).colorScheme.surfaceContainer,
margin: EdgeInsets.zero,
child: Column(
children: [
if (allowedTypes.contains(UniversalFileType.image))
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
leading: const Icon(Symbols.photo),
title: Text('addPhoto'.tr()),
onTap: () => pickImage(),
),
if (allowedTypes.contains(UniversalFileType.video))
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
leading: const Icon(Symbols.video_call),
title: Text('addVideo'.tr()),
onTap: () => pickVideo(),
),
if (allowedTypes.contains(UniversalFileType.file))
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
leading: const Icon(Symbols.draft),
title: Text('addFile'.tr()),
onTap: () => pickFile(),
),
],
),
),
],
).padding(all: 24),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,122 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:island/polls/polls_widgets/poll/poll_submit.dart';
import 'package:island/posts/posts_models/embed.dart';
import 'package:island/core/widgets/content/embed/link.dart';
import 'package:island/wallet/wallet_widgets/wallet/fund_envelope.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
class EmbedListWidget extends StatelessWidget {
final List<dynamic> embeds;
final bool isInteractive;
final bool isFullPost;
final EdgeInsets renderingPadding;
final double? maxWidth;
const EmbedListWidget({
super.key,
required this.embeds,
this.isInteractive = true,
this.isFullPost = false,
this.renderingPadding = EdgeInsets.zero,
this.maxWidth,
});
@override
Widget build(BuildContext context) {
final linkEmbeds = embeds.where((e) => e['type'] == 'link').toList();
final otherEmbeds = embeds.where((e) => e['type'] != 'link').toList();
return Column(
children: [
if (linkEmbeds.isNotEmpty)
Container(
margin: EdgeInsets.only(
top: 8,
left: renderingPadding.horizontal,
right: renderingPadding.horizontal,
),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor.withOpacity(0.5),
),
borderRadius: BorderRadius.circular(8),
),
child: Theme(
data: Theme.of(
context,
).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
initiallyExpanded: true,
dense: true,
leading: const Icon(Symbols.link),
title: Text('embedLinks'.plural(linkEmbeds.length)),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: linkEmbeds.length == 1
? EmbedLinkWidget(
link: SnScrappedLink.fromJson(linkEmbeds.first),
)
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: linkEmbeds
.map(
(embedData) => EmbedLinkWidget(
link: SnScrappedLink.fromJson(embedData),
maxWidth:
200, // Fixed width for horizontal scroll
margin: const EdgeInsets.symmetric(
horizontal: 4,
),
),
)
.toList(),
),
),
),
],
),
),
),
...otherEmbeds.map(
(embedData) => switch (embedData['type']) {
'poll' => Card(
margin: EdgeInsets.symmetric(
horizontal: renderingPadding.horizontal,
vertical: 8,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: embedData['id'] == null
? const Text('Poll was unavailable...')
: PollSubmit(
pollId: embedData['id'],
onSubmit: (_) {},
isReadonly: !isInteractive,
isInitiallyExpanded: isFullPost,
),
),
),
'fund' =>
embedData['id'] == null
? const Text('Fund envelope was unavailable...')
: FundEnvelopeWidget(
fundId: embedData['id'],
margin: EdgeInsets.symmetric(
horizontal: renderingPadding.horizontal,
vertical: 8,
),
),
_ => Text('Unable show embed: ${embedData['type']}'),
},
),
],
);
}
}

View File

@@ -0,0 +1,313 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:island/posts/posts_models/embed.dart';
import 'package:island/core/widgets/content/image.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:url_launcher/url_launcher.dart';
class EmbedLinkWidget extends StatefulWidget {
final SnScrappedLink link;
final double? maxWidth;
final EdgeInsetsGeometry? margin;
const EmbedLinkWidget({
super.key,
required this.link,
this.maxWidth,
this.margin,
});
@override
State<EmbedLinkWidget> createState() => _EmbedLinkWidgetState();
}
class _EmbedLinkWidgetState extends State<EmbedLinkWidget> {
bool? _isSquare;
@override
void initState() {
super.initState();
_checkIfSquare();
}
Future<void> _checkIfSquare() async {
if (widget.link.imageUrl == null ||
widget.link.imageUrl!.isEmpty ||
widget.link.imageUrl == widget.link.faviconUrl) {
return;
}
try {
final image = CachedNetworkImageProvider(widget.link.imageUrl!);
final ImageStream stream = image.resolve(ImageConfiguration.empty);
final completer = Completer<ImageInfo>();
final listener = ImageStreamListener((
ImageInfo info,
bool synchronousCall,
) {
completer.complete(info);
});
stream.addListener(listener);
final info = await completer.future;
stream.removeListener(listener);
final aspectRatio = info.image.width / info.image.height;
if (mounted) {
setState(() {
_isSquare = aspectRatio >= 0.9 && aspectRatio <= 1.1;
});
}
} catch (e) {
// If error, assume not square
if (mounted) {
setState(() {
_isSquare = false;
});
}
}
}
Future<void> _launchUrl() async {
final uri = Uri.parse(widget.link.url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
String _getBaseUrl(String url) {
final uri = Uri.parse(url);
final port = uri.port;
final defaultPort = uri.scheme == 'https' ? 443 : 80;
final portString = port != defaultPort ? ':$port' : '';
return '${uri.scheme}://${uri.host}$portString';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
width: widget.maxWidth,
margin: widget.margin ?? const EdgeInsets.symmetric(vertical: 8),
child: Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: _launchUrl,
child: Row(
children: [
// Sqaure open graph image
if (_isSquare == true) ...[
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 120),
child: AspectRatio(
aspectRatio: 1,
child: UniversalImage(
uri: widget.link.imageUrl!,
fit: BoxFit.cover,
),
),
),
const Gap(8),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Preview Image
if (widget.link.imageUrl != null &&
widget.link.imageUrl!.isNotEmpty &&
widget.link.imageUrl != widget.link.faviconUrl &&
_isSquare != true)
Container(
width: double.infinity,
color: Theme.of(
context,
).colorScheme.surfaceContainerHigh,
child: IntrinsicHeight(
child: UniversalImage(
uri: widget.link.imageUrl!,
fit: BoxFit.cover,
useFallbackImage: false,
),
),
),
// Content
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Site info row
Row(
children: [
if (widget.link.faviconUrl?.isNotEmpty ??
false) ...[
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: UniversalImage(
uri:
widget.link.faviconUrl!.startsWith('//')
? 'https:${widget.link.faviconUrl!}'
: widget.link.faviconUrl!.startsWith(
'/',
)
? _getBaseUrl(widget.link.url) +
widget.link.faviconUrl!
: widget.link.faviconUrl!,
width: 16,
height: 16,
fit: BoxFit.cover,
useFallbackImage: false,
),
),
const Gap(8),
] else ...[
Icon(
Symbols.link,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const Gap(8),
],
// Site name
Expanded(
child: Text(
(widget.link.siteName?.isNotEmpty ?? false)
? widget.link.siteName!
: Uri.parse(widget.link.url).host,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// External link icon
Icon(
Symbols.open_in_new,
size: 16,
color: colorScheme.onSurfaceVariant,
),
],
),
const Gap(8),
// Title
if (widget.link.title?.isNotEmpty ?? false) ...[
Text(
widget.link.title!,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: _isSquare == true ? 1 : 2,
overflow: TextOverflow.ellipsis,
),
Gap(_isSquare == true ? 2 : 4),
],
// Description
if (widget.link.description != null &&
widget.link.description!.isNotEmpty) ...[
Text(
widget.link.description!,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: _isSquare == true ? 1 : 3,
overflow: TextOverflow.ellipsis,
),
Gap(_isSquare == true ? 4 : 8),
],
// URL
Text(
widget.link.url,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
decoration: TextDecoration.underline,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Author and publish date
if (widget.link.author != null ||
widget.link.publishedDate != null) ...[
const Gap(8),
Row(
children: [
if (widget.link.author != null) ...[
Icon(
Symbols.person,
size: 14,
color: colorScheme.onSurfaceVariant,
),
const Gap(4),
Text(
widget.link.author!,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
if (widget.link.author != null &&
widget.link.publishedDate != null)
const Gap(16),
if (widget.link.publishedDate != null) ...[
Icon(
Symbols.schedule,
size: 14,
color: colorScheme.onSurfaceVariant,
),
const Gap(4),
Text(
_formatDate(widget.link.publishedDate!),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
],
),
],
],
),
),
],
),
),
],
),
),
),
);
}
String _formatDate(DateTime date) {
try {
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return 'Today';
} else if (difference.inDays == 1) {
return 'Yesterday';
} else if (difference.inDays < 7) {
return '${difference.inDays} days ago';
} else {
return '${date.day}/${date.month}/${date.year}';
}
} catch (e) {
return date.toString();
}
}
}

View File

@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:material_symbols_icons/symbols.dart';
class ExifInfoOverlay extends StatelessWidget {
final SnCloudFile item;
const ExifInfoOverlay({super.key, required this.item});
static bool precheck(SnCloudFile item) {
final exifData = item.fileMeta?['exif'] as Map<String, dynamic>? ?? {};
if (exifData.isEmpty) return false;
final dateTime = exifData['ifd0-DateTime'];
final model = exifData['ifd0-Model'];
final iso = exifData['ifd2-ISOSpeedRatings'];
final fnumber = exifData['ifd2-FNumber'];
final exposureTime = exifData['ifd2-ExposureTime'];
final focalLength = exifData['ifd2-FocalLength'];
return (dateTime != null && dateTime.isNotEmpty) ||
(model != null && model.isNotEmpty) ||
iso != null ||
fnumber != null ||
exposureTime != null ||
focalLength != null;
}
bool _isPreferredValue(String key, String value) {
if ([
'ExposureTime',
'FNumber',
'FocalLength',
'ApertureValue',
'DateTime',
].contains(key)) {
return true;
}
return false;
}
String _formatExifValue(String key, String value) {
final lastOpen = value.lastIndexOf('(');
final lastClose = value.endsWith(')') ? value.length - 1 : -1;
if (lastOpen == -1 || lastClose == -1 || lastOpen > lastClose) {
return value;
}
final inside = value.substring(lastOpen + 1, lastClose);
final commaIndex = inside.indexOf(',');
if (commaIndex != -1) {
final candidate = inside.substring(0, commaIndex).trim();
if (_isPreferredValue(key, candidate)) {
return candidate;
}
}
if (lastOpen == -1) {
return value;
}
return value.substring(0, lastOpen).trimRight();
}
@override
Widget build(BuildContext context) {
final exifData = item.fileMeta?['exif'] as Map<String, dynamic>? ?? {};
if (exifData.isEmpty) return const SizedBox.shrink();
final dateTime = exifData['ifd0-DateTime'];
final model = exifData['ifd0-Model'];
final iso = exifData['ifd2-ISOSpeedRatings'];
final fnumber = exifData['ifd2-FNumber'];
final exposureTime = exifData['ifd2-ExposureTime'];
final focalLength = exifData['ifd2-FocalLength'];
final items = <Widget>[];
if (dateTime != null && dateTime.isNotEmpty) {
items.add(_buildExifItem('DateTime', dateTime, Symbols.calendar_check));
}
if (model != null && model.isNotEmpty) {
items.add(_buildExifItem('Model', model, Symbols.camera_alt));
}
if (iso != null) {
items.add(_buildExifItem('ISO', iso, Icons.iso));
}
if (fnumber != null) {
items.add(_buildExifItem('FNumber', fnumber, Symbols.camera_enhance));
}
if (exposureTime != null) {
items.add(
_buildExifItem('ExposureTime', exposureTime, Icons.shutter_speed),
);
}
if (focalLength != null) {
items.add(
_buildExifItem(
'FocalLength',
focalLength,
Symbols.photo_size_select_large,
),
);
}
if (items.isEmpty) return const SizedBox.shrink();
return Material(
color: Colors.transparent,
child: Container(
margin: const EdgeInsets.only(bottom: 16),
child: Wrap(
alignment: WrapAlignment.end,
children: items
.map(
(item) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: item,
),
)
.toList(),
),
),
);
}
Widget _buildExifItem(String key, String value, IconData icon) {
final formattedValue = _formatExifValue(key, value);
final shadow = [
Shadow(
color: Colors.black54,
blurRadius: 5.0,
offset: const Offset(1.0, 1.0),
),
];
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: Colors.white70, shadows: shadow),
const SizedBox(width: 6),
Flexible(
child: Text(
formattedValue,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
shadows: shadow,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
enum FileActionType { save, info, more, close, custom }
class FileActionButton extends StatelessWidget {
final FileActionType type;
final IconData? icon;
final VoidCallback onPressed;
final Color? color;
final List<Shadow>? shadows;
final String? tooltip;
const FileActionButton({
super.key,
required this.type,
required this.onPressed,
this.icon,
this.color,
this.shadows,
this.tooltip,
});
factory FileActionButton.save({
Key? key,
required VoidCallback onPressed,
Color? color,
List<Shadow>? shadows,
}) {
return FileActionButton(
key: key,
type: FileActionType.save,
icon: Icons.save_alt,
onPressed: onPressed,
color: color ?? Colors.white,
shadows: shadows,
);
}
factory FileActionButton.info({
Key? key,
required VoidCallback onPressed,
Color? color,
List<Shadow>? shadows,
}) {
return FileActionButton(
key: key,
type: FileActionType.info,
icon: Icons.info_outline,
onPressed: onPressed,
color: color ?? Colors.white,
shadows: shadows,
);
}
factory FileActionButton.more({
Key? key,
required VoidCallback onPressed,
Color? color,
List<Shadow>? shadows,
}) {
return FileActionButton(
key: key,
type: FileActionType.more,
icon: Icons.more_horiz,
onPressed: onPressed,
color: color ?? Colors.white,
shadows: shadows,
);
}
factory FileActionButton.close({
Key? key,
required VoidCallback onPressed,
Color? color,
List<Shadow>? shadows,
}) {
return FileActionButton(
key: key,
type: FileActionType.close,
icon: Icons.close,
onPressed: onPressed,
color: color ?? Colors.white,
shadows: shadows,
);
}
@override
Widget build(BuildContext context) {
final buttonIcon = icon ?? Icons.circle;
final button = IconButton(
icon: Icon(buttonIcon, color: color, shadows: shadows),
onPressed: onPressed,
);
if (tooltip != null) {
return Tooltip(message: tooltip!, child: button);
}
return button;
}
}
class WhiteShadows {
static List<Shadow> get standard => [
Shadow(color: Colors.black54, blurRadius: 5.0, offset: Offset(1.0, 1.0)),
];
}

View File

@@ -0,0 +1,291 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/core/utils/format.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
class FileInfoSheet extends StatelessWidget {
final SnCloudFile item;
final VoidCallback? onClose;
const FileInfoSheet({super.key, required this.item, this.onClose});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final exifData = item.fileMeta?['exif'] as Map<String, dynamic>? ?? {};
return SheetScaffold(
onClose: onClose,
titleText: 'fileInfoTitle'.tr(),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text('mimeType').tr(),
Text(
item.mimeType ?? 'unknown'.tr(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
SizedBox(height: 28, child: const VerticalDivider()),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text('fileSize').tr(),
Text(
formatFileSize(item.size),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
if (item.hash != null)
SizedBox(height: 28, child: const VerticalDivider()),
if (item.hash != null)
Expanded(
child: GestureDetector(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text('fileHash').tr(),
Text(
'${item.hash!.substring(0, 6)}...',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
onLongPress: () {
Clipboard.setData(ClipboardData(text: item.hash!));
showSnackBar('fileHashCopied'.tr());
},
),
),
],
).padding(horizontal: 24, vertical: 16),
const Divider(height: 1),
ListTile(
leading: const Icon(Symbols.tag),
title: Text('ID').tr(),
subtitle: Text(
item.id,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
trailing: IconButton(
icon: const Icon(Icons.copy),
onPressed: () {
Clipboard.setData(ClipboardData(text: item.id));
showSnackBar('fileIdCopied'.tr());
},
),
),
ListTile(
leading: const Icon(Symbols.file_present),
title: Text('Name').tr(),
subtitle: Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
trailing: IconButton(
icon: const Icon(Icons.copy),
onPressed: () {
Clipboard.setData(ClipboardData(text: item.name));
showSnackBar('fileNameCopied'.tr());
},
),
),
ListTile(
leading: const Icon(Symbols.launch),
title: Text('openInBrowser').tr(),
subtitle: Text('https://solian.app/files/${item.id}'),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
onTap: () {
launchUrlString(
'https://solian.app/files/${item.id}',
mode: LaunchMode.externalApplication,
);
},
),
if (exifData.isNotEmpty) ...[
const Divider(height: 1),
Theme(
data: theme.copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(
'exifData'.tr(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...exifData.entries.map(
(entry) => ListTile(
dense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 24,
),
title: Text(
entry.key.contains('-')
? entry.key.split('-').last
: entry.key,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
).bold(),
subtitle: Text(
'${entry.value}'.isNotEmpty
? '${entry.value}'
: 'N/A',
style: theme.textTheme.bodyMedium,
),
onTap: () {
Clipboard.setData(
ClipboardData(text: '${entry.value}'),
);
showSnackBar('valueCopied'.tr());
},
),
),
],
),
],
),
),
],
if (item.fileMeta != null && item.fileMeta!.isNotEmpty) ...[
const Divider(height: 1),
Theme(
data: theme.copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(
'fileMetadata'.tr(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...item.fileMeta!.entries.map(
(entry) => ListTile(
dense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 24,
),
title: Text(
entry.key,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
).bold(),
subtitle: Text(
jsonEncode(entry.value),
style: theme.textTheme.bodyMedium,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
onTap: () {
Clipboard.setData(
ClipboardData(text: jsonEncode(entry.value)),
);
showSnackBar('valueCopied'.tr());
},
),
),
],
),
],
),
),
],
if (item.userMeta != null && item.userMeta!.isNotEmpty) ...[
const Divider(height: 1),
Theme(
data: theme.copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(
'userMetadata'.tr(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...item.userMeta!.entries.map(
(entry) => ListTile(
dense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 24,
),
title: Text(
entry.key,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
).bold(),
subtitle: Text(
jsonEncode(entry.value),
style: theme.textTheme.bodyMedium,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
onTap: () {
Clipboard.setData(
ClipboardData(text: jsonEncode(entry.value)),
);
showSnackBar('valueCopied'.tr());
},
),
),
],
),
],
),
),
],
const SizedBox(height: 16),
],
),
),
);
}
}

View File

@@ -0,0 +1,312 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/core/config.dart';
import 'package:island/core/network.dart';
import 'package:island/drive/drive_service.dart';
import 'package:island/core/utils/format.dart';
import 'package:island/core/widgets/content/audio.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/core/widgets/content/exif_info_overlay.dart';
import 'package:island/core/widgets/content/file_info_sheet.dart';
import 'package:island/core/widgets/content/image_control_overlay.dart';
import 'package:island/core/widgets/content/video.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:photo_view/photo_view.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
class PdfFileContent extends HookConsumerWidget {
final String uri;
const PdfFileContent({required this.uri, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fileFuture = useMemoized(
() => DefaultCacheManager().getSingleFile(uri),
[uri],
);
final pdfController = useMemoized(() => PdfViewerController(), []);
final shadow = [
Shadow(color: Colors.black54, blurRadius: 5.0, offset: Offset(1.0, 1.0)),
];
return FutureBuilder<File>(
future: fileFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(child: Text('Error loading PDF: ${snapshot.error}'));
} else if (snapshot.hasData) {
return Stack(
children: [
SfPdfViewer.file(snapshot.data!, controller: pdfController),
// Controls overlay
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 16,
left: 16,
right: 16,
child: Row(
children: [
IconButton(
icon: Icon(
Icons.remove,
color: Colors.white,
shadows: shadow,
),
onPressed: () {
pdfController.zoomLevel = pdfController.zoomLevel * 0.9;
},
),
IconButton(
icon: Icon(
Icons.add,
color: Colors.white,
shadows: shadow,
),
onPressed: () {
pdfController.zoomLevel = pdfController.zoomLevel * 1.1;
},
),
],
),
),
],
);
}
return const Center(child: Text('No PDF data'));
},
);
}
}
class TextFileContent extends HookConsumerWidget {
final String uri;
const TextFileContent({required this.uri, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final textFuture = useMemoized(
() => ref
.read(apiClientProvider)
.get(uri)
.then((response) => response.data as String),
[uri],
);
return FutureBuilder<String>(
future: textFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(child: Text('Error loading text: ${snapshot.error}'));
} else if (snapshot.hasData) {
return SingleChildScrollView(
padding: EdgeInsets.all(20),
child: SelectableText(
snapshot.data!,
style: const TextStyle(fontFamily: 'monospace', fontSize: 14),
),
);
}
return const Center(child: Text('No content'));
},
);
}
}
class ImageFileContent extends HookConsumerWidget {
final SnCloudFile item;
final String uri;
const ImageFileContent({required this.item, required this.uri, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final photoViewController = useMemoized(() => PhotoViewController(), []);
final rotation = useState(0);
final hasExifData = ExifInfoOverlay.precheck(item);
final showOriginal = useState(false);
final showExif = useState(hasExifData);
return Stack(
children: [
Positioned.fill(
child: Listener(
onPointerSignal: (pointerSignal) {
try {
// Handle mouse wheel zoom - cast to dynamic to access scrollDelta
final delta =
(pointerSignal as dynamic).scrollDelta.dy as double?;
if (delta != null && delta != 0) {
final currentScale = photoViewController.scale ?? 1.0;
// Adjust scale based on scroll direction (invert for natural zoom)
final newScale = delta > 0
? currentScale * 0.9
: currentScale * 1.1;
// Clamp scale to reasonable bounds
final clampedScale = newScale.clamp(0.1, 10.0);
photoViewController.scale = clampedScale;
}
} catch (e) {
// Ignore non-scroll events
}
},
child: PhotoView(
backgroundDecoration: BoxDecoration(
color: Colors.black.withOpacity(0.9),
),
controller: photoViewController,
imageProvider: CloudImageWidget.provider(
file: item,
serverUrl: ref.watch(serverUrlProvider),
original: showOriginal.value,
),
customSize: MediaQuery.of(context).size,
basePosition: Alignment.center,
filterQuality: FilterQuality.high,
),
),
),
if (showExif.value)
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 60,
left: 16,
right: 16,
child: ExifInfoOverlay(item: item),
),
ImageControlOverlay(
photoViewController: photoViewController,
rotation: rotation,
showOriginal: showOriginal.value,
onToggleQuality: () {
showOriginal.value = !showOriginal.value;
},
showExifInfo: showExif.value,
onToggleExif: () {
showExif.value = !showExif.value;
},
hasExifData: hasExifData,
),
],
);
}
}
class VideoFileContent extends HookConsumerWidget {
final SnCloudFile item;
final String uri;
const VideoFileContent({required this.item, required this.uri, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
var ratio = item.fileMeta?['ratio'] is num
? item.fileMeta!['ratio'].toDouble()
: 1.0;
if (ratio == 0) ratio = 1.0;
return Center(
child: AspectRatio(
aspectRatio: ratio,
child: UniversalVideo(uri: uri, autoplay: true),
),
);
}
}
class AudioFileContent extends HookConsumerWidget {
final SnCloudFile item;
final String uri;
const AudioFileContent({required this.item, required this.uri, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8),
),
child: UniversalAudio(uri: uri, filename: item.name),
),
);
}
}
class GenericFileContent extends HookConsumerWidget {
final SnCloudFile item;
const GenericFileContent({required this.item, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.insert_drive_file,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const Gap(16),
Text(
item.name,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const Gap(8),
Text(
formatFileSize(item.size),
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const Gap(24),
Row(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton.icon(
onPressed: () => FileDownloadService(ref).downloadFile(item),
icon: const Icon(Symbols.download),
label: Text('download').tr(),
),
const Gap(16),
OutlinedButton.icon(
onPressed: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder: (context) => FileInfoSheet(item: item),
);
},
icon: const Icon(Symbols.info),
label: Text('info').tr(),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,319 @@
import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/core/config.dart';
import 'package:island/core/network.dart';
class UniversalImage extends HookConsumerWidget {
final String uri;
final String? blurHash;
final BoxFit fit;
final double? width;
final double? height;
final bool noCacheOptimization;
final bool isSvg;
final bool useFallbackImage;
const UniversalImage({
super.key,
required this.uri,
this.blurHash,
this.fit = BoxFit.cover,
this.width,
this.height,
this.noCacheOptimization = false,
this.isSvg = false,
this.useFallbackImage = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final loaded = useState(false);
final isCached = useState<bool?>(null);
final isSvgImage = isSvg || uri.toLowerCase().endsWith('.svg');
final serverUrl = ref.watch(serverUrlProvider);
final token = ref.watch(tokenProvider);
final Map<String, String>? httpHeaders =
uri.startsWith(serverUrl) && token != null
? {'Authorization': 'AtField ${token.token}'}
: null;
useEffect(() {
DefaultCacheManager().getFileFromCache(uri).then((fileInfo) {
isCached.value = fileInfo != null;
});
return null;
}, [uri]);
if (isSvgImage) {
return SvgPicture.network(
uri,
fit: fit,
width: width,
height: height,
placeholderBuilder: (BuildContext context) =>
Center(child: CircularProgressIndicator()),
);
}
int? cacheWidth;
int? cacheHeight;
if (width != null && height != null && !noCacheOptimization) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
cacheWidth = width != null ? (width! * devicePixelRatio).round() : null;
cacheHeight = height != null
? (height! * devicePixelRatio).round()
: null;
}
return SizedBox(
width: width,
height: height,
child: Stack(
fit: StackFit.expand,
children: [
if (blurHash != null) BlurHash(hash: blurHash!),
if (isCached.value == null)
Center(child: CircularProgressIndicator())
else if (isCached.value!)
CachedNetworkImage(
imageUrl: uri,
httpHeaders: httpHeaders,
fit: fit,
width: width,
height: height,
memCacheHeight: cacheHeight,
memCacheWidth: cacheWidth,
imageBuilder: (context, imageProvider) => Image(
image: imageProvider,
fit: fit,
width: width,
height: height,
),
errorWidget: (context, url, error) => CachedImageErrorWidget(
useFallbackImage: useFallbackImage,
uri: uri,
blurHash: blurHash,
error: error,
debug: true,
),
)
else
CachedNetworkImage(
imageUrl: uri,
httpHeaders: httpHeaders,
fit: fit,
width: width,
height: height,
memCacheHeight: cacheHeight,
memCacheWidth: cacheWidth,
progressIndicatorBuilder: (context, url, progress) {
return Center(
child: AnimatedCircularProgressIndicator(
value: progress.progress,
color: Colors.white.withOpacity(0.5),
),
);
},
imageBuilder: (context, imageProvider) {
Future(() {
if (context.mounted) loaded.value = true;
});
return AnimatedOpacity(
opacity: loaded.value ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Image(
image: imageProvider,
fit: fit,
width: width,
height: height,
),
);
},
errorWidget: (context, url, error) => CachedImageErrorWidget(
useFallbackImage: useFallbackImage,
uri: uri,
blurHash: blurHash,
error: error,
debug: true,
),
),
],
),
);
}
}
class CachedImageErrorWidget extends StatelessWidget {
final bool useFallbackImage;
final String uri;
final String? blurHash;
final dynamic error;
final bool debug;
const CachedImageErrorWidget({
super.key,
required this.useFallbackImage,
required this.uri,
this.blurHash,
this.error,
this.debug = false,
});
int? _extractStatusCode(dynamic error) {
if (error == null) return null;
final errorString = error.toString();
// Check for HttpException with status code
final httpExceptionRegex = RegExp(r'Invalid statusCode: (\d+)');
final match = httpExceptionRegex.firstMatch(errorString);
if (match != null) {
return int.tryParse(match.group(1) ?? '');
}
// Check if error has statusCode property (like DioError)
if (error.response?.statusCode != null) {
return error.response.statusCode;
}
return null;
}
@override
Widget build(BuildContext context) {
if (debug && error != null) {
debugPrint('Image load error for $uri: $error');
}
if (!useFallbackImage) {
return SizedBox.shrink();
}
final statusCode = _extractStatusCode(error);
return LayoutBuilder(
builder: (context, constraints) {
final minDimension = constraints.maxWidth < constraints.maxHeight
? constraints.maxWidth
: constraints.maxHeight;
final iconSize = math.max(
minDimension * 0.3,
28,
); // 30% of the smaller dimension
final hasEnoughSpace = minDimension > 40;
return Stack(
fit: StackFit.expand,
children: [
if (blurHash != null)
BlurHash(hash: blurHash!)
else
Image.asset(
'assets/images/media-offline.jpg',
fit: BoxFit.cover,
key: Key('-$uri'),
),
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getErrorIcon(statusCode),
color: Colors.white,
size: iconSize * 0.5,
shadows: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
if (hasEnoughSpace && statusCode != null) ...[
SizedBox(height: iconSize * 0.1),
Container(
padding: EdgeInsets.symmetric(
horizontal: iconSize * 0.15,
vertical: iconSize * 0.05,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(iconSize * 0.1),
),
child: Text(
statusCode.toString(),
style: TextStyle(
color: Colors.white,
fontSize: iconSize * 0.15,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
),
],
);
},
);
}
IconData _getErrorIcon(int? statusCode) {
switch (statusCode) {
case 403:
case 401:
return Icons.lock_rounded;
case 404:
return Icons.broken_image_rounded;
case 500:
case 502:
case 503:
return Icons.error_rounded;
default:
return Icons.broken_image_rounded;
}
}
}
class AnimatedCircularProgressIndicator extends HookWidget {
final double? value;
final Color? color;
final double strokeWidth;
final Duration duration;
const AnimatedCircularProgressIndicator({
super.key,
this.value,
this.color,
this.strokeWidth = 4.0,
this.duration = const Duration(milliseconds: 200),
});
@override
Widget build(BuildContext context) {
final animationController = useAnimationController(duration: duration);
final animation = useAnimation(
Tween<double>(begin: 0.0, end: value ?? 0.0).animate(
CurvedAnimation(parent: animationController, curve: Curves.linear),
),
);
useEffect(() {
animationController.animateTo(value ?? 0.0);
return null;
}, [value]);
return CircularProgressIndicator(
value: animation,
color: color,
strokeWidth: strokeWidth,
backgroundColor: Colors.transparent,
);
}
}

View File

@@ -0,0 +1,107 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:photo_view/photo_view.dart';
import 'package:material_symbols_icons/symbols.dart';
class ImageControlOverlay extends HookWidget {
final PhotoViewController photoViewController;
final ValueNotifier<int> rotation;
final bool showOriginal;
final VoidCallback onToggleQuality;
final List<Widget>? extraButtons;
final bool showExtraOnLeft;
final bool showExifInfo;
final VoidCallback onToggleExif;
final bool hasExifData;
const ImageControlOverlay({
super.key,
required this.photoViewController,
required this.rotation,
required this.showOriginal,
required this.onToggleQuality,
this.extraButtons,
this.showExtraOnLeft = false,
this.showExifInfo = false,
required this.onToggleExif,
this.hasExifData = true,
});
@override
Widget build(BuildContext context) {
final shadow = [
Shadow(
color: Colors.black54,
blurRadius: 5.0,
offset: const Offset(1.0, 1.0),
),
];
final controlButtons = [
IconButton(
icon: Icon(Icons.remove, color: Colors.white, shadows: shadow),
onPressed: () {
photoViewController.scale = (photoViewController.scale ?? 1) - 0.05;
},
),
IconButton(
icon: Icon(Icons.add, color: Colors.white, shadows: shadow),
onPressed: () {
photoViewController.scale = (photoViewController.scale ?? 1) + 0.05;
},
),
const Gap(8),
IconButton(
icon: Icon(Icons.rotate_left, color: Colors.white, shadows: shadow),
onPressed: () {
rotation.value = (rotation.value - 1) % 4;
photoViewController.rotation = rotation.value * -math.pi / 2;
},
),
IconButton(
icon: Icon(Icons.rotate_right, color: Colors.white, shadows: shadow),
onPressed: () {
rotation.value = (rotation.value + 1) % 4;
photoViewController.rotation = rotation.value * -math.pi / 2;
},
),
if (hasExifData) ...[
const Gap(8),
IconButton(
icon: Icon(
showExifInfo ? Icons.visibility : Icons.visibility_off,
color: Colors.white,
shadows: shadow,
),
onPressed: onToggleExif,
),
],
];
return Positioned(
bottom: MediaQuery.of(context).padding.bottom + 16,
left: 16,
right: 16,
child: Row(
children: showExtraOnLeft
? [...?extraButtons, const Spacer(), ...controlButtons]
: [
...controlButtons,
const Spacer(),
IconButton(
onPressed: onToggleQuality,
icon: Icon(
showOriginal ? Symbols.hd : Symbols.sd,
color: Colors.white,
shadows: shadow,
),
),
...?extraButtons,
],
),
);
}
}

View File

@@ -0,0 +1,727 @@
import 'package:collection/collection.dart';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_highlight/themes/a11y-dark.dart';
import 'package:flutter_highlight/themes/a11y-light.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/accounts/account/profile.dart';
import 'package:island/accounts/accounts_models/account.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/posts/posts_models/publisher.dart';
import 'package:island/core/config.dart';
import 'package:island/posts/publisher_profile.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/core/widgets/content/cloud_file_lightbox.dart';
import 'package:island/core/widgets/content/markdown_latex.dart';
import 'package:markdown/markdown.dart' as markdown;
import 'package:markdown_widget/markdown_widget.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:uuid/uuid.dart';
import 'image.dart';
class MarkdownTextContent extends HookConsumerWidget {
static const String stickerRegex = r':([-\w]*\+[-\w]*):';
final String content;
final bool isAutoWarp;
final TextStyle? textStyle;
final TextStyle? linkStyle;
final EdgeInsets? linesMargin;
final bool isSelectable;
final List<SnCloudFile>? attachments;
final List<markdown.InlineSyntax> extraInlineSyntaxList;
final List<markdown.BlockSyntax> extraBlockSyntaxList;
final List<dynamic> extraGenerators;
final bool noMentionChip;
const MarkdownTextContent({
super.key,
required this.content,
this.isAutoWarp = false,
this.textStyle,
this.linkStyle,
this.isSelectable = false,
this.linesMargin,
this.attachments,
this.extraInlineSyntaxList = const [],
this.extraBlockSyntaxList = const [],
this.extraGenerators = const [],
this.noMentionChip = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final doesEnlargeSticker = useMemoized(() {
// Check if content only contains one sticker by matching the sticker pattern
final stickerPattern = RegExp(stickerRegex);
final matches = stickerPattern.allMatches(content);
// Content should only contain one sticker and nothing else (except whitespace)
final contentWithoutStickers = content
.replaceAll(stickerPattern, '')
.trim();
return matches.length == 1 && contentWithoutStickers.isEmpty;
}, [content]);
final isDark = Theme.of(context).brightness == Brightness.dark;
final config = isDark
? MarkdownConfig.darkConfig
: MarkdownConfig.defaultConfig;
final onMentionTap = useCallback((String type, String id) {
final fullPath = '/$type/$id';
context.push(fullPath);
}, [context]);
final mentionGenerator = MentionChipGenerator(
backgroundColor: Theme.of(context).colorScheme.secondary,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
onTap: onMentionTap,
);
final highlightGenerator = HighlightGenerator(
highlightColor: Theme.of(context).colorScheme.primaryContainer,
);
final spoilerRevealed = useState(false);
final spoilerGenerator = SpoilerGenerator(
backgroundColor: Theme.of(context).colorScheme.tertiary,
foregroundColor: Theme.of(context).colorScheme.onTertiary,
outlineColor: Theme.of(context).colorScheme.outline,
revealed: spoilerRevealed.value,
onToggle: () => spoilerRevealed.value = !spoilerRevealed.value,
);
final baseUrl = ref.watch(serverUrlProvider);
final stickerGenerator = StickerGenerator(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
isEnlarged: doesEnlargeSticker,
baseUrl: baseUrl,
);
return MarkdownBlock(
data: content,
selectable: isSelectable,
config: config.copy(
configs: [
isDark
? PreConfig.darkConfig.copy(textStyle: textStyle)
: PreConfig().copy(textStyle: textStyle),
PConfig(
textStyle: (textStyle ?? Theme.of(context).textTheme.bodyMedium!),
),
HrConfig(height: 1, color: Theme.of(context).dividerColor),
PreConfig(
theme: isDark ? a11yDarkTheme : a11yLightTheme,
textStyle: GoogleFonts.robotoMono(fontSize: 14),
styleNotMatched: GoogleFonts.robotoMono(fontSize: 14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.all(Radius.circular(8.0)),
),
),
TableConfig(
wrapper: (child) => SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: child,
),
),
LinkConfig(
style:
linkStyle ??
TextStyle(color: Theme.of(context).colorScheme.primary),
onTap: (href) async {
final url = Uri.tryParse(href);
if (url != null) {
if (url.scheme == 'solian') {
final fullPath = ['/', url.host, url.path].join('');
context.push(fullPath);
return;
}
await openExternalLink(url, ref);
} else {
showSnackBar(
'brokenLink'.tr(args: [href]),
action: SnackBarAction(
label: 'copyToClipboard'.tr(),
onPressed: () {
Clipboard.setData(ClipboardData(text: href));
},
),
);
}
},
),
ImgConfig(
builder: (url, attributes) {
final uri = Uri.parse(url);
if (uri.scheme == 'solian') {
switch (uri.host) {
case 'files':
final file = attachments?.firstWhereOrNull(
(file) => file.id == uri.pathSegments[0],
);
if (file == null) {
return const SizedBox.shrink();
}
final heroTag = 'cloud-file-markdown#${const Uuid().v4()}';
return InkWell(
onTap: () {
context.pushTransparentRoute(
CloudFileLightbox(item: file, heroTag: heroTag),
rootNavigator: true,
);
},
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
child: Container(
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
),
child: CloudFileWidget(
item: file,
heroTag: heroTag,
fit: BoxFit.cover,
).clipRRect(all: 8),
),
),
);
}
}
final content = ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 360),
child: UniversalImage(
uri: uri.toString(),
fit: BoxFit.contain,
),
),
);
return content;
},
),
],
),
generator: MarkdownTextContent.buildGenerator(
isDark: isDark,
linesMargin: linesMargin,
generators: [
if (!noMentionChip) mentionGenerator,
highlightGenerator,
spoilerGenerator,
stickerGenerator,
...extraGenerators,
],
extraInlineSyntaxList: extraInlineSyntaxList,
extraBlockSyntaxList: extraBlockSyntaxList,
),
);
}
static MarkdownGenerator buildGenerator({
bool isDark = false,
EdgeInsets? linesMargin,
List<dynamic> generators = const [],
List<markdown.InlineSyntax> extraInlineSyntaxList = const [],
List<markdown.BlockSyntax> extraBlockSyntaxList = const [],
}) {
return MarkdownGenerator(
generators: [latexGenerator, ...generators],
inlineSyntaxList: [
_MentionInlineSyntax(),
_HighlightInlineSyntax(),
_SpoilerInlineSyntax(),
_StickerInlineSyntax(),
LatexSyntax(isDark),
...extraInlineSyntaxList,
],
blockSyntaxList: extraBlockSyntaxList,
linesMargin: linesMargin ?? EdgeInsets.symmetric(vertical: 4),
);
}
}
class _MentionInlineSyntax extends markdown.InlineSyntax {
_MentionInlineSyntax() : super(r'(^|[^A-Za-z0-9._%+\-])(@[-A-Za-z0-9_./]+)');
@override
bool onMatch(markdown.InlineParser parser, Match match) {
final prefix = match[1] ?? '';
final alias = match[2]!;
if (prefix.isNotEmpty) {
parser.addNode(markdown.Text(prefix));
}
final parts = alias.substring(1).split('/');
final typeShortcut = parts.length == 1 ? 'u' : parts.first;
final type = switch (typeShortcut) {
'u' => 'accounts',
'r' => 'realms',
'p' => 'publishers',
_ => '',
};
final element = markdown.Element('mention-chip', [markdown.Text(alias)])
..attributes['alias'] = alias
..attributes['type'] = type
..attributes['id'] = parts.last;
parser.addNode(element);
return true;
}
}
class _StickerInlineSyntax extends markdown.InlineSyntax {
_StickerInlineSyntax() : super(MarkdownTextContent.stickerRegex);
@override
bool onMatch(markdown.InlineParser parser, Match match) {
final placeholder = match[1]!;
final element = markdown.Element('sticker', [markdown.Text(placeholder)]);
parser.addNode(element);
return true;
}
}
class _HighlightInlineSyntax extends markdown.InlineSyntax {
_HighlightInlineSyntax() : super(r'==([^=]+)==');
@override
bool onMatch(markdown.InlineParser parser, Match match) {
final text = match[1]!;
final element = markdown.Element('highlight', [markdown.Text(text)]);
parser.addNode(element);
return true;
}
}
class _SpoilerInlineSyntax extends markdown.InlineSyntax {
_SpoilerInlineSyntax() : super(r'=!([^!]+)!=');
@override
bool onMatch(markdown.InlineParser parser, Match match) {
final text = match[1]!;
final element = markdown.Element('spoiler', [markdown.Text(text)]);
parser.addNode(element);
return true;
}
}
class MentionSpanNodeGenerator {
final Color backgroundColor;
final Color foregroundColor;
final void Function(String type, String id) onTap;
MentionSpanNodeGenerator({
required this.backgroundColor,
required this.foregroundColor,
required this.onTap,
});
SpanNode? call(
String tag,
Map<String, String> attributes,
List<SpanNode> children,
) {
if (tag == 'mention-chip') {
return MentionChipSpanNode(
attributes: attributes,
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
onTap: onTap,
);
}
return null;
}
}
class MentionChipGenerator extends SpanNodeGeneratorWithTag {
MentionChipGenerator({
required Color backgroundColor,
required Color foregroundColor,
required void Function(String type, String id) onTap,
}) : super(
tag: 'mention-chip',
generator:
(
markdown.Element element,
MarkdownConfig config,
WidgetVisitor visitor,
) {
return MentionChipSpanNode(
attributes: element.attributes,
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
onTap: onTap,
);
},
);
}
class _MentionChipContent extends HookConsumerWidget {
final String mentionType;
final String id;
final String alias;
final Color backgroundColor;
final Color foregroundColor;
final VoidCallback onTap;
const _MentionChipContent({
required this.mentionType,
required this.id,
required this.alias,
required this.backgroundColor,
required this.foregroundColor,
required this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isHovered = useState(false);
if (mentionType == 'accounts' || mentionType == 'publishers') {
final data = mentionType == 'accounts'
? ref.watch(accountProvider(id))
: ref.watch(publisherProvider(id));
return data.when(
data: (profile) {
final picture = mentionType == 'accounts'
? (profile as SnAccount).profile.picture
: (profile as SnPublisher).picture;
final icon = mentionType == 'accounts'
? Symbols.person_rounded
: Symbols.design_services_rounded;
return _buildChip(
ProfilePictureWidget(file: picture, fallbackIcon: icon, radius: 9),
id,
isHovered,
);
},
error: (_, _) => Text(
alias,
style: TextStyle(
color: backgroundColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
loading: () => Text(
alias,
style: TextStyle(
color: backgroundColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
);
}
return _buildStaticChip(mentionType, id);
}
Widget _buildChip(
Widget avatar,
String displayName,
ValueNotifier<bool> isHovered,
) {
return InkWell(
onTap: onTap,
onHover: (value) => isHovered.value = value,
borderRadius: BorderRadius.circular(32),
child: Container(
padding: const EdgeInsets.only(
left: 5,
right: 7,
top: 2.5,
bottom: 2.5,
),
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: backgroundColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(32),
),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 6,
children: [
Container(
decoration: BoxDecoration(
color: backgroundColor.withOpacity(0.5),
borderRadius: const BorderRadius.all(Radius.circular(32)),
),
child: avatar,
),
Text(
displayName,
style: TextStyle(
color: backgroundColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
Widget _buildStaticChip(String type, String id) {
final icon = switch (type) {
'chat' => Symbols.forum_rounded,
'realms' => Symbols.group_rounded,
_ => Symbols.person_rounded,
};
return _buildChip(
Icon(icon, size: 14, color: foregroundColor, fill: 1).padding(all: 2),
id,
useState(false),
);
}
}
class MentionChipSpanNode extends SpanNode {
final Map<String, String> attributes;
final Color backgroundColor;
final Color foregroundColor;
final void Function(String type, String id) onTap;
MentionChipSpanNode({
required this.attributes,
required this.backgroundColor,
required this.foregroundColor,
required this.onTap,
});
@override
InlineSpan build() {
final alias = attributes['alias'] ?? '';
final type = attributes['type'] ?? '';
final id = attributes['id'] ?? '';
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _MentionChipContent(
mentionType: type,
id: id,
alias: alias,
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
onTap: () => onTap(type, id),
),
);
}
}
class HighlightGenerator extends SpanNodeGeneratorWithTag {
HighlightGenerator({required Color highlightColor})
: super(
tag: 'highlight',
generator:
(
markdown.Element element,
MarkdownConfig config,
WidgetVisitor visitor,
) {
return HighlightSpanNode(
text: element.textContent,
highlightColor: highlightColor,
);
},
);
}
class HighlightSpanNode extends SpanNode {
final String text;
final Color highlightColor;
HighlightSpanNode({required this.text, required this.highlightColor});
@override
InlineSpan build() {
return TextSpan(
text: text,
style: TextStyle(backgroundColor: highlightColor),
);
}
}
class SpoilerGenerator extends SpanNodeGeneratorWithTag {
SpoilerGenerator({
required Color backgroundColor,
required Color foregroundColor,
required Color outlineColor,
required bool revealed,
required VoidCallback onToggle,
}) : super(
tag: 'spoiler',
generator:
(
markdown.Element element,
MarkdownConfig config,
WidgetVisitor visitor,
) {
return SpoilerSpanNode(
text: element.textContent,
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
outlineColor: outlineColor,
revealed: revealed,
onToggle: onToggle,
);
},
);
}
class SpoilerSpanNode extends SpanNode {
final String text;
final Color backgroundColor;
final Color foregroundColor;
final Color outlineColor;
final bool revealed;
final VoidCallback onToggle;
SpoilerSpanNode({
required this.text,
required this.backgroundColor,
required this.foregroundColor,
required this.outlineColor,
required this.revealed,
required this.onToggle,
});
@override
InlineSpan build() {
return WidgetSpan(
child: InkWell(
onTap: onToggle,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: revealed ? Colors.transparent : backgroundColor,
border: revealed ? Border.all(color: outlineColor, width: 1) : null,
borderRadius: BorderRadius.circular(4),
),
child: revealed
? Row(
spacing: 6,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Symbols.visibility, size: 18).padding(top: 1),
Flexible(child: Text(text)),
],
)
: Row(
spacing: 6,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.visibility_off,
color: foregroundColor,
size: 18,
),
Flexible(
child: Text(
'spoiler',
style: TextStyle(color: foregroundColor),
).tr(),
),
],
),
),
),
);
}
}
class StickerGenerator extends SpanNodeGeneratorWithTag {
StickerGenerator({
required Color backgroundColor,
required Color foregroundColor,
required bool isEnlarged,
required String baseUrl,
}) : super(
tag: 'sticker',
generator:
(
markdown.Element element,
MarkdownConfig config,
WidgetVisitor visitor,
) {
return StickerSpanNode(
placeholder: element.textContent,
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
isEnlarged: isEnlarged,
baseUrl: baseUrl,
);
},
);
}
class StickerSpanNode extends SpanNode {
final String placeholder;
final Color backgroundColor;
final Color foregroundColor;
final bool isEnlarged;
final String baseUrl;
StickerSpanNode({
required this.placeholder,
required this.backgroundColor,
required this.foregroundColor,
required this.isEnlarged,
required this.baseUrl,
});
@override
InlineSpan build() {
final size = isEnlarged ? 96.0 : 24.0;
final stickerUri = '$baseUrl/sphere/stickers/lookup/$placeholder/open';
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Container(
decoration: BoxDecoration(
color: backgroundColor.withOpacity(0.1),
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: UniversalImage(
uri: stickerUri,
width: size,
height: size,
fit: BoxFit.contain,
noCacheOptimization: true,
),
),
),
);
}
}

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:markdown_widget/markdown_widget.dart';
import 'package:flutter_math_fork/flutter_math.dart';
import 'package:markdown/markdown.dart' as m;
SpanNodeGeneratorWithTag latexGenerator = SpanNodeGeneratorWithTag(
tag: _latexTag,
generator:
(e, config, visitor) => LatexNode(e.attributes, e.textContent, config),
);
const _latexTag = 'latex';
class LatexSyntax extends m.InlineSyntax {
final bool isDark;
LatexSyntax(this.isDark) : super(r'(\$\$[\s\S]+\$\$)|(\$.+?\$)');
@override
bool onMatch(m.InlineParser parser, Match match) {
final input = match.input;
final matchValue = input.substring(match.start, match.end);
String content = '';
bool isInline = true;
const blockSyntax = '\$\$';
const inlineSyntax = '\$';
if (matchValue.startsWith(blockSyntax) &&
matchValue.endsWith(blockSyntax) &&
(matchValue != blockSyntax)) {
content = matchValue.substring(2, matchValue.length - 2);
isInline = false;
} else if (matchValue.startsWith(inlineSyntax) &&
matchValue.endsWith(inlineSyntax) &&
matchValue != inlineSyntax) {
content = matchValue.substring(1, matchValue.length - 1);
}
m.Element el = m.Element.text(_latexTag, matchValue);
el.attributes['content'] = content;
el.attributes['isInline'] = '$isInline';
el.attributes['isDark'] = isDark.toString();
parser.addNode(el);
return true;
}
}
class LatexNode extends SpanNode {
final Map<String, String> attributes;
final String textContent;
final MarkdownConfig config;
LatexNode(this.attributes, this.textContent, this.config);
@override
InlineSpan build() {
final content = attributes['content'] ?? '';
final isInline = attributes['isInline'] == 'true';
final isDark = attributes['isDark'] == 'true';
final style = parentStyle ?? config.p.textStyle;
if (content.isEmpty) return TextSpan(style: style, text: textContent);
final latex = Math.tex(
content,
mathStyle: MathStyle.text,
textStyle: style.copyWith(color: isDark ? Colors.white : Colors.black),
textScaleFactor: 1,
onErrorFallback: (error) {
return Text(textContent, style: style.copyWith(color: Colors.red));
},
);
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child:
!isInline
? Container(
width: double.infinity,
margin: EdgeInsets.symmetric(vertical: 16),
child: Center(child: latex),
)
: latex,
);
}
}

View File

@@ -0,0 +1,231 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/core/config.dart';
import 'package:island/core/network.dart';
import 'package:island/core/websocket.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
class NetworkStatusSheet extends HookConsumerWidget {
final bool autoClose;
const NetworkStatusSheet({super.key, this.autoClose = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ws = ref.watch(websocketProvider);
final wsState = ref.watch(websocketStateProvider);
final apiState = ref.watch(networkStatusProvider);
final serverUrl = ref.watch(serverUrlProvider);
final wsNotifier = ref.watch(websocketStateProvider.notifier);
final checks = [
wsState == WebSocketState.connected(),
apiState == NetworkStatus.online,
];
useEffect(() {
if (!autoClose) return;
final checks = [
wsState == WebSocketState.connected(),
apiState == NetworkStatus.online,
];
if (!checks.any((e) => !e)) {
Future.delayed(Duration(seconds: 3), () {
if (context.mounted) Navigator.of(context).pop();
});
}
return null;
}, [wsState, apiState]);
return SheetScaffold(
heightFactor: 0.6,
titleText: !checks.any((e) => !e)
? 'Connection Status'
: 'Connection Issues',
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 4,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: !checks.any((e) => !e)
? Colors.green.withOpacity(0.1)
: Colors.red.withOpacity(0.1),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
margin: const EdgeInsets.only(bottom: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('overview').tr().bold(),
Column(
spacing: 8,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!checks.any((e) => !e))
Text('Everything is operational.'),
if (!checks[0])
Text(
'WebSocket is disconnected. Realtime updates are not available. You can try tap the reconnect button below to try connect again.',
),
if (!checks[1])
...([
Text(
'API is unreachable, you can try again later. If the issue persists, please contact support. Or you can check the service status.',
),
InkWell(
onTap: () {
launchUrlString("https://status.solsynth.dev");
},
child: Text(
'Check Service Status',
).textColor(Colors.blueAccent).bold(),
),
]),
],
),
],
),
),
Row(
spacing: 8,
children: [
Text('WebSocket').bold(),
wsState.when(
connected: () => Text('connectionConnected').tr(),
connecting: () => Text('connectionReconnecting').tr(),
disconnected: () => Text('connectionDisconnected').tr(),
serverDown: () => Text('connectionServerDown').tr(),
duplicateDevice: () => Text(
'Another device has connected with the same account.',
),
error: (message) => Text('Connection error: $message'),
),
if (ws.heartbeatDelay != null)
Text('${ws.heartbeatDelay!.inMilliseconds}ms'),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: wsState.when(
connected: () => Icon(
Symbols.check_circle,
key: ValueKey(WebSocketState.connected),
color: Colors.green,
size: 16,
),
connecting: () => Icon(
Symbols.sync,
key: ValueKey(WebSocketState.connecting),
color: Colors.orange,
size: 16,
),
disconnected: () => Icon(
Symbols.wifi_off,
key: ValueKey(WebSocketState.disconnected),
color: Colors.grey,
size: 16,
),
serverDown: () => Icon(
Symbols.cloud_off,
key: ValueKey(WebSocketState.serverDown),
color: Colors.red,
size: 16,
),
duplicateDevice: () => Icon(
Symbols.devices,
key: ValueKey(WebSocketState.duplicateDevice),
color: Colors.orange,
size: 16,
),
error: (message) => Icon(
Symbols.error,
key: ValueKey(WebSocketState.error),
color: Colors.red,
size: 16,
),
),
),
],
),
Row(
spacing: 8,
children: [
Text('API').bold(),
Text(
apiState == NetworkStatus.online
? 'Online'
: apiState == NetworkStatus.notReady
? 'Not Ready'
: apiState == NetworkStatus.maintenance
? 'Under Maintenance'
: 'Offline',
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: apiState == NetworkStatus.online
? Icon(
Symbols.check_circle,
key: ValueKey(NetworkStatus.online),
color: Colors.green,
size: 16,
)
: apiState == NetworkStatus.notReady
? Icon(
Symbols.warning,
key: ValueKey(NetworkStatus.notReady),
color: Colors.orange,
size: 16,
)
: apiState == NetworkStatus.maintenance
? Icon(
Symbols.construction,
key: ValueKey(NetworkStatus.maintenance),
color: Colors.orange,
size: 16,
)
: Icon(
Symbols.cloud_off,
key: ValueKey(NetworkStatus.offline),
color: Colors.red,
size: 16,
),
),
],
),
Row(
spacing: 8,
children: [
Text('API Server').bold(),
Expanded(child: Text(serverUrl)),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
spacing: 8,
children: [
FilledButton.icon(
icon: const Icon(Symbols.wifi),
label: const Text('Reconnect'),
onPressed: () {
wsNotifier.manualReconnect();
},
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'profile_decoration.freezed.dart';
@freezed
sealed class ProfileDecoration with _$ProfileDecoration {
const factory ProfileDecoration({
required String text,
required Color color,
Color? textColor,
}) = _ProfileDecoration;
}

View File

@@ -0,0 +1,271 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'profile_decoration.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ProfileDecoration {
String get text; Color get color; Color? get textColor;
/// Create a copy of ProfileDecoration
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ProfileDecorationCopyWith<ProfileDecoration> get copyWith => _$ProfileDecorationCopyWithImpl<ProfileDecoration>(this as ProfileDecoration, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ProfileDecoration&&(identical(other.text, text) || other.text == text)&&(identical(other.color, color) || other.color == color)&&(identical(other.textColor, textColor) || other.textColor == textColor));
}
@override
int get hashCode => Object.hash(runtimeType,text,color,textColor);
@override
String toString() {
return 'ProfileDecoration(text: $text, color: $color, textColor: $textColor)';
}
}
/// @nodoc
abstract mixin class $ProfileDecorationCopyWith<$Res> {
factory $ProfileDecorationCopyWith(ProfileDecoration value, $Res Function(ProfileDecoration) _then) = _$ProfileDecorationCopyWithImpl;
@useResult
$Res call({
String text, Color color, Color? textColor
});
}
/// @nodoc
class _$ProfileDecorationCopyWithImpl<$Res>
implements $ProfileDecorationCopyWith<$Res> {
_$ProfileDecorationCopyWithImpl(this._self, this._then);
final ProfileDecoration _self;
final $Res Function(ProfileDecoration) _then;
/// Create a copy of ProfileDecoration
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? text = null,Object? color = null,Object? textColor = freezed,}) {
return _then(_self.copyWith(
text: null == text ? _self.text : text // ignore: cast_nullable_to_non_nullable
as String,color: null == color ? _self.color : color // ignore: cast_nullable_to_non_nullable
as Color,textColor: freezed == textColor ? _self.textColor : textColor // ignore: cast_nullable_to_non_nullable
as Color?,
));
}
}
/// Adds pattern-matching-related methods to [ProfileDecoration].
extension ProfileDecorationPatterns on ProfileDecoration {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ProfileDecoration value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ProfileDecoration() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ProfileDecoration value) $default,){
final _that = this;
switch (_that) {
case _ProfileDecoration():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ProfileDecoration value)? $default,){
final _that = this;
switch (_that) {
case _ProfileDecoration() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String text, Color color, Color? textColor)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ProfileDecoration() when $default != null:
return $default(_that.text,_that.color,_that.textColor);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String text, Color color, Color? textColor) $default,) {final _that = this;
switch (_that) {
case _ProfileDecoration():
return $default(_that.text,_that.color,_that.textColor);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String text, Color color, Color? textColor)? $default,) {final _that = this;
switch (_that) {
case _ProfileDecoration() when $default != null:
return $default(_that.text,_that.color,_that.textColor);case _:
return null;
}
}
}
/// @nodoc
class _ProfileDecoration implements ProfileDecoration {
const _ProfileDecoration({required this.text, required this.color, this.textColor});
@override final String text;
@override final Color color;
@override final Color? textColor;
/// Create a copy of ProfileDecoration
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ProfileDecorationCopyWith<_ProfileDecoration> get copyWith => __$ProfileDecorationCopyWithImpl<_ProfileDecoration>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ProfileDecoration&&(identical(other.text, text) || other.text == text)&&(identical(other.color, color) || other.color == color)&&(identical(other.textColor, textColor) || other.textColor == textColor));
}
@override
int get hashCode => Object.hash(runtimeType,text,color,textColor);
@override
String toString() {
return 'ProfileDecoration(text: $text, color: $color, textColor: $textColor)';
}
}
/// @nodoc
abstract mixin class _$ProfileDecorationCopyWith<$Res> implements $ProfileDecorationCopyWith<$Res> {
factory _$ProfileDecorationCopyWith(_ProfileDecoration value, $Res Function(_ProfileDecoration) _then) = __$ProfileDecorationCopyWithImpl;
@override @useResult
$Res call({
String text, Color color, Color? textColor
});
}
/// @nodoc
class __$ProfileDecorationCopyWithImpl<$Res>
implements _$ProfileDecorationCopyWith<$Res> {
__$ProfileDecorationCopyWithImpl(this._self, this._then);
final _ProfileDecoration _self;
final $Res Function(_ProfileDecoration) _then;
/// Create a copy of ProfileDecoration
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? text = null,Object? color = null,Object? textColor = freezed,}) {
return _then(_ProfileDecoration(
text: null == text ? _self.text : text // ignore: cast_nullable_to_non_nullable
as String,color: null == color ? _self.color : color // ignore: cast_nullable_to_non_nullable
as Color,textColor: freezed == textColor ? _self.textColor : textColor // ignore: cast_nullable_to_non_nullable
as Color?,
));
}
}
// dart format on

View File

@@ -0,0 +1,71 @@
// Copyright (c) Solsynth
// Sensitive content categories for content warnings, in fixed order.
enum SensitiveCategory {
language,
sexualContent,
violence,
profanity,
hateSpeech,
racism,
adultContent,
drugAbuse,
alcoholAbuse,
gambling,
selfHarm,
childAbuse,
other,
}
extension SensitiveCategoryI18n on SensitiveCategory {
/// i18n key to look up localized label
String get i18nKey => switch (this) {
SensitiveCategory.language => 'sensitiveCategories.language',
SensitiveCategory.sexualContent => 'sensitiveCategories.sexualContent',
SensitiveCategory.violence => 'sensitiveCategories.violence',
SensitiveCategory.profanity => 'sensitiveCategories.profanity',
SensitiveCategory.hateSpeech => 'sensitiveCategories.hateSpeech',
SensitiveCategory.racism => 'sensitiveCategories.racism',
SensitiveCategory.adultContent => 'sensitiveCategories.adultContent',
SensitiveCategory.drugAbuse => 'sensitiveCategories.drugAbuse',
SensitiveCategory.alcoholAbuse => 'sensitiveCategories.alcoholAbuse',
SensitiveCategory.gambling => 'sensitiveCategories.gambling',
SensitiveCategory.selfHarm => 'sensitiveCategories.selfHarm',
SensitiveCategory.childAbuse => 'sensitiveCategories.childAbuse',
SensitiveCategory.other => 'sensitiveCategories.other',
};
/// Optional symbol you can use alongside the label in UI
String get symbol => switch (this) {
SensitiveCategory.language => '🌐',
SensitiveCategory.sexualContent => '🔞',
SensitiveCategory.violence => '⚠️',
SensitiveCategory.profanity => '🗯️',
SensitiveCategory.hateSpeech => '🚫',
SensitiveCategory.racism => '',
SensitiveCategory.adultContent => '🍑',
SensitiveCategory.drugAbuse => '💊',
SensitiveCategory.alcoholAbuse => '🍺',
SensitiveCategory.gambling => '🎲',
SensitiveCategory.selfHarm => '🆘',
SensitiveCategory.childAbuse => '🛑',
SensitiveCategory.other => '',
};
}
/// Ordered list for UI consumption, matching enum declaration order.
const List<SensitiveCategory> kSensitiveCategoriesOrdered = [
SensitiveCategory.language,
SensitiveCategory.sexualContent,
SensitiveCategory.violence,
SensitiveCategory.profanity,
SensitiveCategory.hateSpeech,
SensitiveCategory.racism,
SensitiveCategory.adultContent,
SensitiveCategory.drugAbuse,
SensitiveCategory.alcoholAbuse,
SensitiveCategory.gambling,
SensitiveCategory.selfHarm,
SensitiveCategory.childAbuse,
SensitiveCategory.other,
];

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
class SheetScaffold extends StatelessWidget {
final Widget? title;
final String? titleText;
final List<Widget> actions;
final Widget child;
final double heightFactor;
final double? height;
final VoidCallback? onClose;
const SheetScaffold({
super.key,
this.title,
this.titleText,
required this.child,
this.actions = const [],
this.heightFactor = 0.8,
this.height,
this.onClose,
});
@override
Widget build(BuildContext context) {
assert(title != null || titleText != null);
var titleWidget =
title ??
Text(
titleText!,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
return Container(
padding: MediaQuery.of(context).viewInsets,
constraints: BoxConstraints(
maxHeight: height ?? MediaQuery.of(context).size.height * heightFactor,
),
child: Column(
children: [
Padding(
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
child: Row(
children: [
Expanded(child: titleWidget),
const Spacer(),
...actions,
IconButton(
icon: Icon(
Symbols.close,
color: Theme.of(context).colorScheme.onSurface,
),
onPressed:
() =>
onClose != null
? onClose?.call()
: Navigator.pop(context),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
),
],
),
),
const Divider(height: 1),
Expanded(child: child),
],
),
);
}
}

View File

@@ -0,0 +1 @@
export 'video.native.dart' if (dart.library.html) 'video.web.dart';

View File

@@ -0,0 +1,73 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/core/config.dart';
import 'package:island/core/network.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
class UniversalVideo extends ConsumerStatefulWidget {
final String uri;
final double aspectRatio;
final bool autoplay;
const UniversalVideo({
super.key,
required this.uri,
this.aspectRatio = 16 / 9,
this.autoplay = false,
});
@override
ConsumerState<UniversalVideo> createState() => _UniversalVideoState();
}
class _UniversalVideoState extends ConsumerState<UniversalVideo> {
Player? _player;
VideoController? _videoController;
void _openVideo() async {
MediaKit.ensureInitialized();
_player = Player();
_videoController = VideoController(_player!);
final serverUrl = ref.read(serverUrlProvider);
final token = ref.read(tokenProvider);
final Map<String, String>? httpHeaders =
widget.uri.startsWith(serverUrl) && token != null
? {'Authorization': 'AtField ${token.token}'}
: null;
_player!.open(Media(widget.uri, httpHeaders: httpHeaders), play: widget.autoplay);
}
@override
void initState() {
super.initState();
_openVideo();
}
@override
void dispose() {
super.dispose();
_player?.dispose();
}
@override
Widget build(BuildContext context) {
if (_videoController == null) {
return Center(child: CircularProgressIndicator());
}
return Video(
controller: _videoController!,
aspectRatio: widget.aspectRatio != 1 ? widget.aspectRatio : null,
fit: BoxFit.contain,
controls:
!kIsWeb && (Platform.isAndroid || Platform.isIOS)
? MaterialVideoControls
: MaterialDesktopVideoControls,
);
}
}

View File

@@ -0,0 +1,28 @@
import 'package:web/web.dart' as web;
import 'package:flutter/material.dart';
class UniversalVideo extends StatelessWidget {
final String uri;
final double? aspectRatio;
final bool autoplay;
const UniversalVideo({
super.key,
required this.uri,
this.aspectRatio,
this.autoplay = false,
});
@override
Widget build(BuildContext context) {
return HtmlElementView.fromTagName(
tagName: 'video',
onElementCreated: (element) {
element as web.HTMLVideoElement;
element.src = uri;
element.style.width = '100%';
element.style.height = '100%';
element.controls = true;
},
);
}
}

View File

@@ -0,0 +1,243 @@
# Payment Overlay Widget
A reusable payment verification overlay that supports both 6-digit PIN input and biometric authentication for secure payment processing.
## Features
- **6-digit PIN Input**: Secure numeric PIN entry with automatic focus management
- **Biometric Authentication**: Support for fingerprint and face recognition
- **Order Summary**: Display payment details including amount, description, and remarks
- **Integrated API Calls**: Automatically handles payment processing via `/orders/{orderId}/pay`
- **Error Handling**: Comprehensive error handling with user-friendly messages
- **Loading States**: Visual feedback during payment processing
- **Responsive Design**: Adapts to different screen sizes and orientations
- **Customizable**: Flexible callbacks and styling options
- **Accessibility**: Screen reader support and proper focus management
- **Localization**: Full i18n support with easy_localization
## Usage
```dart
import 'package:flutter/material.dart';
import 'package:solian/models/wallet.dart';
import 'package:solian/widgets/payment/payment_overlay.dart';
// Create an order
final order = SnWalletOrder(
id: 'order_123',
amount: 2500, // $25.00 in cents
currency: 'USD',
description: 'Premium Subscription',
remarks: 'Monthly billing',
status: 'pending',
);
// Show payment overlay
PaymentOverlay.show(
context: context,
order: order,
onPaymentSuccess: (completedOrder) {
// Handle successful payment
print('Payment completed: ${completedOrder.id}');
// Navigate to success page or update UI
},
onPaymentError: (error) {
// Handle payment error
print('Payment failed: $error');
// Show error message to user
},
onCancel: () {
Navigator.of(context).pop();
print('Payment cancelled');
},
enableBiometric: true,
);
```
### Advanced Usage with Loading States
```dart
bool isLoading = false;
PaymentOverlay.show(
context: context,
order: order,
enableBiometric: true,
isLoading: isLoading,
onPinSubmit: (String pin) async {
setState(() => isLoading = true);
try {
await processPaymentWithPin(pin);
Navigator.of(context).pop();
} catch (e) {
showErrorDialog(e.toString());
} finally {
setState(() => isLoading = false);
}
},
onBiometricAuth: () async {
setState(() => isLoading = true);
try {
final authenticated = await authenticateWithBiometrics();
if (authenticated) {
await processPaymentWithBiometrics();
Navigator.of(context).pop();
}
} catch (e) {
showErrorDialog(e.toString());
} finally {
setState(() => isLoading = false);
}
},
);
```
## Parameters
### PaymentOverlay.show()
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `context` | `BuildContext` | ✅ | The build context for showing the overlay |
| `order` | `SnWalletOrder` | ✅ | The order to be paid |
| `onPaymentSuccess` | `Function(SnWalletOrder)?` | ❌ | Callback when payment succeeds with completed order |
| `onPaymentError` | `Function(String)?` | ❌ | Callback when payment fails with error message |
| `onCancel` | `VoidCallback?` | ❌ | Callback when payment is cancelled |
| `enableBiometric` | `bool` | ❌ | Whether to show biometric option (default: true) |
## API Integration
The PaymentOverlay automatically handles payment processing by calling the `/orders/{orderId}/pay` endpoint with the following request body:
### PIN Payment
```json
{
"pin": "123456"
}
```
### Biometric Payment
```json
{
"biometric": true
}
```
### Response
The API should return the completed `SnWalletOrder` object:
```json
{
"id": "order_123",
"amount": 2500,
"currency": "USD",
"description": "Premium Subscription",
"status": "completed",
"processorReference": "txn_abc123",
// ... other order fields
}
```
### Error Handling
The widget handles common HTTP status codes:
- `401`: Invalid PIN or biometric authentication failed
- `400`: Bad request with custom error message
- Other errors: Generic payment failed message
### Implementation Example
```dart
import 'package:local_auth/local_auth.dart';
class BiometricService {
final LocalAuthentication _auth = LocalAuthentication();
Future<bool> isAvailable() async {
final isAvailable = await _auth.canCheckBiometrics;
final isDeviceSupported = await _auth.isDeviceSupported();
return isAvailable && isDeviceSupported;
}
Future<bool> authenticate() async {
try {
final bool didAuthenticate = await _auth.authenticate(
localizedReason: 'Please authenticate to complete payment',
options: const AuthenticationOptions(
biometricOnly: true,
stickyAuth: true,
),
);
return didAuthenticate;
} catch (e) {
print('Biometric authentication error: $e');
return false;
}
}
}
```
## Localization
Add these keys to your localization files:
```json
{
"paymentVerification": "Payment Verification",
"paymentSummary": "Payment Summary",
"amount": "Amount",
"description": "Description",
"pinCode": "PIN Code",
"biometric": "Biometric",
"enterPinToConfirmPayment": "Enter your 6-digit PIN to confirm payment",
"clearPin": "Clear PIN",
"useBiometricToConfirm": "Use biometric authentication to confirm payment",
"touchSensorToAuthenticate": "Touch the sensor to authenticate",
"authenticating": "Authenticating...",
"authenticateNow": "Authenticate Now",
"confirm": "Confirm",
"cancel": "Cancel",
"paymentFailed": "Payment failed. Please try again.",
"invalidPin": "Invalid PIN. Please try again.",
"biometricAuthFailed": "Biometric authentication failed. Please try again.",
"paymentSuccess": "Payment completed successfully!",
"paymentError": "Payment failed: {error}"
}
```
## Styling
The widget automatically adapts to your app's theme. It uses:
- `Theme.of(context).colorScheme.primary` for primary elements
- `Theme.of(context).colorScheme.surface` for backgrounds
- `Theme.of(context).textTheme` for typography
## Security Considerations
1. **PIN Handling**: The PIN is passed as a string to your callback. Ensure you handle it securely and don't log it.
2. **Biometric Authentication**: Always verify biometric authentication on your backend.
3. **Network Security**: Use HTTPS for all payment-related API calls.
4. **Data Validation**: Validate all payment data on your backend before processing.
## Example Integration
See `payment_overlay_example.dart` for a complete working example that demonstrates:
- How to show the overlay
- Handling PIN and biometric authentication
- Processing payments
- Error handling
- Loading states
## Dependencies
- `flutter/material.dart` - Material Design components
- `flutter/services.dart` - Input formatters and system services
- `flutter_riverpod/flutter_riverpod.dart` - State management and dependency injection
- `gap/gap.dart` - Spacing widgets
- `material_symbols_icons/symbols.dart` - Material Symbols icons
- `easy_localization/easy_localization.dart` - Internationalization
- `dio/dio.dart` - HTTP client for API calls
- `solian/models/wallet.dart` - Wallet order model
- `solian/widgets/common/sheet_scaffold.dart` - Sheet scaffold widget
- `solian/pods/network.dart` - API client provider

View File

@@ -0,0 +1,477 @@
import 'package:flutter/material.dart';
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:island/wallet/wallet_models/wallet.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:island/core/network.dart';
import 'package:dio/dio.dart';
import 'package:local_auth/local_auth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter/services.dart';
import 'package:styled_widget/styled_widget.dart';
class PaymentOverlay extends HookConsumerWidget {
final SnWalletOrder order;
final Function(SnWalletOrder completedOrder)? onPaymentSuccess;
final Function(String error)? onPaymentError;
final VoidCallback? onCancel;
final bool enableBiometric;
const PaymentOverlay({
super.key,
required this.order,
this.onPaymentSuccess,
this.onPaymentError,
this.onCancel,
this.enableBiometric = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SheetScaffold(
titleText: 'Solarpay',
heightFactor: 0.7,
child: _PaymentContent(
order: order,
onPaymentSuccess: onPaymentSuccess,
onPaymentError: onPaymentError,
onCancel: onCancel,
enableBiometric: enableBiometric,
),
),
),
);
}
static Future<SnWalletOrder?> show({
required BuildContext context,
required SnWalletOrder order,
bool enableBiometric = true,
}) {
return showModalBottomSheet<SnWalletOrder>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
useSafeArea: true,
builder: (context) => PaymentOverlay(
order: order,
enableBiometric: enableBiometric,
onPaymentSuccess: (completedOrder) {
Navigator.of(context).pop(completedOrder);
},
onPaymentError: (err) {
Navigator.of(context).pop();
showErrorAlert(err);
},
onCancel: () {
Navigator.of(context).pop();
},
),
);
}
}
class _PaymentContent extends ConsumerStatefulWidget {
final SnWalletOrder order;
final Function(SnWalletOrder)? onPaymentSuccess;
final Function(String)? onPaymentError;
final VoidCallback? onCancel;
final bool enableBiometric;
const _PaymentContent({
required this.order,
this.onPaymentSuccess,
this.onPaymentError,
this.onCancel,
this.enableBiometric = true,
});
@override
ConsumerState<_PaymentContent> createState() => _PaymentContentState();
}
class _PaymentContentState extends ConsumerState<_PaymentContent> {
static const String _pinStorageKey = 'app_pin_code';
static final _secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
final LocalAuthentication _localAuth = LocalAuthentication();
String _pin = '';
bool _isPinMode = true;
bool _hasBiometricSupport = false;
bool _hasStoredPin = false;
@override
void initState() {
super.initState();
_initializeBiometric();
}
@override
void dispose() {
super.dispose();
}
Future<void> _initializeBiometric() async {
try {
// Check if biometric is available
final isAvailable = await _localAuth.isDeviceSupported();
final canCheckBiometrics = await _localAuth.canCheckBiometrics;
_hasBiometricSupport = isAvailable && canCheckBiometrics;
// Check if PIN is stored
final storedPin = await _secureStorage.read(key: _pinStorageKey);
_hasStoredPin = storedPin != null && storedPin.isNotEmpty;
// Set initial mode based on stored PIN and biometric support
if (_hasStoredPin && _hasBiometricSupport && widget.enableBiometric) {
_isPinMode = false;
} else {
_isPinMode = true;
}
if (mounted) {
setState(() {});
}
} catch (e) {
// Fallback to PIN mode if biometric setup fails
_isPinMode = true;
if (mounted) {
setState(() {});
}
}
}
void _onPinSubmit(String pin) {
_pin = pin;
if (pin.length == 6) {
_processPaymentWithPin(pin);
}
}
Future<void> _processPaymentWithPin(String pin) async {
showLoadingModal(context);
try {
// Store PIN securely for future biometric authentication
if (_hasBiometricSupport && widget.enableBiometric && !_hasStoredPin) {
await _secureStorage.write(key: _pinStorageKey, value: pin);
_hasStoredPin = true;
}
await _makePaymentRequest(pin);
} catch (err) {
widget.onPaymentError?.call(err.toString());
_pin = '';
} finally {
if (mounted) {
hideLoadingModal(context);
}
}
}
Future<void> _authenticateWithBiometric() async {
showLoadingModal(context);
try {
// Perform biometric authentication
final bool didAuthenticate = await _localAuth.authenticate(
localizedReason: 'biometricPrompt'.tr(),
biometricOnly: true,
);
if (didAuthenticate) {
// Retrieve stored PIN and process payment
final storedPin = await _secureStorage.read(key: _pinStorageKey);
if (storedPin != null && storedPin.isNotEmpty) {
await _makePaymentRequest(storedPin);
} else {
// Fallback to PIN mode if no stored PIN
_fallbackToPinMode('noStoredPin'.tr());
}
} else {
// Biometric authentication failed, fallback to PIN mode
_fallbackToPinMode('biometricAuthFailed'.tr());
}
} catch (err) {
// Handle biometric authentication errors
String errorMessage = 'biometricAuthFailed'.tr();
if (err is PlatformException) {
switch (err.code) {
case 'NotAvailable':
errorMessage = 'biometricNotAvailable'.tr();
break;
case 'NotEnrolled':
errorMessage = 'biometricNotEnrolled'.tr();
break;
case 'LockedOut':
case 'PermanentlyLockedOut':
errorMessage = 'biometricLockedOut'.tr();
break;
default:
errorMessage = 'biometricAuthFailed'.tr();
}
}
_fallbackToPinMode(errorMessage);
} finally {
if (mounted) {
hideLoadingModal(context);
}
}
}
/// Unified method for making payment requests with PIN
Future<void> _makePaymentRequest(String pin) async {
try {
final client = ref.read(apiClientProvider);
final response = await client.post(
'/wallet/orders/${widget.order.id}/pay',
data: {'pin_code': pin},
);
final completedOrder = SnWalletOrder.fromJson(response.data);
widget.onPaymentSuccess?.call(completedOrder);
} catch (err) {
String errorMessage = 'paymentFailed'.tr();
if (err is DioException) {
if (err.response?.statusCode == 403 ||
err.response?.statusCode == 401) {
// PIN is invalid
errorMessage = 'invalidPin'.tr();
// If this was a biometric attempt with stored PIN, remove the stored PIN
if (!_isPinMode) {
await _secureStorage.delete(key: _pinStorageKey);
_hasStoredPin = false;
_fallbackToPinMode(errorMessage);
return;
}
} else if (err.response?.statusCode == 400) {
errorMessage = err.response?.data?['error'] ?? errorMessage;
} else {
rethrow;
}
}
throw errorMessage;
}
}
void _fallbackToPinMode(String? message) {
setState(() {
_isPinMode = true;
});
if (message != null && message.isNotEmpty) {
showSnackBar(message);
}
}
String _formatCurrency(int amount, String currency) {
final value = amount;
return '${value.toStringAsFixed(2)} $currency';
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Order Summary
_buildOrderSummary(),
const Gap(32),
// Authentication Content
Expanded(
child: _isPinMode ? _buildPinInput() : _buildBiometricAuth(),
),
// Action Buttons
const Gap(24),
_buildActionButtons(),
],
),
);
}
Widget _buildOrderSummary() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.receipt,
color: Theme.of(context).colorScheme.primary,
),
const Gap(8),
Text(
'paymentSummary'.tr(),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
),
],
),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'amount'.tr(),
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
_formatCurrency(widget.order.amount, widget.order.currency),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
),
],
),
if (widget.order.remarks != null) ...[
const Gap(8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'description'.tr(),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
const Spacer(),
Expanded(
flex: 2,
child: Text(
widget.order.remarks!,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.end,
),
),
],
),
],
],
),
);
}
Widget _buildPinInput() {
return Column(
children: [
Text(
'enterPinToConfirmPayment'.tr(),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
textAlign: TextAlign.center,
),
const Gap(24),
OtpTextField(
numberOfFields: 6,
borderColor: Theme.of(context).colorScheme.outline,
focusedBorderColor: Theme.of(context).colorScheme.primary,
showFieldAsBox: true,
obscureText: true,
keyboardType: TextInputType.number,
fieldWidth: 48,
fieldHeight: 56,
borderRadius: BorderRadius.circular(8),
borderWidth: 1,
textStyle: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600),
onSubmit: _onPinSubmit,
onCodeChanged: (String code) {
_pin = code;
setState(() {});
},
),
],
);
}
Widget _buildBiometricAuth() {
return SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Symbols.fingerprint, size: 48),
const Gap(16),
Text(
'useBiometricToConfirm'.tr(),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
textAlign: TextAlign.center,
),
Text(
'The biometric data will only be processed on your device',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 11,
),
textAlign: TextAlign.center,
).opacity(0.75),
const Gap(28),
ElevatedButton.icon(
onPressed: _authenticateWithBiometric,
icon: const Icon(Symbols.fingerprint),
label: Text('authenticateNow'.tr()),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
TextButton(
onPressed: () => _fallbackToPinMode(null),
child: Text('usePinInstead'.tr()),
),
],
).center(),
);
}
Widget _buildActionButtons() {
return Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: widget.onCancel,
child: Text('cancel'.tr()),
),
),
if (_isPinMode && _pin.length == 6) ...[
const Gap(12),
Expanded(
child: ElevatedButton(
onPressed: () => _processPaymentWithPin(_pin),
child: Text('confirm'.tr()),
),
),
],
],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
class UploadMenuItemData {
final IconData icon;
final String textKey;
final VoidCallback onPressed;
const UploadMenuItemData(this.icon, this.textKey, this.onPressed);
}
class UploadMenu extends StatelessWidget {
final List<UploadMenuItemData> items;
final bool isCompact;
final Color? iconColor;
const UploadMenu({
super.key,
required this.items,
this.isCompact = false,
this.iconColor,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return MenuAnchor(
builder:
(context, controller, child) => IconButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
tooltip: 'uploadFile'.tr(),
icon: const Icon(Symbols.file_upload),
color: iconColor ?? colorScheme.primary,
visualDensity:
isCompact
? const VisualDensity(horizontal: -4, vertical: -2)
: null,
),
menuChildren:
items
.map(
(item) => MenuItemButton(
onPressed: item.onPressed,
leadingIcon: Icon(item.icon),
style: ButtonStyle(
visualDensity: VisualDensity.compact,
padding: WidgetStatePropertyAll(
EdgeInsets.only(left: 12, right: 16, top: 20, bottom: 20),
),
),
child: Text(item.textKey.tr()),
),
)
.toList(),
);
}
}