Compare commits
	
		
			31 Commits
		
	
	
		
			925cb2b423
			...
			3.0.0+112
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 996462f1fd | |||
| 778f6bb79f | |||
| 8747f948b9 | |||
| 9546d6e4b8 | |||
| f8d1940af6 | |||
| b2b0891d24 | |||
| 274168d4bc | |||
| 2c98b348d5 | |||
| afc7887ddd | |||
| 99ff78a3d5 | |||
| 2ad85addf6 | |||
| 552b4b2572 | |||
| 594ac39e3d | |||
| 23321171f3 | |||
| ee72d79c93 | |||
| a20c2598fc | |||
| 2eba871a6d | |||
| 46919dec31 | |||
| 9dd6cffe0c | |||
| 2ea9f5e907 | |||
| 050750a808 | |||
| f479b9fc8b | |||
| 13ea182707 | |||
| 14183a7316 | |||
| 9fc9b87608 | |||
| 53c2445ba9 | |||
| d414695eb3 | |||
|  | 27bc17079e | ||
|  | 295188459b | ||
|  | 66115258a7 | ||
|  | 2cf2c515b4 | 
| @@ -42,6 +42,15 @@ | ||||
|                 <category android:name="android.intent.category.LAUNCHER" /> | ||||
|             </intent-filter> | ||||
|  | ||||
|             <!-- Deeplinking --> | ||||
|             <intent-filter android:autoVerify="true"> | ||||
|                 <action android:name="android.intent.action.VIEW" /> | ||||
|                 <category android:name="android.intent.category.DEFAULT" /> | ||||
|                 <category android:name="android.intent.category.BROWSABLE" /> | ||||
|                 <data android:scheme="http" android:host="solian.app" /> | ||||
|                 <data android:scheme="https" /> | ||||
|             </intent-filter> | ||||
|  | ||||
|             <!-- Share Intent Filters --> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.SEND" /> | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -772,6 +772,7 @@ | ||||
| 				ENABLE_BITCODE = NO; | ||||
| 				INFOPLIST_FILE = Runner/Info.plist; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | ||||
| 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 13.0; | ||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | ||||
| 					"$(inherited)", | ||||
| @@ -1202,6 +1203,7 @@ | ||||
| 				ENABLE_BITCODE = NO; | ||||
| 				INFOPLIST_FILE = Runner/Info.plist; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | ||||
| 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 13.0; | ||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | ||||
| 					"$(inherited)", | ||||
| @@ -1229,6 +1231,7 @@ | ||||
| 				ENABLE_BITCODE = NO; | ||||
| 				INFOPLIST_FILE = Runner/Info.plist; | ||||
| 				INFOPLIST_KEY_CFBundleDisplayName = Solian; | ||||
| 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 13.0; | ||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | ||||
| 					"$(inherited)", | ||||
|   | ||||
| @@ -2,16 +2,10 @@ | ||||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||||
| <plist version="1.0"> | ||||
| <dict> | ||||
| 	<key>CLIENT_ID</key> | ||||
| 	<string>961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig.apps.googleusercontent.com</string> | ||||
| 	<key>REVERSED_CLIENT_ID</key> | ||||
| 	<string>com.googleusercontent.apps.961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig</string> | ||||
| 	<key>PLIST_VERSION</key> | ||||
| 	<string>1</string> | ||||
| 	<key>AppGroupId</key> | ||||
| 	<string>$(CUSTOM_GROUP_ID)</string> | ||||
| 	<key>BUNDLE_ID</key> | ||||
| 	<string>dev.solsynth.solian</string> | ||||
| 	<key>ITSAppUsesNonExemptEncryption</key> | ||||
| 	<false/> | ||||
| 	<key>CADisableMinimumFrameDurationOnPhone</key> | ||||
| 	<true/> | ||||
| 	<key>CFBundleDevelopmentRegion</key> | ||||
| @@ -32,8 +26,6 @@ | ||||
| 	<string>$(FLUTTER_BUILD_NAME)</string> | ||||
| 	<key>CFBundleSignature</key> | ||||
| 	<string>????</string> | ||||
| 	<key>CFBundleVersion</key> | ||||
| 	<string>$(FLUTTER_BUILD_NUMBER)</string> | ||||
| 	<key>CFBundleURLTypes</key> | ||||
| 	<array> | ||||
| 		<dict> | ||||
| @@ -45,18 +37,35 @@ | ||||
| 			</array> | ||||
| 		</dict> | ||||
| 	</array> | ||||
| 	<key>CFBundleVersion</key> | ||||
| 	<string>$(FLUTTER_BUILD_NUMBER)</string> | ||||
| 	<key>CLIENT_ID</key> | ||||
| 	<string>961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig.apps.googleusercontent.com</string> | ||||
| 	<key>ITSAppUsesNonExemptEncryption</key> | ||||
| 	<false/> | ||||
| 	<key>LSRequiresIPhoneOS</key> | ||||
| 	<true/> | ||||
| 	<key>NSCalendarsUsageDescription</key> | ||||
| 	<string>Grant access to Calander help us to shows Solar Calander with your own events.</string> | ||||
| 	<key>NSCameraUsageDescription</key> | ||||
| 	<string>Grant access to Camera will allow Solian take photo or video for your post.</string> | ||||
| 	<key>NSFaceIDUsageDescription</key> | ||||
| 	<string>Allow the Solar Network verify your ownership of the logged in account and continue your action quickly.</string> | ||||
| 	<key>NSMicrophoneUsageDescription</key> | ||||
| 	<string>Grant access to Microphone will allow Solian record audio for your post.</string> | ||||
| 	<key>NSPhotoLibraryAddUsageDescription</key> | ||||
| 	<string>Grant access to Photo Library will allow Solian download photo to album for you.</string> | ||||
| 	<key>NSPhotoLibraryUsageDescription</key> | ||||
| 	<string>Grant access to Photo Library will allow Solian upload photo or video for your post.</string> | ||||
| 	<key>NSUserActivityTypes</key> | ||||
| 	<array> | ||||
| 		<string>INStartCallIntent</string> | ||||
| 		<string>INSendMessageIntent</string> | ||||
| 	</array> | ||||
| 	<key>PLIST_VERSION</key> | ||||
| 	<string>1</string> | ||||
| 	<key>REVERSED_CLIENT_ID</key> | ||||
| 	<string>com.googleusercontent.apps.961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig</string> | ||||
| 	<key>UIApplicationSupportsIndirectInputEvents</key> | ||||
| 	<true/> | ||||
| 	<key>UIBackgroundModes</key> | ||||
| @@ -75,24 +84,13 @@ | ||||
| 	<key>UISupportedInterfaceOrientations</key> | ||||
| 	<array> | ||||
| 		<string>UIInterfaceOrientationPortrait</string> | ||||
| 		<string>UIInterfaceOrientationLandscapeLeft</string> | ||||
| 		<string>UIInterfaceOrientationLandscapeRight</string> | ||||
| 	</array> | ||||
| 	<key>UISupportedInterfaceOrientations~ipad</key> | ||||
| 	<array> | ||||
| 		<string>UIInterfaceOrientationPortrait</string> | ||||
| 		<string>UIInterfaceOrientationPortraitUpsideDown</string> | ||||
| 		<string>UIInterfaceOrientationLandscapeLeft</string> | ||||
| 		<string>UIInterfaceOrientationLandscapeRight</string> | ||||
| 	</array> | ||||
| 	<key>NSFaceIDUsageDescription</key> | ||||
| 	<string>Allow the Solar Network verify your ownership of the logged in account and continue your action quickly.</string> | ||||
| 	<key>AppGroupId</key> | ||||
| 	<string>$(CUSTOM_GROUP_ID)</string> | ||||
| 	<key>NSUserActivityTypes</key> | ||||
| 	<array> | ||||
| 		<string>INStartCallIntent</string> | ||||
| 		<string>INSendMessageIntent</string> | ||||
| 		<string>UIInterfaceOrientationPortrait</string> | ||||
| 		<string>UIInterfaceOrientationPortraitUpsideDown</string> | ||||
| 	</array> | ||||
| </dict> | ||||
| </plist> | ||||
|   | ||||
| @@ -9,6 +9,7 @@ import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:image_picker_android/image_picker_android.dart'; | ||||
| import 'package:island/firebase_options.dart'; | ||||
| @@ -45,6 +46,10 @@ void main() async { | ||||
|     FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); | ||||
|   } | ||||
|  | ||||
|   if (kIsWeb) { | ||||
|     GoRouter.optionURLReflectsImperativeAPIs = true; | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     await EasyLocalization.ensureInitialized(); | ||||
|     await Firebase.initializeApp( | ||||
| @@ -216,7 +221,7 @@ class IslandApp extends HookConsumerWidget { | ||||
|       Future(() { | ||||
|         userNotifier.fetchUser().then((_) { | ||||
|           final user = ref.watch(userInfoProvider); | ||||
|           if (user.hasValue) { | ||||
|           if (user.value != null) { | ||||
|             final apiClient = ref.read(apiClientProvider); | ||||
|             subscribePushNotification(apiClient); | ||||
|             final wsNotifier = ref.read(websocketStateProvider.notifier); | ||||
|   | ||||
							
								
								
									
										23
									
								
								lib/models/abuse_report.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								lib/models/abuse_report.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
|  | ||||
| part 'abuse_report.freezed.dart'; | ||||
| part 'abuse_report.g.dart'; | ||||
|  | ||||
| @freezed | ||||
| sealed class SnAbuseReport with _$SnAbuseReport { | ||||
|   const factory SnAbuseReport({ | ||||
|     required String id, | ||||
|     required String resourceIdentifier, | ||||
|     required int type, | ||||
|     required String reason, | ||||
|     required DateTime? resolvedAt, | ||||
|     required String? resolution, | ||||
|     required String accountId, | ||||
|     required DateTime createdAt, | ||||
|     required DateTime updatedAt, | ||||
|     required DateTime? deletedAt, | ||||
|   }) = _SnAbuseReport; | ||||
|  | ||||
|   factory SnAbuseReport.fromJson(Map<String, dynamic> json) => | ||||
|       _$SnAbuseReportFromJson(json); | ||||
| } | ||||
							
								
								
									
										175
									
								
								lib/models/abuse_report.freezed.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										175
									
								
								lib/models/abuse_report.freezed.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,175 @@ | ||||
| // dart format width=80 | ||||
| // coverage:ignore-file | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| // 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 'abuse_report.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // FreezedGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| // dart format off | ||||
| T _$identity<T>(T value) => value; | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$SnAbuseReport { | ||||
|  | ||||
|  String get id; String get resourceIdentifier; int get type; String get reason; DateTime? get resolvedAt; String? get resolution; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; | ||||
| /// Create a copy of SnAbuseReport | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnAbuseReportCopyWith<SnAbuseReport> get copyWith => _$SnAbuseReportCopyWithImpl<SnAbuseReport>(this as SnAbuseReport, _$identity); | ||||
|  | ||||
|   /// Serializes this SnAbuseReport to a JSON map. | ||||
|   Map<String, dynamic> toJson(); | ||||
|  | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAbuseReport&&(identical(other.id, id) || other.id == id)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&(identical(other.type, type) || other.type == type)&&(identical(other.reason, reason) || other.reason == reason)&&(identical(other.resolvedAt, resolvedAt) || other.resolvedAt == resolvedAt)&&(identical(other.resolution, resolution) || other.resolution == resolution)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,resourceIdentifier,type,reason,resolvedAt,resolution,accountId,createdAt,updatedAt,deletedAt); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnAbuseReport(id: $id, resourceIdentifier: $resourceIdentifier, type: $type, reason: $reason, resolvedAt: $resolvedAt, resolution: $resolution, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class $SnAbuseReportCopyWith<$Res>  { | ||||
|   factory $SnAbuseReportCopyWith(SnAbuseReport value, $Res Function(SnAbuseReport) _then) = _$SnAbuseReportCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String id, String resourceIdentifier, int type, String reason, DateTime? resolvedAt, String? resolution, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class _$SnAbuseReportCopyWithImpl<$Res> | ||||
|     implements $SnAbuseReportCopyWith<$Res> { | ||||
|   _$SnAbuseReportCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final SnAbuseReport _self; | ||||
|   final $Res Function(SnAbuseReport) _then; | ||||
|  | ||||
| /// Create a copy of SnAbuseReport | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? resourceIdentifier = null,Object? type = null,Object? reason = null,Object? resolvedAt = freezed,Object? resolution = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,resourceIdentifier: null == resourceIdentifier ? _self.resourceIdentifier : resourceIdentifier // ignore: cast_nullable_to_non_nullable | ||||
| as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| as int,reason: null == reason ? _self.reason : reason // ignore: cast_nullable_to_non_nullable | ||||
| as String,resolvedAt: freezed == resolvedAt ? _self.resolvedAt : resolvedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,resolution: freezed == resolution ? _self.resolution : resolution // ignore: cast_nullable_to_non_nullable | ||||
| as String?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _SnAbuseReport implements SnAbuseReport { | ||||
|   const _SnAbuseReport({required this.id, required this.resourceIdentifier, required this.type, required this.reason, required this.resolvedAt, required this.resolution, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}); | ||||
|   factory _SnAbuseReport.fromJson(Map<String, dynamic> json) => _$SnAbuseReportFromJson(json); | ||||
|  | ||||
| @override final  String id; | ||||
| @override final  String resourceIdentifier; | ||||
| @override final  int type; | ||||
| @override final  String reason; | ||||
| @override final  DateTime? resolvedAt; | ||||
| @override final  String? resolution; | ||||
| @override final  String accountId; | ||||
| @override final  DateTime createdAt; | ||||
| @override final  DateTime updatedAt; | ||||
| @override final  DateTime? deletedAt; | ||||
|  | ||||
| /// Create a copy of SnAbuseReport | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @pragma('vm:prefer-inline') | ||||
| _$SnAbuseReportCopyWith<_SnAbuseReport> get copyWith => __$SnAbuseReportCopyWithImpl<_SnAbuseReport>(this, _$identity); | ||||
|  | ||||
| @override | ||||
| Map<String, dynamic> toJson() { | ||||
|   return _$SnAbuseReportToJson(this, ); | ||||
| } | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAbuseReport&&(identical(other.id, id) || other.id == id)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&(identical(other.type, type) || other.type == type)&&(identical(other.reason, reason) || other.reason == reason)&&(identical(other.resolvedAt, resolvedAt) || other.resolvedAt == resolvedAt)&&(identical(other.resolution, resolution) || other.resolution == resolution)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,id,resourceIdentifier,type,reason,resolvedAt,resolution,accountId,createdAt,updatedAt,deletedAt); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'SnAbuseReport(id: $id, resourceIdentifier: $resourceIdentifier, type: $type, reason: $reason, resolvedAt: $resolvedAt, resolution: $resolution, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract mixin class _$SnAbuseReportCopyWith<$Res> implements $SnAbuseReportCopyWith<$Res> { | ||||
|   factory _$SnAbuseReportCopyWith(_SnAbuseReport value, $Res Function(_SnAbuseReport) _then) = __$SnAbuseReportCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String id, String resourceIdentifier, int type, String reason, DateTime? resolvedAt, String? resolution, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| class __$SnAbuseReportCopyWithImpl<$Res> | ||||
|     implements _$SnAbuseReportCopyWith<$Res> { | ||||
|   __$SnAbuseReportCopyWithImpl(this._self, this._then); | ||||
|  | ||||
|   final _SnAbuseReport _self; | ||||
|   final $Res Function(_SnAbuseReport) _then; | ||||
|  | ||||
| /// Create a copy of SnAbuseReport | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? resourceIdentifier = null,Object? type = null,Object? reason = null,Object? resolvedAt = freezed,Object? resolution = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { | ||||
|   return _then(_SnAbuseReport( | ||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||
| as String,resourceIdentifier: null == resourceIdentifier ? _self.resourceIdentifier : resourceIdentifier // ignore: cast_nullable_to_non_nullable | ||||
| as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||
| as int,reason: null == reason ? _self.reason : reason // ignore: cast_nullable_to_non_nullable | ||||
| as String,resolvedAt: freezed == resolvedAt ? _self.resolvedAt : resolvedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?,resolution: freezed == resolution ? _self.resolution : resolution // ignore: cast_nullable_to_non_nullable | ||||
| as String?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable | ||||
| as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
| as DateTime?, | ||||
|   )); | ||||
| } | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
| // dart format on | ||||
							
								
								
									
										41
									
								
								lib/models/abuse_report.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								lib/models/abuse_report.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
|  | ||||
| part of 'abuse_report.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // JsonSerializableGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| _SnAbuseReport _$SnAbuseReportFromJson(Map<String, dynamic> json) => | ||||
|     _SnAbuseReport( | ||||
|       id: json['id'] as String, | ||||
|       resourceIdentifier: json['resource_identifier'] as String, | ||||
|       type: (json['type'] as num).toInt(), | ||||
|       reason: json['reason'] as String, | ||||
|       resolvedAt: | ||||
|           json['resolved_at'] == null | ||||
|               ? null | ||||
|               : DateTime.parse(json['resolved_at'] as String), | ||||
|       resolution: json['resolution'] 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> _$SnAbuseReportToJson(_SnAbuseReport instance) => | ||||
|     <String, dynamic>{ | ||||
|       'id': instance.id, | ||||
|       'resource_identifier': instance.resourceIdentifier, | ||||
|       'type': instance.type, | ||||
|       'reason': instance.reason, | ||||
|       'resolved_at': instance.resolvedAt?.toIso8601String(), | ||||
|       'resolution': instance.resolution, | ||||
|       'account_id': instance.accountId, | ||||
|       'created_at': instance.createdAt.toIso8601String(), | ||||
|       'updated_at': instance.updatedAt.toIso8601String(), | ||||
|       'deleted_at': instance.deletedAt?.toIso8601String(), | ||||
|     }; | ||||
							
								
								
									
										38
									
								
								lib/models/abuse_report_type.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								lib/models/abuse_report_type.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| enum AbuseReportType { | ||||
|   copyright(0), | ||||
|   harassment(1), | ||||
|   impersonation(2), | ||||
|   offensiveContent(3), | ||||
|   spam(4), | ||||
|   privacyViolation(5), | ||||
|   illegalContent(6), | ||||
|   other(7); | ||||
|  | ||||
|   const AbuseReportType(this.value); | ||||
|   final int value; | ||||
|  | ||||
|   static AbuseReportType fromValue(int value) { | ||||
|     return values.firstWhere((e) => e.value == value); | ||||
|   } | ||||
|  | ||||
|   String get displayName { | ||||
|     switch (this) { | ||||
|       case AbuseReportType.copyright: | ||||
|         return 'Copyright'; | ||||
|       case AbuseReportType.harassment: | ||||
|         return 'Harassment'; | ||||
|       case AbuseReportType.impersonation: | ||||
|         return 'Impersonation'; | ||||
|       case AbuseReportType.offensiveContent: | ||||
|         return 'Offensive Content'; | ||||
|       case AbuseReportType.spam: | ||||
|         return 'Spam'; | ||||
|       case AbuseReportType.privacyViolation: | ||||
|         return 'Privacy Violation'; | ||||
|       case AbuseReportType.illegalContent: | ||||
|         return 'Illegal Content'; | ||||
|       case AbuseReportType.other: | ||||
|         return 'Other'; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -18,8 +18,13 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> { | ||||
|       final user = SnAccount.fromJson(response.data); | ||||
|       state = AsyncValue.data(user); | ||||
|     } catch (error, stackTrace) { | ||||
|       log("[UserInfo] Failed to fetch user info: $error"); | ||||
|       state = AsyncValue.error(error, stackTrace); | ||||
|       log( | ||||
|         "[UserInfo] Failed to fetch user info...", | ||||
|         name: 'UserInfoNotifier', | ||||
|         error: error, | ||||
|         stackTrace: stackTrace, | ||||
|       ); | ||||
|       state = AsyncValue.data(null); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -11,7 +11,7 @@ import 'package:island/screens/posts/post_search.dart'; | ||||
| import 'package:island/widgets/app_wrapper.dart'; | ||||
| import 'package:island/screens/tabs.dart'; | ||||
| import 'package:island/screens/explore.dart'; | ||||
| import 'package:island/screens/article_detail_screen.dart'; | ||||
| import 'package:island/screens/discovery/article_detail.dart'; | ||||
| import 'package:island/screens/account.dart'; | ||||
| import 'package:island/screens/notification.dart'; | ||||
| import 'package:island/screens/wallet.dart'; | ||||
| @@ -41,6 +41,8 @@ import 'package:island/screens/realm/realms.dart'; | ||||
| import 'package:island/screens/realm/realm_detail.dart'; | ||||
| import 'package:island/screens/account/event_calendar.dart'; | ||||
| import 'package:island/screens/discovery/realms.dart'; | ||||
| import 'package:island/screens/reports/report_detail.dart'; | ||||
| import 'package:island/screens/reports/report_list.dart'; | ||||
|  | ||||
| // Shell route keys for nested navigation | ||||
| final rootNavigatorKey = GlobalKey<NavigatorState>(); | ||||
| @@ -65,6 +67,9 @@ final routerProvider = Provider<GoRouter>((ref) { | ||||
|             builder: | ||||
|                 (context, state) => PostComposeScreen( | ||||
|                   initialState: state.extra as PostComposeInitialState?, | ||||
|                   type: | ||||
|                       int.tryParse(state.uri.queryParameters['type'] ?? '0') ?? | ||||
|                       0, | ||||
|                 ), | ||||
|           ), | ||||
|           GoRoute( | ||||
| @@ -255,6 +260,19 @@ final routerProvider = Provider<GoRouter>((ref) { | ||||
|             builder: (context, state) => const AboutScreen(), | ||||
|           ), | ||||
|  | ||||
|           GoRoute( | ||||
|             path: '/safety/reports/me', | ||||
|             builder: (context, state) => const AbuseReportListScreen(), | ||||
|           ), | ||||
|  | ||||
|           GoRoute( | ||||
|             path: '/safety/reports/me/:id', | ||||
|             builder: (context, state) { | ||||
|               final id = state.pathParameters['id']!; | ||||
|               return AbuseReportDetailScreen(reportId: id); | ||||
|             }, | ||||
|           ), | ||||
|  | ||||
|           // Main tabs with TabsScreen shell | ||||
|           ShellRoute( | ||||
|             navigatorKey: _tabsShellKey, | ||||
|   | ||||
| @@ -1,22 +1,35 @@ | ||||
| import 'package:device_info_plus/device_info_plus.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/services/notify.dart'; | ||||
| import 'package:island/services/udid.native.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:package_info_plus/package_info_plus.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:url_launcher/url_launcher.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
|  | ||||
| class AboutScreen extends StatefulWidget { | ||||
| class AboutScreen extends ConsumerStatefulWidget { | ||||
|   const AboutScreen({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<AboutScreen> createState() => _AboutScreenState(); | ||||
|   ConsumerState<AboutScreen> createState() => _AboutScreenState(); | ||||
| } | ||||
|  | ||||
| class _AboutScreenState extends State<AboutScreen> { | ||||
| class _AboutScreenState extends ConsumerState<AboutScreen> { | ||||
|   PackageInfo _packageInfo = PackageInfo( | ||||
|     appName: 'Island', | ||||
|     packageName: 'com.example.island', | ||||
|     appName: 'Solian', | ||||
|     packageName: 'dev.solsynth.solian', | ||||
|     version: '1.0.0', | ||||
|     buildNumber: '1', | ||||
|   ); | ||||
|   BaseDeviceInfo? _deviceInfo; | ||||
|   String? _deviceUdid; | ||||
|   bool _isLoading = true; | ||||
|   String? _errorMessage; | ||||
|  | ||||
| @@ -24,6 +37,7 @@ class _AboutScreenState extends State<AboutScreen> { | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _initPackageInfo(); | ||||
|     _initDeviceInfo(); | ||||
|   } | ||||
|  | ||||
|   Future<void> _initPackageInfo() async { | ||||
| @@ -38,13 +52,34 @@ class _AboutScreenState extends State<AboutScreen> { | ||||
|     } catch (e) { | ||||
|       if (mounted) { | ||||
|         setState(() { | ||||
|           _errorMessage = 'Failed to load package info: $e'; | ||||
|           _errorMessage = 'aboutScreenFailedToLoadPackageInfo'.tr( | ||||
|             args: [e.toString()], | ||||
|           ); | ||||
|           _isLoading = false; | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _initDeviceInfo() async { | ||||
|     try { | ||||
|       final deviceInfoPlugin = DeviceInfoPlugin(); | ||||
|       _deviceInfo = await deviceInfoPlugin.deviceInfo; | ||||
|       _deviceUdid = await getUdid(); | ||||
|       if (mounted) { | ||||
|         setState(() {}); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       if (mounted) { | ||||
|         setState(() { | ||||
|           _errorMessage = 'aboutScreenFailedToLoadDeviceInfo'.tr( | ||||
|             args: [e.toString()], | ||||
|           ); | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> _launchURL(String url) async { | ||||
|     final uri = Uri.parse(url); | ||||
|     if (await canLaunchUrl(uri)) { | ||||
| @@ -56,8 +91,8 @@ class _AboutScreenState extends State<AboutScreen> { | ||||
|   Widget build(BuildContext context) { | ||||
|     final theme = Theme.of(context); | ||||
|  | ||||
|     return Scaffold( | ||||
|       appBar: AppBar(title: const Text('About'), elevation: 0), | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar(title: Text('about'.tr()), elevation: 0), | ||||
|       body: | ||||
|           _isLoading | ||||
|               ? const Center(child: CircularProgressIndicator()) | ||||
| @@ -88,7 +123,9 @@ class _AboutScreenState extends State<AboutScreen> { | ||||
|                       ), | ||||
|                     ), | ||||
|                     Text( | ||||
|                       'Version ${_packageInfo.version} (${_packageInfo.buildNumber})', | ||||
|                       'aboutScreenVersionInfo'.tr( | ||||
|                         args: [_packageInfo.version, _packageInfo.buildNumber], | ||||
|                       ), | ||||
|                       style: theme.textTheme.bodyMedium?.copyWith( | ||||
|                         color: theme.textTheme.bodySmall?.color, | ||||
|                       ), | ||||
| @@ -98,40 +135,81 @@ class _AboutScreenState extends State<AboutScreen> { | ||||
|                     // App Info Card | ||||
|                     _buildSection( | ||||
|                       context, | ||||
|                       title: 'App Information', | ||||
|                       title: 'aboutScreenAppInfoSectionTitle'.tr(), | ||||
|                       children: [ | ||||
|                         _buildInfoItem( | ||||
|                           context, | ||||
|                           icon: Icons.info_outline, | ||||
|                           label: 'Package Name', | ||||
|                           icon: Symbols.info, | ||||
|                           label: 'aboutScreenPackageNameLabel'.tr(), | ||||
|                           value: _packageInfo.packageName, | ||||
|                         ), | ||||
|                         _buildInfoItem( | ||||
|                           context, | ||||
|                           icon: Icons.update, | ||||
|                           label: 'Version', | ||||
|                           icon: Symbols.update, | ||||
|                           label: 'aboutScreenVersionLabel'.tr(), | ||||
|                           value: _packageInfo.version, | ||||
|                         ), | ||||
|                         _buildInfoItem( | ||||
|                           context, | ||||
|                           icon: Icons.build, | ||||
|                           label: 'Build Number', | ||||
|                           icon: Symbols.build, | ||||
|                           label: 'aboutScreenBuildNumberLabel'.tr(), | ||||
|                           value: _packageInfo.buildNumber, | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|  | ||||
|                     if (_deviceInfo != null) const SizedBox(height: 16), | ||||
|  | ||||
|                     if (_deviceInfo != null) | ||||
|                       _buildSection( | ||||
|                         context, | ||||
|                         title: 'Device Information', | ||||
|                         children: [ | ||||
|                           _buildInfoItem( | ||||
|                             context, | ||||
|                             icon: Symbols.label, | ||||
|                             label: 'Device Name', | ||||
|                             value: _deviceInfo?.data['name'], | ||||
|                           ), | ||||
|                           _buildInfoItem( | ||||
|                             context, | ||||
|                             icon: Symbols.fingerprint, | ||||
|                             label: 'Device Identifier', | ||||
|                             value: _deviceUdid ?? 'N/A', | ||||
|                             copyable: true, | ||||
|                           ), | ||||
|                           const Divider(height: 1), | ||||
|                           _buildListTile( | ||||
|                             context, | ||||
|                             icon: Symbols.notifications_active, | ||||
|                             title: 'Reactivate Push Notifications', | ||||
|                             onTap: () async { | ||||
|                               showLoadingModal(context); | ||||
|                               try { | ||||
|                                 await subscribePushNotification( | ||||
|                                   ref.watch(apiClientProvider), | ||||
|                                 ); | ||||
|                               } catch (err) { | ||||
|                                 showErrorAlert(err); | ||||
|                               } finally { | ||||
|                                 if (context.mounted) hideLoadingModal(context); | ||||
|                               } | ||||
|                             }, | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|  | ||||
|                     const SizedBox(height: 16), | ||||
|  | ||||
|                     // Links Card | ||||
|                     _buildSection( | ||||
|                       context, | ||||
|                       title: 'Links', | ||||
|                       title: 'aboutScreenLinksSectionTitle'.tr(), | ||||
|                       children: [ | ||||
|                         _buildListTile( | ||||
|                           context, | ||||
|                           icon: Icons.privacy_tip_outlined, | ||||
|                           title: 'Privacy Policy', | ||||
|                           icon: Symbols.privacy_tip, | ||||
|                           title: 'aboutScreenPrivacyPolicyTitle'.tr(), | ||||
|                           onTap: | ||||
|                               () => _launchURL( | ||||
|                                 'https://solsynth.dev/terms/privacy-policy', | ||||
| @@ -139,17 +217,17 @@ class _AboutScreenState extends State<AboutScreen> { | ||||
|                         ), | ||||
|                         _buildListTile( | ||||
|                           context, | ||||
|                           icon: Icons.description_outlined, | ||||
|                           title: 'Terms of Service', | ||||
|                           icon: Symbols.description, | ||||
|                           title: 'aboutScreenTermsOfServiceTitle'.tr(), | ||||
|                           onTap: | ||||
|                               () => _launchURL( | ||||
|                                 'https://example.com/terms/basic-law', | ||||
|                                 'https://solsynth.dev/terms/basic-law', | ||||
|                               ), | ||||
|                         ), | ||||
|                         _buildListTile( | ||||
|                           context, | ||||
|                           icon: Icons.code, | ||||
|                           title: 'Open Source Licenses', | ||||
|                           icon: Symbols.code, | ||||
|                           title: 'aboutScreenOpenSourceLicensesTitle'.tr(), | ||||
|                           onTap: () { | ||||
|                             showLicensePage( | ||||
|                               context: context, | ||||
| @@ -167,21 +245,22 @@ class _AboutScreenState extends State<AboutScreen> { | ||||
|                     // Developer Info | ||||
|                     _buildSection( | ||||
|                       context, | ||||
|                       title: 'Developer', | ||||
|                       title: 'aboutScreenDeveloperSectionTitle'.tr(), | ||||
|                       children: [ | ||||
|                         _buildListTile( | ||||
|                           context, | ||||
|                           icon: Icons.email_outlined, | ||||
|                           title: 'Contact Us', | ||||
|                           icon: Symbols.email, | ||||
|                           title: 'aboutScreenContactUsTitle'.tr(), | ||||
|                           subtitle: 'lily@solsynth.dev', | ||||
|                           onTap: () => _launchURL('mailto:lily@solsynth.dev'), | ||||
|                         ), | ||||
|                         _buildListTile( | ||||
|                           context, | ||||
|                           icon: Icons.copyright, | ||||
|                           title: 'License', | ||||
|                           subtitle: | ||||
|                               'Copyright reserved © ${DateTime.now().year} Solsynth\nGNU Affero General Public License v3.0', | ||||
|                           icon: Symbols.copyright, | ||||
|                           title: 'aboutScreenLicenseTitle'.tr(), | ||||
|                           subtitle: 'aboutScreenLicenseContent'.tr( | ||||
|                             args: [DateTime.now().year.toString()], | ||||
|                           ), | ||||
|                           onTap: | ||||
|                               () => _launchURL( | ||||
|                                 'https://github.com/Solsynth/Solian/blob/v3/LICENSE.txt', | ||||
| @@ -195,12 +274,25 @@ class _AboutScreenState extends State<AboutScreen> { | ||||
|                     // Copyright | ||||
|                     Padding( | ||||
|                       padding: const EdgeInsets.all(16.0), | ||||
|                       child: Text( | ||||
|                         '© ${DateTime.now().year} ${_packageInfo.appName}. All rights reserved.', | ||||
|                       child: Column( | ||||
|                         children: [ | ||||
|                           Text( | ||||
|                             'aboutScreenCopyright'.tr( | ||||
|                               args: [DateTime.now().year.toString()], | ||||
|                             ), | ||||
|                             style: theme.textTheme.bodySmall, | ||||
|                             textAlign: TextAlign.center, | ||||
|                           ), | ||||
|                           const Gap(1), | ||||
|                           Text( | ||||
|                             'aboutScreenMadeWith'.tr(), | ||||
|                             textAlign: TextAlign.center, | ||||
|                           ).fontSize(10).opacity(0.8), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ), | ||||
|  | ||||
|                     Gap(MediaQuery.of(context).padding.bottom + 16), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
| @@ -238,6 +330,7 @@ class _AboutScreenState extends State<AboutScreen> { | ||||
|     required IconData icon, | ||||
|     required String label, | ||||
|     required String value, | ||||
|     bool copyable = false, | ||||
|   }) { | ||||
|     return Padding( | ||||
|       padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), | ||||
| @@ -254,22 +347,23 @@ class _AboutScreenState extends State<AboutScreen> { | ||||
|                 SelectableText( | ||||
|                   value, | ||||
|                   style: Theme.of(context).textTheme.bodyMedium, | ||||
|                   maxLines: copyable ? 1 : null, | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|           if (value.startsWith('http') || value.contains('@')) | ||||
|           if (value.startsWith('http') || value.contains('@') || copyable) | ||||
|             IconButton( | ||||
|               icon: const Icon(Icons.copy, size: 16), | ||||
|               icon: const Icon(Symbols.content_copy, size: 16), | ||||
|               onPressed: () { | ||||
|                 Clipboard.setData(ClipboardData(text: value)); | ||||
|                 ScaffoldMessenger.of(context).showSnackBar( | ||||
|                   const SnackBar(content: Text('Copied to clipboard')), | ||||
|                   SnackBar(content: Text('copiedToClipboard'.tr())), | ||||
|                 ); | ||||
|               }, | ||||
|               padding: EdgeInsets.zero, | ||||
|               constraints: const BoxConstraints(), | ||||
|               tooltip: 'Copy to clipboard', | ||||
|               tooltip: 'copyToClipboardTooltip'.tr(), | ||||
|             ), | ||||
|         ], | ||||
|       ), | ||||
| @@ -283,13 +377,18 @@ class _AboutScreenState extends State<AboutScreen> { | ||||
|     String? subtitle, | ||||
|     required VoidCallback onTap, | ||||
|   }) { | ||||
|     final multipleLines = subtitle?.contains('\n') ?? false; | ||||
|     return Column( | ||||
|       children: [ | ||||
|         ListTile( | ||||
|           leading: Icon(icon), | ||||
|           leading: Icon(icon).padding(top: multipleLines ? 8 : 0), | ||||
|           title: Text(title), | ||||
|           subtitle: subtitle != null ? Text(subtitle) : null, | ||||
|           trailing: const Icon(Icons.chevron_right), | ||||
|           isThreeLine: multipleLines, | ||||
|           trailing: const Icon( | ||||
|             Symbols.chevron_right, | ||||
|           ).padding(top: multipleLines ? 8 : 0), | ||||
|           shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), | ||||
|           onTap: onTap, | ||||
|           contentPadding: const EdgeInsets.symmetric(horizontal: 16), | ||||
|           minLeadingWidth: 24, | ||||
|   | ||||
| @@ -59,7 +59,7 @@ class AccountScreen extends HookConsumerWidget { | ||||
|       notificationUnreadCountNotifierProvider, | ||||
|     ); | ||||
|  | ||||
|     if (!user.hasValue || user.value == null) { | ||||
|     if (user.value == null || user.value == null) { | ||||
|       return _UnauthorizedAccountScreen(); | ||||
|     } | ||||
|  | ||||
| @@ -222,9 +222,17 @@ class AccountScreen extends HookConsumerWidget { | ||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||
|               title: Text('relationships').tr(), | ||||
|               onTap: () { | ||||
|                 context.push('/account/relationship'); | ||||
|                 context.push('/account/relationships'); | ||||
|               }, | ||||
|             ), | ||||
|             ListTile( | ||||
|               minTileHeight: 48, | ||||
|               title: Text('abuseReports').tr(), | ||||
|               contentPadding: const EdgeInsets.only(left: 24, right: 17), | ||||
|               leading: const Icon(Symbols.gavel), | ||||
|               trailing: const Icon(Symbols.chevron_right), | ||||
|               onTap: () => context.push('/safety/reports/me'), | ||||
|             ), | ||||
|             const Divider(height: 1).padding(vertical: 8), | ||||
|             ListTile( | ||||
|               minTileHeight: 48, | ||||
| @@ -367,12 +375,23 @@ class _UnauthorizedAccountScreen extends StatelessWidget { | ||||
|                   ), | ||||
|                 ), | ||||
|                 const Gap(8), | ||||
|                 Row( | ||||
|                   mainAxisAlignment: MainAxisAlignment.center, | ||||
|                   children: [ | ||||
|                     TextButton( | ||||
|                       onPressed: () { | ||||
|                         context.push('/about'); | ||||
|                       }, | ||||
|                       child: Text('about').tr(), | ||||
|                     ), | ||||
|                     TextButton( | ||||
|                       onPressed: () { | ||||
|                         context.push('/settings'); | ||||
|                       }, | ||||
|                       child: Text('appSettings').tr(), | ||||
|                 ).center(), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ).center(), | ||||
|   | ||||
| @@ -82,7 +82,7 @@ class EventCalanderScreen extends HookConsumerWidget { | ||||
|                       ), | ||||
|  | ||||
|                       // Show user profile if viewing someone else's calendar | ||||
|                       if (name != 'me' && user.hasValue) | ||||
|                       if (name != 'me' && user.value != null) | ||||
|                         AccountNameplate(name: name), | ||||
|                     ], | ||||
|                   ), | ||||
| @@ -106,7 +106,7 @@ class EventCalanderScreen extends HookConsumerWidget { | ||||
|                     ).padding(horizontal: 8, vertical: 4), | ||||
|  | ||||
|                     // Show user profile if viewing someone else's calendar | ||||
|                     if (name != 'me' && user.hasValue) | ||||
|                     if (name != 'me' && user.value != null) | ||||
|                       AccountNameplate(name: name), | ||||
|                     Gap(MediaQuery.of(context).padding.bottom + 16), | ||||
|                   ], | ||||
|   | ||||
| @@ -1,4 +1,7 @@ | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:google_fonts/google_fonts.dart'; | ||||
| @@ -14,7 +17,9 @@ import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/payment/payment_overlay.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| part 'leveling.g.dart'; | ||||
|  | ||||
| @@ -84,35 +89,6 @@ class LevelingScreen extends HookConsumerWidget { | ||||
|                 // Membership section | ||||
|                 _buildMembershipSection(context, ref, stellarSubscription), | ||||
|                 const Gap(16), | ||||
|  | ||||
|                 // Unlocked features section | ||||
|                 Container( | ||||
|                   width: double.infinity, | ||||
|                   padding: const EdgeInsets.all(16), | ||||
|                   decoration: BoxDecoration( | ||||
|                     color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||
|                     borderRadius: BorderRadius.circular(12), | ||||
|                   ), | ||||
|                   child: Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                     children: [ | ||||
|                       Text( | ||||
|                         'unlockedFeatures'.tr(), | ||||
|                         style: TextStyle( | ||||
|                           fontSize: 18, | ||||
|                           fontWeight: FontWeight.bold, | ||||
|                         ), | ||||
|                       ), | ||||
|                       const Gap(8), | ||||
|                       Text( | ||||
|                         'unlockedFeaturesDescription'.tr(), | ||||
|                         style: Theme.of(context).textTheme.bodyMedium?.copyWith( | ||||
|                           color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
| @@ -292,6 +268,31 @@ class LevelingScreen extends HookConsumerWidget { | ||||
|   ) { | ||||
|     final isActive = membership?.isActive ?? false; | ||||
|  | ||||
|     Future<void> membershipCancel() async { | ||||
|       if (!isActive || membership == null) return; | ||||
|  | ||||
|       final confirm = await showConfirmAlert( | ||||
|         'membershipCancelHint'.tr(), | ||||
|         'membershipCancelConfirm'.tr(), | ||||
|       ); | ||||
|       if (!confirm || !context.mounted) return; | ||||
|  | ||||
|       try { | ||||
|         showLoadingModal(context); | ||||
|         final client = ref.watch(apiClientProvider); | ||||
|         await client.post('/subscriptions/${membership.identifier}/cancel'); | ||||
|         ref.invalidate(accountStellarSubscriptionProvider); | ||||
|         ref.read(userInfoProvider.notifier).fetchUser(); | ||||
|         if (context.mounted) { | ||||
|           hideLoadingModal(context); | ||||
|           showSnackBar('membershipCancelSuccess'.tr()); | ||||
|         } | ||||
|       } catch (err) { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|         showErrorAlert(err); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return Container( | ||||
|       width: double.infinity, | ||||
|       padding: const EdgeInsets.all(16), | ||||
| @@ -307,7 +308,7 @@ class LevelingScreen extends HookConsumerWidget { | ||||
|         borderRadius: BorderRadius.circular(12), | ||||
|       ), | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           Row( | ||||
|             children: [ | ||||
| @@ -327,19 +328,34 @@ class LevelingScreen extends HookConsumerWidget { | ||||
|  | ||||
|           if (isActive) ...[ | ||||
|             _buildCurrentMembershipCard(context, membership!), | ||||
|             const Gap(16), | ||||
|             const Gap(12), | ||||
|             FilledButton.icon( | ||||
|               style: ButtonStyle( | ||||
|                 backgroundColor: WidgetStateProperty.all( | ||||
|                   Theme.of(context).colorScheme.error, | ||||
|                 ), | ||||
|                 foregroundColor: WidgetStateProperty.all( | ||||
|                   Theme.of(context).colorScheme.onError, | ||||
|                 ), | ||||
|               ), | ||||
|               onPressed: membershipCancel, | ||||
|               icon: const Icon(Symbols.cancel), | ||||
|               label: Text('membershipCancel'.tr()), | ||||
|             ), | ||||
|           ], | ||||
|  | ||||
|           if (!isActive) ...[ | ||||
|             Text( | ||||
|             isActive ? 'upgradeYourPlan'.tr() : 'chooseYourPlan'.tr(), | ||||
|               'chooseYourPlan'.tr(), | ||||
|               style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), | ||||
|             ), | ||||
|             const Gap(12), | ||||
|  | ||||
|             _buildMembershipTiers(context, ref, membership), | ||||
|           const Gap(12), | ||||
|           ], | ||||
|  | ||||
|           // Restore Purchase Button | ||||
|           // As you know Apple platform need IAP | ||||
|           if (kIsWeb || !(Platform.isIOS || Platform.isMacOS)) | ||||
|             OutlinedButton.icon( | ||||
|               onPressed: () => _showRestorePurchaseSheet(context, ref), | ||||
|               icon: const Icon(Icons.restore), | ||||
| @@ -347,7 +363,7 @@ class LevelingScreen extends HookConsumerWidget { | ||||
|               style: OutlinedButton.styleFrom( | ||||
|                 minimumSize: const Size(double.infinity, 48), | ||||
|               ), | ||||
|           ), | ||||
|             ).padding(top: 12), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
| @@ -410,33 +426,18 @@ class LevelingScreen extends HookConsumerWidget { | ||||
|         'id': 'solian.stellar.primary', | ||||
|         'name': 'membershipTierStellar'.tr(), | ||||
|         'price': 'membershipPriceStellar'.tr(), | ||||
|         'features': [ | ||||
|           'membershipFeatureBasic'.tr(), | ||||
|           'membershipFeaturePrioritySupport'.tr(), | ||||
|           'membershipFeatureAdFree'.tr(), | ||||
|         ], | ||||
|         'color': Colors.blue, | ||||
|       }, | ||||
|       { | ||||
|         'id': 'solian.stellar.nova', | ||||
|         'name': 'membershipTierNova'.tr(), | ||||
|         'price': 'membershipPriceNova'.tr(), | ||||
|         'features': [ | ||||
|           'membershipFeatureAllPrimary'.tr(), | ||||
|           'membershipFeatureAdvancedCustomization'.tr(), | ||||
|           'membershipFeatureEarlyAccess'.tr(), | ||||
|         ], | ||||
|         'color': Colors.purple, | ||||
|         'color': Colors.indigo, | ||||
|       }, | ||||
|       { | ||||
|         'id': 'solian.stellar.supernova', | ||||
|         'name': 'membershipTierSupernova'.tr(), | ||||
|         'price': 'membershipPriceSupernova'.tr(), | ||||
|         'features': [ | ||||
|           'membershipFeatureAllNova'.tr(), | ||||
|           'membershipFeatureExclusiveContent'.tr(), | ||||
|           'membershipFeatureVipSupport'.tr(), | ||||
|         ], | ||||
|         'color': Colors.orange, | ||||
|       }, | ||||
|     ]; | ||||
|   | ||||
| @@ -22,6 +22,7 @@ import 'package:island/widgets/account/status.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/safety/abuse_report_helper.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:palette_generator/palette_generator.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| @@ -72,6 +73,8 @@ Future<Color?> accountAppbarForcegroundColor(Ref ref, String uname) async { | ||||
|  | ||||
| @riverpod | ||||
| Future<SnChatRoom?> accountDirectChat(Ref ref, String uname) async { | ||||
|   final userInfo = ref.watch(userInfoProvider); | ||||
|   if (userInfo.value == null) return null; | ||||
|   final account = await ref.watch(accountProvider(uname).future); | ||||
|   final apiClient = ref.watch(apiClientProvider); | ||||
|   try { | ||||
| @@ -87,6 +90,8 @@ Future<SnChatRoom?> accountDirectChat(Ref ref, String uname) async { | ||||
|  | ||||
| @riverpod | ||||
| Future<SnRelationship?> accountRelationship(Ref ref, String uname) async { | ||||
|   final userInfo = ref.watch(userInfoProvider); | ||||
|   if (userInfo.value == null) return null; | ||||
|   final account = await ref.watch(accountProvider(uname).future); | ||||
|   final apiClient = ref.watch(apiClientProvider); | ||||
|   try { | ||||
| @@ -139,6 +144,23 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Future<void> blockAction() async { | ||||
|       showLoadingModal(context); | ||||
|       try { | ||||
|         final client = ref.watch(apiClientProvider); | ||||
|         if (accountRelationship.value == null) { | ||||
|           await client.post('/relationships/${account.value!.id}/block'); | ||||
|         } else { | ||||
|           await client.delete('/relationships/${account.value!.id}/block'); | ||||
|         } | ||||
|         ref.invalidate(accountRelationshipProvider(name)); | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         if (context.mounted) hideLoadingModal(context); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Future<void> directMessageAction() async { | ||||
|       if (!account.hasValue) return; | ||||
|       if (accountChat.value != null) { | ||||
| @@ -219,6 +241,8 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|       ]; | ||||
|     } | ||||
|  | ||||
|     final user = ref.watch(userInfoProvider); | ||||
|  | ||||
|     return account.when( | ||||
|       data: | ||||
|           (data) => AppScaffold( | ||||
| @@ -379,13 +403,19 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|                   ).padding(horizontal: 24), | ||||
|                 ), | ||||
|  | ||||
|                 if (user.value != null) | ||||
|                   SliverToBoxAdapter( | ||||
|                   child: const Divider(height: 1).padding(top: 24, bottom: 12), | ||||
|                     child: const Divider( | ||||
|                       height: 1, | ||||
|                     ).padding(top: 24, bottom: 12), | ||||
|                   ), | ||||
|                 if (user.value != null) | ||||
|                   SliverToBoxAdapter( | ||||
|                     child: Row( | ||||
|                       spacing: 8, | ||||
|                       children: [ | ||||
|                         if (accountRelationship.value == null || | ||||
|                             accountRelationship.value!.status > -100) | ||||
|                           Expanded( | ||||
|                             child: FilledButton.icon( | ||||
|                               style: ButtonStyle( | ||||
| @@ -397,7 +427,9 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|                                 foregroundColor: WidgetStatePropertyAll( | ||||
|                                   accountRelationship.value == null | ||||
|                                       ? null | ||||
|                                   : Theme.of(context).colorScheme.onSecondary, | ||||
|                                       : Theme.of( | ||||
|                                         context, | ||||
|                                       ).colorScheme.onSecondary, | ||||
|                                 ), | ||||
|                               ), | ||||
|                               onPressed: relationshipAction, | ||||
| @@ -413,6 +445,44 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|                                       : const Icon(Symbols.person_check), | ||||
|                             ), | ||||
|                           ), | ||||
|                         if (accountRelationship.value == null || | ||||
|                             accountRelationship.value!.status <= -100) | ||||
|                           Expanded( | ||||
|                             child: FilledButton.icon( | ||||
|                               style: ButtonStyle( | ||||
|                                 backgroundColor: WidgetStatePropertyAll( | ||||
|                                   accountRelationship.value == null | ||||
|                                       ? null | ||||
|                                       : Theme.of(context).colorScheme.secondary, | ||||
|                                 ), | ||||
|                                 foregroundColor: WidgetStatePropertyAll( | ||||
|                                   accountRelationship.value == null | ||||
|                                       ? null | ||||
|                                       : Theme.of( | ||||
|                                         context, | ||||
|                                       ).colorScheme.onSecondary, | ||||
|                                 ), | ||||
|                               ), | ||||
|                               onPressed: blockAction, | ||||
|                               label: | ||||
|                                   Text( | ||||
|                                     accountRelationship.value == null | ||||
|                                         ? 'blockUser' | ||||
|                                         : 'unblockUser', | ||||
|                                   ).tr(), | ||||
|                               icon: | ||||
|                                   accountRelationship.value == null | ||||
|                                       ? const Icon(Symbols.block) | ||||
|                                       : const Icon(Symbols.person_cancel), | ||||
|                             ), | ||||
|                           ), | ||||
|                       ], | ||||
|                     ).padding(horizontal: 16), | ||||
|                   ), | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: Row( | ||||
|                     spacing: 8, | ||||
|                     children: [ | ||||
|                       Expanded( | ||||
|                         child: FilledButton.icon( | ||||
|                           onPressed: directMessageAction, | ||||
| @@ -426,8 +496,25 @@ class AccountProfileScreen extends HookConsumerWidget { | ||||
|                               ).tr(), | ||||
|                         ), | ||||
|                       ), | ||||
|                       IconButton.filled( | ||||
|                         onPressed: () { | ||||
|                           showAbuseReportSheet( | ||||
|                             context, | ||||
|                             resourceIdentifier: 'account/${data.id}', | ||||
|                           ); | ||||
|                         }, | ||||
|                         icon: Icon( | ||||
|                           Symbols.flag, | ||||
|                           color: Theme.of(context).colorScheme.onError, | ||||
|                         ), | ||||
|                         style: ButtonStyle( | ||||
|                           backgroundColor: WidgetStatePropertyAll( | ||||
|                             Theme.of(context).colorScheme.error, | ||||
|                           ), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ).padding(horizontal: 16), | ||||
|                   ).padding(horizontal: 16, top: 4), | ||||
|                 ), | ||||
|                 SliverToBoxAdapter( | ||||
|                   child: const Divider(height: 1).padding(top: 12), | ||||
|   | ||||
| @@ -395,7 +395,7 @@ class _AccountAppbarForcegroundColorProviderElement | ||||
|   String get uname => (origin as AccountAppbarForcegroundColorProvider).uname; | ||||
| } | ||||
|  | ||||
| String _$accountDirectChatHash() => r'60d0015fc2a3c8fc2190bb41d6818cf3027d9d0a'; | ||||
| String _$accountDirectChatHash() => r'3d28c8ba8079159f724fe3cd47bbe00db55cedcc'; | ||||
|  | ||||
| /// See also [accountDirectChat]. | ||||
| @ProviderFor(accountDirectChat) | ||||
| @@ -517,7 +517,7 @@ class _AccountDirectChatProviderElement | ||||
| } | ||||
|  | ||||
| String _$accountRelationshipHash() => | ||||
|     r'cb7d0d3f8cd4f23ad9d2d529872c540dac483d4f'; | ||||
|     r'0be2420e1f6a65b8dcead9617191471924aaf232'; | ||||
|  | ||||
| /// See also [accountRelationship]. | ||||
| @ProviderFor(accountRelationship) | ||||
|   | ||||
| @@ -1,105 +0,0 @@ | ||||
| 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/widgets/content/markdown.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
| import 'package:island/models/webfeed.dart'; | ||||
| import 'package:island/pods/article_detail.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/loading_indicator.dart'; | ||||
| import 'package:html2md/html2md.dart' as html2md; | ||||
|  | ||||
| class ArticleDetailScreen extends ConsumerWidget { | ||||
|   final String articleId; | ||||
|  | ||||
|   const ArticleDetailScreen({super.key, required this.articleId}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final articleAsync = ref.watch(articleDetailProvider(articleId)); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       body: articleAsync.when( | ||||
|         data: | ||||
|             (article) => AppScaffold( | ||||
|               appBar: AppBar( | ||||
|                 leading: const BackButton(), | ||||
|                 title: Text(article.title), | ||||
|               ), | ||||
|               body: _ArticleDetailContent(article: article), | ||||
|             ), | ||||
|         loading: () => const Center(child: LoadingIndicator()), | ||||
|         error: | ||||
|             (error, stackTrace) => | ||||
|                 Center(child: Text('Failed to load article: $error')), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _ArticleDetailContent extends HookConsumerWidget { | ||||
|   final SnWebArticle article; | ||||
|  | ||||
|   const _ArticleDetailContent({required this.article}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final markdownContent = useMemoized( | ||||
|       () => html2md.convert(article.content ?? ''), | ||||
|       [article], | ||||
|     ); | ||||
|  | ||||
|     return SingleChildScrollView( | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|         children: [ | ||||
|           if (article.preview?.imageUrl != null) | ||||
|             Image.network( | ||||
|               article.preview!.imageUrl!, | ||||
|               width: double.infinity, | ||||
|               height: 200, | ||||
|               fit: BoxFit.cover, | ||||
|             ), | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.all(16.0), | ||||
|             child: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 Text( | ||||
|                   article.title, | ||||
|                   style: Theme.of(context).textTheme.headlineSmall, | ||||
|                 ), | ||||
|                 const SizedBox(height: 8), | ||||
|                 if (article.feed?.title != null) | ||||
|                   Text( | ||||
|                     article.feed!.title, | ||||
|                     style: Theme.of(context).textTheme.bodyMedium?.copyWith( | ||||
|                       color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|                     ), | ||||
|                   ), | ||||
|                 const Divider(height: 32), | ||||
|                 if (article.content != null) | ||||
|                   ...MarkdownTextContent.buildGenerator( | ||||
|                     isDark: Theme.of(context).brightness == Brightness.dark, | ||||
|                   ).buildWidgets(markdownContent) | ||||
|                 else if (article.preview?.description != null) | ||||
|                   Text(article.preview!.description!), | ||||
|                 const Gap(24), | ||||
|                 FilledButton( | ||||
|                   onPressed: | ||||
|                       () => launchUrlString( | ||||
|                         article.url, | ||||
|                         mode: LaunchMode.externalApplication, | ||||
|                       ), | ||||
|                   child: const Text('Read Full Article'), | ||||
|                 ), | ||||
|                 Gap(MediaQuery.of(context).padding.bottom), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -108,15 +108,18 @@ class CreatorHubShellScreen extends StatelessWidget { | ||||
|   Widget build(BuildContext context) { | ||||
|     final isWide = isWideScreen(context); | ||||
|     if (isWide) { | ||||
|       return Row( | ||||
|       return AppBackground( | ||||
|         isRoot: true, | ||||
|         child: Row( | ||||
|           children: [ | ||||
|             SizedBox(width: 360, child: const CreatorHubScreen(isAside: true)), | ||||
|             const VerticalDivider(width: 1), | ||||
|             Expanded(child: child), | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|     return child; | ||||
|     return AppBackground(isRoot: true, child: child); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @@ -198,7 +201,6 @@ class CreatorHubScreen extends HookConsumerWidget { | ||||
|     ); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       noBackground: false, | ||||
|       appBar: AppBar( | ||||
|         leading: !isWide ? const PageBackButton() : null, | ||||
|         title: Text('creatorHub').tr(), | ||||
| @@ -322,9 +324,7 @@ class CreatorHubScreen extends HookConsumerWidget { | ||||
|                             subtitle: Text('createPublisherHint').tr(), | ||||
|                             trailing: const Icon(Symbols.chevron_right), | ||||
|                             onTap: () { | ||||
|                               context.push('/creators/publishers/new').then(( | ||||
|                                 value, | ||||
|                               ) { | ||||
|                               context.push('/creators/new').then((value) { | ||||
|                                 if (value != null) { | ||||
|                                   ref.invalidate(publishersManagedProvider); | ||||
|                                 } | ||||
|   | ||||
| @@ -26,7 +26,7 @@ class CreatorPostListScreen extends HookConsumerWidget { | ||||
|                 children: [ | ||||
|                   ListTile( | ||||
|                     leading: const Icon(Symbols.edit), | ||||
|                     title: Text('postContent'.tr()), | ||||
|                     title: Text('Post'), | ||||
|                     subtitle: Text('Create a regular post'), | ||||
|                     onTap: () async { | ||||
|                       Navigator.pop(context); | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/webfeed.dart'; | ||||
| import 'package:island/pods/webfeed.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @@ -183,7 +184,7 @@ class WebFeedEditScreen extends HookConsumerWidget { | ||||
|       } | ||||
|     }, [pubName, feedId, ref, context]); | ||||
|  | ||||
|     return Scaffold( | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar( | ||||
|         title: Text(hasFeedId ? 'Edit Web Feed' : 'New Web Feed'), | ||||
|         actions: [ | ||||
|   | ||||
| @@ -47,15 +47,21 @@ class DeveloperHubShellScreen extends StatelessWidget { | ||||
|   Widget build(BuildContext context) { | ||||
|     final isWide = isWideScreen(context); | ||||
|     if (isWide) { | ||||
|       return Row( | ||||
|       return AppBackground( | ||||
|         isRoot: true, | ||||
|         child: Row( | ||||
|           children: [ | ||||
|           SizedBox(width: 360, child: const DeveloperHubScreen(isAside: true)), | ||||
|             SizedBox( | ||||
|               width: 360, | ||||
|               child: const DeveloperHubScreen(isAside: true), | ||||
|             ), | ||||
|             const VerticalDivider(width: 1), | ||||
|             Expanded(child: child), | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|     return child; | ||||
|     return AppBackground(isRoot: true, child: child); | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										110
									
								
								lib/screens/discovery/article_detail.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								lib/screens/discovery/article_detail.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,110 @@ | ||||
| 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/widgets/content/markdown.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
| import 'package:island/models/webfeed.dart'; | ||||
| import 'package:island/pods/article_detail.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/loading_indicator.dart'; | ||||
| import 'package:html2md/html2md.dart' as html2md; | ||||
|  | ||||
| class ArticleDetailScreen extends ConsumerWidget { | ||||
|   final String articleId; | ||||
|  | ||||
|   const ArticleDetailScreen({super.key, required this.articleId}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final articleAsync = ref.watch(articleDetailProvider(articleId)); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       body: articleAsync.when( | ||||
|         data: | ||||
|             (article) => AppScaffold( | ||||
|               appBar: AppBar( | ||||
|                 leading: const BackButton(), | ||||
|                 title: Text(article.title), | ||||
|               ), | ||||
|               body: _ArticleDetailContent(article: article), | ||||
|             ), | ||||
|         loading: () => const Center(child: LoadingIndicator()), | ||||
|         error: | ||||
|             (error, stackTrace) => | ||||
|                 Center(child: Text('Failed to load article: $error')), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _ArticleDetailContent extends HookConsumerWidget { | ||||
|   final SnWebArticle article; | ||||
|  | ||||
|   const _ArticleDetailContent({required this.article}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final markdownContent = useMemoized( | ||||
|       () => html2md.convert(article.content ?? ''), | ||||
|       [article], | ||||
|     ); | ||||
|  | ||||
|     return SingleChildScrollView( | ||||
|       child: Center( | ||||
|         child: ConstrainedBox( | ||||
|           constraints: const BoxConstraints(maxWidth: 560), | ||||
|           child: Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|             children: [ | ||||
|               if (article.preview?.imageUrl != null) | ||||
|                 Image.network( | ||||
|                   article.preview!.imageUrl!, | ||||
|                   width: double.infinity, | ||||
|                   height: 200, | ||||
|                   fit: BoxFit.cover, | ||||
|                 ), | ||||
|               Padding( | ||||
|                 padding: const EdgeInsets.all(16.0), | ||||
|                 child: Column( | ||||
|                   crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                   children: [ | ||||
|                     Text( | ||||
|                       article.title, | ||||
|                       style: Theme.of(context).textTheme.headlineSmall, | ||||
|                     ), | ||||
|                     const SizedBox(height: 8), | ||||
|                     if (article.feed?.title != null) | ||||
|                       Text( | ||||
|                         article.feed!.title, | ||||
|                         style: Theme.of(context).textTheme.bodyMedium?.copyWith( | ||||
|                           color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|                         ), | ||||
|                       ), | ||||
|                     const Divider(height: 32), | ||||
|                     if (article.content != null) | ||||
|                       ...MarkdownTextContent.buildGenerator( | ||||
|                         isDark: Theme.of(context).brightness == Brightness.dark, | ||||
|                       ).buildWidgets(markdownContent) | ||||
|                     else if (article.preview?.description != null) | ||||
|                       Text(article.preview!.description!), | ||||
|                     const Gap(24), | ||||
|                     FilledButton( | ||||
|                       onPressed: | ||||
|                           () => launchUrlString( | ||||
|                             article.url, | ||||
|                             mode: LaunchMode.externalApplication, | ||||
|                           ), | ||||
|                       child: const Text('Read Full Article'), | ||||
|                     ), | ||||
|                     Gap(MediaQuery.of(context).padding.bottom), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/webfeed.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/web_article_card.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||
| @@ -124,9 +125,12 @@ class ArticlesScreen extends ConsumerWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     return Scaffold( | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar(title: Text(title ?? 'Articles')), | ||||
|       body: CustomScrollView( | ||||
|       body: Center( | ||||
|         child: ConstrainedBox( | ||||
|           constraints: const BoxConstraints(maxWidth: 560), | ||||
|           child: CustomScrollView( | ||||
|             slivers: [ | ||||
|               SliverPadding( | ||||
|                 padding: const EdgeInsets.only(top: 8, left: 8, right: 8), | ||||
| @@ -137,6 +141,8 @@ class ArticlesScreen extends ConsumerWidget { | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -307,7 +307,7 @@ class _ActivityListView extends HookConsumerWidget { | ||||
|  | ||||
|     return CustomScrollView( | ||||
|       slivers: [ | ||||
|         if (user.hasValue && !contentOnly) | ||||
|         if (user.value != null && !contentOnly) | ||||
|           SliverToBoxAdapter(child: CheckInWidget()), | ||||
|         SliverList.builder( | ||||
|           itemCount: widgetCount, | ||||
| @@ -338,7 +338,7 @@ class _ActivityListView extends HookConsumerWidget { | ||||
|                             bottom: 16, | ||||
|                           ) | ||||
|                           : null, | ||||
|                   onRefresh: (_) { | ||||
|                   onRefresh: () { | ||||
|                     activitiesNotifier.forceRefresh(); | ||||
|                   }, | ||||
|                   onUpdate: (post) { | ||||
|   | ||||
| @@ -33,6 +33,8 @@ sealed class PostComposeInitialState with _$PostComposeInitialState { | ||||
|     String? content, | ||||
|     @Default([]) List<UniversalFile> attachments, | ||||
|     int? visibility, | ||||
|     SnPost? replyingTo, | ||||
|     SnPost? forwardingTo, | ||||
|   }) = _PostComposeInitialState; | ||||
|  | ||||
|   factory PostComposeInitialState.fromJson(Map<String, dynamic> json) => | ||||
| @@ -66,23 +68,22 @@ class PostEditScreen extends HookConsumerWidget { | ||||
|  | ||||
| class PostComposeScreen extends HookConsumerWidget { | ||||
|   final SnPost? originalPost; | ||||
|   final SnPost? repliedPost; | ||||
|   final SnPost? forwardedPost; | ||||
|   final int? type; | ||||
|   final PostComposeInitialState? initialState; | ||||
|   const PostComposeScreen({ | ||||
|     super.key, | ||||
|     this.originalPost, | ||||
|     this.repliedPost, | ||||
|     this.forwardedPost, | ||||
|     this.type, | ||||
|     this.initialState, | ||||
|     this.originalPost, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     // Determine the compose type: auto-detect from edited post or use query parameter | ||||
|     final composeType = originalPost?.type ?? type ?? 0; | ||||
|     final repliedPost = initialState?.replyingTo ?? originalPost?.repliedPost; | ||||
|     final forwardedPost = | ||||
|         initialState?.forwardingTo ?? originalPost?.forwardedPost; | ||||
|  | ||||
|     // If type is 1 (article), return ArticleComposeScreen | ||||
|     if (composeType == 1) { | ||||
| @@ -136,8 +137,11 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|     // Initialize publisher once when data is available | ||||
|     useEffect(() { | ||||
|       if (publishers.value?.isNotEmpty ?? false) { | ||||
|         if (state.currentPublisher.value == null) { | ||||
|           // If no publisher is set, use the first available one | ||||
|           state.currentPublisher.value = publishers.value!.first; | ||||
|         } | ||||
|       } | ||||
|       return null; | ||||
|     }, [publishers]); | ||||
|  | ||||
| @@ -480,8 +484,10 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|  | ||||
|   Widget _buildInfoBanner(BuildContext context) { | ||||
|     // When editing, preserve the original replied/forwarded post references | ||||
|     final effectiveRepliedPost = repliedPost ?? originalPost?.repliedPost; | ||||
|     final effectiveForwardedPost = forwardedPost ?? originalPost?.forwardedPost; | ||||
|     final effectiveRepliedPost = | ||||
|         initialState?.replyingTo ?? originalPost?.repliedPost; | ||||
|     final effectiveForwardedPost = | ||||
|         initialState?.forwardingTo ?? originalPost?.forwardedPost; | ||||
|  | ||||
|     // Show editing banner when editing a post | ||||
|     if (originalPost != null) { | ||||
| @@ -497,15 +503,15 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|                   size: 16, | ||||
|                   color: Theme.of(context).colorScheme.onPrimaryContainer, | ||||
|                 ), | ||||
|                 const Gap(4), | ||||
|                 const Gap(8), | ||||
|                 Text( | ||||
|                   'edit'.tr(), | ||||
|                   'postEditing'.tr(), | ||||
|                   style: Theme.of(context).textTheme.labelMedium?.copyWith( | ||||
|                     color: Theme.of(context).colorScheme.onPrimaryContainer, | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ).padding(all: 16), | ||||
|             ).padding(horizontal: 16, vertical: 8), | ||||
|           ), | ||||
|           // Show reply/forward banners below editing banner if they exist | ||||
|           if (effectiveRepliedPost != null) | ||||
| @@ -615,6 +621,7 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|         showModalBottomSheet( | ||||
|           context: context, | ||||
|           isScrollControlled: true, | ||||
|           backgroundColor: Colors.transparent, | ||||
|           builder: | ||||
|               (context) => DraggableScrollableSheet( | ||||
|                 initialChildSize: 0.7, | ||||
|   | ||||
| @@ -16,7 +16,7 @@ T _$identity<T>(T value) => value; | ||||
| /// @nodoc | ||||
| mixin _$PostComposeInitialState { | ||||
|  | ||||
|  String? get title; String? get description; String? get content; List<UniversalFile> get attachments; int? get visibility; | ||||
|  String? get title; String? get description; String? get content; List<UniversalFile> get attachments; int? get visibility; SnPost? get replyingTo; SnPost? get forwardingTo; | ||||
| /// Create a copy of PostComposeInitialState | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @@ -29,16 +29,16 @@ $PostComposeInitialStateCopyWith<PostComposeInitialState> get copyWith => _$Post | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is PostComposeInitialState&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.content, content) || other.content == content)&&const DeepCollectionEquality().equals(other.attachments, attachments)&&(identical(other.visibility, visibility) || other.visibility == visibility)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is PostComposeInitialState&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.content, content) || other.content == content)&&const DeepCollectionEquality().equals(other.attachments, attachments)&&(identical(other.visibility, visibility) || other.visibility == visibility)&&(identical(other.replyingTo, replyingTo) || other.replyingTo == replyingTo)&&(identical(other.forwardingTo, forwardingTo) || other.forwardingTo == forwardingTo)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,title,description,content,const DeepCollectionEquality().hash(attachments),visibility); | ||||
| int get hashCode => Object.hash(runtimeType,title,description,content,const DeepCollectionEquality().hash(attachments),visibility,replyingTo,forwardingTo); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'PostComposeInitialState(title: $title, description: $description, content: $content, attachments: $attachments, visibility: $visibility)'; | ||||
|   return 'PostComposeInitialState(title: $title, description: $description, content: $content, attachments: $attachments, visibility: $visibility, replyingTo: $replyingTo, forwardingTo: $forwardingTo)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -49,11 +49,11 @@ abstract mixin class $PostComposeInitialStateCopyWith<$Res>  { | ||||
|   factory $PostComposeInitialStateCopyWith(PostComposeInitialState value, $Res Function(PostComposeInitialState) _then) = _$PostComposeInitialStateCopyWithImpl; | ||||
| @useResult | ||||
| $Res call({ | ||||
|  String? title, String? description, String? content, List<UniversalFile> attachments, int? visibility | ||||
|  String? title, String? description, String? content, List<UniversalFile> attachments, int? visibility, SnPost? replyingTo, SnPost? forwardingTo | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
| $SnPostCopyWith<$Res>? get replyingTo;$SnPostCopyWith<$Res>? get forwardingTo; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| @@ -66,17 +66,43 @@ class _$PostComposeInitialStateCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of PostComposeInitialState | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? title = freezed,Object? description = freezed,Object? content = freezed,Object? attachments = null,Object? visibility = freezed,}) { | ||||
| @pragma('vm:prefer-inline') @override $Res call({Object? title = freezed,Object? description = freezed,Object? content = freezed,Object? attachments = null,Object? visibility = freezed,Object? replyingTo = freezed,Object? forwardingTo = freezed,}) { | ||||
|   return _then(_self.copyWith( | ||||
| title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable | ||||
| as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable | ||||
| as String?,content: freezed == content ? _self.content : content // ignore: cast_nullable_to_non_nullable | ||||
| as String?,attachments: null == attachments ? _self.attachments : attachments // ignore: cast_nullable_to_non_nullable | ||||
| as List<UniversalFile>,visibility: freezed == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable | ||||
| as int?, | ||||
| as int?,replyingTo: freezed == replyingTo ? _self.replyingTo : replyingTo // ignore: cast_nullable_to_non_nullable | ||||
| as SnPost?,forwardingTo: freezed == forwardingTo ? _self.forwardingTo : forwardingTo // ignore: cast_nullable_to_non_nullable | ||||
| as SnPost?, | ||||
|   )); | ||||
| } | ||||
| /// Create a copy of PostComposeInitialState | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnPostCopyWith<$Res>? get replyingTo { | ||||
|     if (_self.replyingTo == null) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return $SnPostCopyWith<$Res>(_self.replyingTo!, (value) { | ||||
|     return _then(_self.copyWith(replyingTo: value)); | ||||
|   }); | ||||
| }/// Create a copy of PostComposeInitialState | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnPostCopyWith<$Res>? get forwardingTo { | ||||
|     if (_self.forwardingTo == null) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return $SnPostCopyWith<$Res>(_self.forwardingTo!, (value) { | ||||
|     return _then(_self.copyWith(forwardingTo: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -84,7 +110,7 @@ as int?, | ||||
| @JsonSerializable() | ||||
|  | ||||
| class _PostComposeInitialState implements PostComposeInitialState { | ||||
|   const _PostComposeInitialState({this.title, this.description, this.content, final  List<UniversalFile> attachments = const [], this.visibility}): _attachments = attachments; | ||||
|   const _PostComposeInitialState({this.title, this.description, this.content, final  List<UniversalFile> attachments = const [], this.visibility, this.replyingTo, this.forwardingTo}): _attachments = attachments; | ||||
|   factory _PostComposeInitialState.fromJson(Map<String, dynamic> json) => _$PostComposeInitialStateFromJson(json); | ||||
|  | ||||
| @override final  String? title; | ||||
| @@ -98,6 +124,8 @@ class _PostComposeInitialState implements PostComposeInitialState { | ||||
| } | ||||
|  | ||||
| @override final  int? visibility; | ||||
| @override final  SnPost? replyingTo; | ||||
| @override final  SnPost? forwardingTo; | ||||
|  | ||||
| /// Create a copy of PostComposeInitialState | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @@ -112,16 +140,16 @@ Map<String, dynamic> toJson() { | ||||
|  | ||||
| @override | ||||
| bool operator ==(Object other) { | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _PostComposeInitialState&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.content, content) || other.content == content)&&const DeepCollectionEquality().equals(other._attachments, _attachments)&&(identical(other.visibility, visibility) || other.visibility == visibility)); | ||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _PostComposeInitialState&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.content, content) || other.content == content)&&const DeepCollectionEquality().equals(other._attachments, _attachments)&&(identical(other.visibility, visibility) || other.visibility == visibility)&&(identical(other.replyingTo, replyingTo) || other.replyingTo == replyingTo)&&(identical(other.forwardingTo, forwardingTo) || other.forwardingTo == forwardingTo)); | ||||
| } | ||||
|  | ||||
| @JsonKey(includeFromJson: false, includeToJson: false) | ||||
| @override | ||||
| int get hashCode => Object.hash(runtimeType,title,description,content,const DeepCollectionEquality().hash(_attachments),visibility); | ||||
| int get hashCode => Object.hash(runtimeType,title,description,content,const DeepCollectionEquality().hash(_attachments),visibility,replyingTo,forwardingTo); | ||||
|  | ||||
| @override | ||||
| String toString() { | ||||
|   return 'PostComposeInitialState(title: $title, description: $description, content: $content, attachments: $attachments, visibility: $visibility)'; | ||||
|   return 'PostComposeInitialState(title: $title, description: $description, content: $content, attachments: $attachments, visibility: $visibility, replyingTo: $replyingTo, forwardingTo: $forwardingTo)'; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -132,11 +160,11 @@ abstract mixin class _$PostComposeInitialStateCopyWith<$Res> implements $PostCom | ||||
|   factory _$PostComposeInitialStateCopyWith(_PostComposeInitialState value, $Res Function(_PostComposeInitialState) _then) = __$PostComposeInitialStateCopyWithImpl; | ||||
| @override @useResult | ||||
| $Res call({ | ||||
|  String? title, String? description, String? content, List<UniversalFile> attachments, int? visibility | ||||
|  String? title, String? description, String? content, List<UniversalFile> attachments, int? visibility, SnPost? replyingTo, SnPost? forwardingTo | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
| @override $SnPostCopyWith<$Res>? get replyingTo;@override $SnPostCopyWith<$Res>? get forwardingTo; | ||||
|  | ||||
| } | ||||
| /// @nodoc | ||||
| @@ -149,18 +177,44 @@ class __$PostComposeInitialStateCopyWithImpl<$Res> | ||||
|  | ||||
| /// Create a copy of PostComposeInitialState | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? title = freezed,Object? description = freezed,Object? content = freezed,Object? attachments = null,Object? visibility = freezed,}) { | ||||
| @override @pragma('vm:prefer-inline') $Res call({Object? title = freezed,Object? description = freezed,Object? content = freezed,Object? attachments = null,Object? visibility = freezed,Object? replyingTo = freezed,Object? forwardingTo = freezed,}) { | ||||
|   return _then(_PostComposeInitialState( | ||||
| title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable | ||||
| as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable | ||||
| as String?,content: freezed == content ? _self.content : content // ignore: cast_nullable_to_non_nullable | ||||
| as String?,attachments: null == attachments ? _self._attachments : attachments // ignore: cast_nullable_to_non_nullable | ||||
| as List<UniversalFile>,visibility: freezed == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable | ||||
| as int?, | ||||
| as int?,replyingTo: freezed == replyingTo ? _self.replyingTo : replyingTo // ignore: cast_nullable_to_non_nullable | ||||
| as SnPost?,forwardingTo: freezed == forwardingTo ? _self.forwardingTo : forwardingTo // ignore: cast_nullable_to_non_nullable | ||||
| as SnPost?, | ||||
|   )); | ||||
| } | ||||
|  | ||||
| /// Create a copy of PostComposeInitialState | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnPostCopyWith<$Res>? get replyingTo { | ||||
|     if (_self.replyingTo == null) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return $SnPostCopyWith<$Res>(_self.replyingTo!, (value) { | ||||
|     return _then(_self.copyWith(replyingTo: value)); | ||||
|   }); | ||||
| }/// Create a copy of PostComposeInitialState | ||||
| /// with the given fields replaced by the non-null parameter values. | ||||
| @override | ||||
| @pragma('vm:prefer-inline') | ||||
| $SnPostCopyWith<$Res>? get forwardingTo { | ||||
|     if (_self.forwardingTo == null) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return $SnPostCopyWith<$Res>(_self.forwardingTo!, (value) { | ||||
|     return _then(_self.copyWith(forwardingTo: value)); | ||||
|   }); | ||||
| } | ||||
| } | ||||
|  | ||||
| // dart format on | ||||
|   | ||||
| @@ -18,6 +18,14 @@ _PostComposeInitialState _$PostComposeInitialStateFromJson( | ||||
|           .toList() ?? | ||||
|       const [], | ||||
|   visibility: (json['visibility'] as num?)?.toInt(), | ||||
|   replyingTo: | ||||
|       json['replying_to'] == null | ||||
|           ? null | ||||
|           : SnPost.fromJson(json['replying_to'] as Map<String, dynamic>), | ||||
|   forwardingTo: | ||||
|       json['forwarding_to'] == null | ||||
|           ? null | ||||
|           : SnPost.fromJson(json['forwarding_to'] as Map<String, dynamic>), | ||||
| ); | ||||
|  | ||||
| Map<String, dynamic> _$PostComposeInitialStateToJson( | ||||
| @@ -28,4 +36,6 @@ Map<String, dynamic> _$PostComposeInitialStateToJson( | ||||
|   'content': instance.content, | ||||
|   'attachments': instance.attachments.map((e) => e.toJson()).toList(), | ||||
|   'visibility': instance.visibility, | ||||
|   'replying_to': instance.replyingTo?.toJson(), | ||||
|   'forwarding_to': instance.forwardingTo?.toJson(), | ||||
| }; | ||||
|   | ||||
| @@ -238,7 +238,7 @@ class ArticleComposeScreen extends HookConsumerWidget { | ||||
|         children: [ | ||||
|           // Publisher row | ||||
|           Card( | ||||
|             margin: EdgeInsets.only(bottom: 8), | ||||
|             margin: EdgeInsets.only(top: 8), | ||||
|             elevation: 1, | ||||
|             child: Padding( | ||||
|               padding: const EdgeInsets.all(12), | ||||
| @@ -265,11 +265,21 @@ class ArticleComposeScreen extends HookConsumerWidget { | ||||
|                       }); | ||||
|                     }, | ||||
|                   ), | ||||
|                   const Gap(12), | ||||
|                   const Gap(16), | ||||
|                   if (state.currentPublisher.value == null) | ||||
|                     Text( | ||||
|                     state.currentPublisher.value?.name ?? | ||||
|                       'postPublisherUnselected'.tr(), | ||||
|                       style: theme.textTheme.bodyMedium, | ||||
|                     ) | ||||
|                   else | ||||
|                     Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         Text(state.currentPublisher.value!.nick).bold(), | ||||
|                         Text( | ||||
|                           '@${state.currentPublisher.value!.name}', | ||||
|                         ).fontSize(12), | ||||
|                       ], | ||||
|                     ), | ||||
|                 ], | ||||
|               ), | ||||
| @@ -311,8 +321,15 @@ class ArticleComposeScreen extends HookConsumerWidget { | ||||
|             builder: (context, attachments, _) { | ||||
|               if (attachments.isEmpty) return const SizedBox.shrink(); | ||||
|               return Column( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                 children: [ | ||||
|                   const Gap(16), | ||||
|                   Text( | ||||
|                     'articleAttachmentHint'.tr(), | ||||
|                     style: Theme.of(context).textTheme.bodySmall?.copyWith( | ||||
|                       color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|                     ), | ||||
|                   ).padding(bottom: 8), | ||||
|                   ValueListenableBuilder<Map<int, double>>( | ||||
|                     valueListenable: state.attachmentProgress, | ||||
|                     builder: (context, progressMap, _) { | ||||
| @@ -322,8 +339,8 @@ class ArticleComposeScreen extends HookConsumerWidget { | ||||
|                         children: [ | ||||
|                           for (var idx = 0; idx < attachments.length; idx++) | ||||
|                             SizedBox( | ||||
|                               width: 120, | ||||
|                               height: 120, | ||||
|                               width: 280, | ||||
|                               height: 280, | ||||
|                               child: AttachmentPreview( | ||||
|                                 item: attachments[idx], | ||||
|                                 progress: progressMap[idx], | ||||
| @@ -348,6 +365,12 @@ class ArticleComposeScreen extends HookConsumerWidget { | ||||
|                                     delta, | ||||
|                                   ); | ||||
|                                 }, | ||||
|                                 onInsert: | ||||
|                                     () => ComposeLogic.insertAttachment( | ||||
|                                       ref, | ||||
|                                       state, | ||||
|                                       idx, | ||||
|                                     ), | ||||
|                               ), | ||||
|                             ), | ||||
|                         ], | ||||
|   | ||||
| @@ -9,7 +9,6 @@ import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/post/post_item.dart'; | ||||
| import 'package:island/widgets/post/post_quick_reply.dart'; | ||||
| import 'package:island/widgets/post/post_replies.dart'; | ||||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @@ -22,7 +21,8 @@ Future<SnPost?> post(Ref ref, String id) async { | ||||
|   return SnPost.fromJson(resp.data); | ||||
| } | ||||
|  | ||||
| final postStateProvider = StateNotifierProvider.family<PostState, AsyncValue<SnPost?>, String>( | ||||
| final postStateProvider = | ||||
|     StateNotifierProvider.family<PostState, AsyncValue<SnPost?>, String>( | ||||
|       (ref, id) => PostState(ref, id), | ||||
|     ); | ||||
|  | ||||
| @@ -75,7 +75,9 @@ class PostDetailScreen extends HookConsumerWidget { | ||||
|                           backgroundColor: isWide ? Colors.transparent : null, | ||||
|                           onUpdate: (newItem) { | ||||
|                             // Update the local state with the new post data | ||||
|                             ref.read(postStateProvider(id).notifier).updatePost(newItem); | ||||
|                             ref | ||||
|                                 .read(postStateProvider(id).notifier) | ||||
|                                 .updatePost(newItem); | ||||
|                           }, | ||||
|                         ), | ||||
|                         const Divider(height: 1), | ||||
| @@ -93,16 +95,21 @@ class PostDetailScreen extends HookConsumerWidget { | ||||
|                   right: 0, | ||||
|                   child: Material( | ||||
|                     elevation: 2, | ||||
|                     child: postState.when( | ||||
|                       data: (post) => PostQuickReply( | ||||
|                     child: postState | ||||
|                         .when( | ||||
|                           data: | ||||
|                               (post) => PostQuickReply( | ||||
|                                 parent: post!, | ||||
|                                 onPosted: () { | ||||
|                           ref.invalidate(postRepliesNotifierProvider(id)); | ||||
|                                   ref.invalidate( | ||||
|                                     postRepliesNotifierProvider(id), | ||||
|                                   ); | ||||
|                                 }, | ||||
|                               ), | ||||
|                           loading: () => const SizedBox.shrink(), | ||||
|                       error: (_, __) => const SizedBox.shrink(), | ||||
|                     ).padding( | ||||
|                           error: (_, _) => const SizedBox.shrink(), | ||||
|                         ) | ||||
|                         .padding( | ||||
|                           bottom: MediaQuery.of(context).padding.bottom + 16, | ||||
|                           top: 16, | ||||
|                           horizontal: 16, | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/post/post_item.dart'; | ||||
| import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||
|  | ||||
| @@ -107,7 +108,7 @@ class _PostSearchScreenState extends ConsumerState<PostSearchScreen> { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Scaffold( | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar( | ||||
|         title: TextField( | ||||
|           controller: _searchController, | ||||
|   | ||||
| @@ -187,7 +187,7 @@ class PublisherProfileScreen extends HookConsumerWidget { | ||||
|                         ), | ||||
|                         onTap: () { | ||||
|                           Navigator.pop(context, true); | ||||
|                           context.push('/account/${data.name}'); | ||||
|                           context.push('/account/${data.account?.name}'); | ||||
|                         }, | ||||
|                       ), | ||||
|                       Expanded( | ||||
|   | ||||
| @@ -77,6 +77,7 @@ class RealmDetailScreen extends HookConsumerWidget { | ||||
|     ); | ||||
|  | ||||
|     return AppScaffold( | ||||
|       noBackground: false, | ||||
|       body: realmState.when( | ||||
|         loading: () => const Center(child: CircularProgressIndicator()), | ||||
|         error: (error, _) => Center(child: Text('Error: $error')), | ||||
|   | ||||
							
								
								
									
										105
									
								
								lib/screens/reports/report_detail.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								lib/screens/reports/report_detail.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,105 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/abuse_report.dart'; | ||||
| import 'package:island/models/abuse_report_type.dart'; | ||||
| import 'package:island/services/abuse_report_service.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| class AbuseReportDetailScreen extends ConsumerStatefulWidget { | ||||
|   final String reportId; | ||||
|  | ||||
|   const AbuseReportDetailScreen({super.key, required this.reportId}); | ||||
|  | ||||
|   @override | ||||
|   ConsumerState<AbuseReportDetailScreen> createState() => | ||||
|       _AbuseReportDetailScreenState(); | ||||
| } | ||||
|  | ||||
| class _AbuseReportDetailScreenState | ||||
|     extends ConsumerState<AbuseReportDetailScreen> { | ||||
|   Future<SnAbuseReport>? _reportFuture; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _reportFuture = ref | ||||
|         .read(abuseReportServiceProvider) | ||||
|         .getReport(widget.reportId); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar(title: const Text('Abuse Report Details')), | ||||
|       body: FutureBuilder<SnAbuseReport>( | ||||
|         future: _reportFuture, | ||||
|         builder: (context, snapshot) { | ||||
|           if (snapshot.connectionState == ConnectionState.waiting) { | ||||
|             return const Center(child: CircularProgressIndicator()); | ||||
|           } else if (snapshot.hasError) { | ||||
|             return Center(child: Text('Error: ${snapshot.error}')); | ||||
|           } else if (snapshot.hasData) { | ||||
|             final report = snapshot.data!; | ||||
|             return Padding( | ||||
|               padding: const EdgeInsets.all(16.0), | ||||
|               child: Column( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                 children: [ | ||||
|                   _buildDetailRow(context, 'Report ID', report.id), | ||||
|                   _buildDetailRow( | ||||
|                     context, | ||||
|                     'Resource Identifier', | ||||
|                     report.resourceIdentifier, | ||||
|                   ), | ||||
|                   _buildDetailRow( | ||||
|                     context, | ||||
|                     'Type', | ||||
|                     AbuseReportType.fromValue(report.type).displayName, | ||||
|                   ), | ||||
|                   _buildDetailRow(context, 'Reason', report.reason), | ||||
|                   _buildDetailRow( | ||||
|                     context, | ||||
|                     'Resolved At', | ||||
|                     report.resolvedAt?.toString() ?? 'N/A', | ||||
|                   ), | ||||
|                   _buildDetailRow( | ||||
|                     context, | ||||
|                     'Resolution', | ||||
|                     report.resolution ?? 'N/A', | ||||
|                   ), | ||||
|                   _buildDetailRow(context, 'Account ID', report.accountId), | ||||
|                   _buildDetailRow( | ||||
|                     context, | ||||
|                     'Created At', | ||||
|                     report.createdAt.toString(), | ||||
|                   ), | ||||
|                   _buildDetailRow( | ||||
|                     context, | ||||
|                     'Updated At', | ||||
|                     report.updatedAt.toString(), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ); | ||||
|           } else { | ||||
|             return const Center(child: Text('No data')); | ||||
|           } | ||||
|         }, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildDetailRow(BuildContext context, String label, String value) { | ||||
|     return Padding( | ||||
|       padding: const EdgeInsets.symmetric(vertical: 8.0), | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           Text(label, style: Theme.of(context).textTheme.titleMedium).bold(), | ||||
|           Text(value, style: Theme.of(context).textTheme.bodyLarge), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										153
									
								
								lib/screens/reports/report_list.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								lib/screens/reports/report_list.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,153 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/abuse_report.dart'; | ||||
| import 'package:island/models/abuse_report_type.dart'; | ||||
| import 'package:island/services/abuse_report_service.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/safety/abuse_report_helper.dart'; | ||||
|  | ||||
| class AbuseReportListScreen extends ConsumerStatefulWidget { | ||||
|   const AbuseReportListScreen({super.key}); | ||||
|  | ||||
|   @override | ||||
|   ConsumerState<AbuseReportListScreen> createState() => | ||||
|       _AbuseReportListScreenState(); | ||||
| } | ||||
|  | ||||
| class _AbuseReportListScreenState extends ConsumerState<AbuseReportListScreen> { | ||||
|   Future<List<SnAbuseReport>>? _reportsFuture; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _reportsFuture = ref.read(abuseReportServiceProvider).getReports(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar(title: Text('abuseReports').tr()), | ||||
|       floatingActionButton: FloatingActionButton( | ||||
|         child: const Icon(Icons.add), | ||||
|         onPressed: () { | ||||
|           showAbuseReportSheet(context, resourceIdentifier: 'unidentified'); | ||||
|         }, | ||||
|       ), | ||||
|       body: FutureBuilder<List<SnAbuseReport>>( | ||||
|         future: _reportsFuture, | ||||
|         builder: (context, snapshot) { | ||||
|           if (snapshot.connectionState == ConnectionState.waiting) { | ||||
|             return const Center(child: CircularProgressIndicator()); | ||||
|           } else if (snapshot.hasError) { | ||||
|             return Center(child: Text('Error: ${snapshot.error}')); | ||||
|           } else if (snapshot.hasData) { | ||||
|             final reports = snapshot.data!; | ||||
|             return ListView.builder( | ||||
|               padding: EdgeInsets.zero, | ||||
|               itemCount: reports.length, | ||||
|               itemBuilder: (context, index) { | ||||
|                 final report = reports[index]; | ||||
|                 return Card( | ||||
|                   elevation: 2, | ||||
|                   margin: const EdgeInsets.symmetric( | ||||
|                     horizontal: 16, | ||||
|                     vertical: 8, | ||||
|                   ), | ||||
|                   child: InkWell( | ||||
|                     onTap: () { | ||||
|                       context.push('/safety/reports/me/${report.id}'); | ||||
|                     }, | ||||
|                     child: Padding( | ||||
|                       padding: const EdgeInsets.all(16.0), | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           Text( | ||||
|                             report.reason, | ||||
|                             style: Theme.of(context).textTheme.titleMedium, | ||||
|                           ), | ||||
|                           const SizedBox(height: 8), | ||||
|                           Row( | ||||
|                             mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                             children: [ | ||||
|                               Text( | ||||
|                                 'ID', | ||||
|                                 style: Theme.of(context).textTheme.bodySmall, | ||||
|                               ), | ||||
|                               Text( | ||||
|                                 report.id, | ||||
|                                 style: Theme.of(context).textTheme.bodyMedium, | ||||
|                               ), | ||||
|                             ], | ||||
|                           ), | ||||
|                           const SizedBox(height: 4), | ||||
|                           Row( | ||||
|                             mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                             children: [ | ||||
|                               Text( | ||||
|                                 'Type', | ||||
|                                 style: Theme.of(context).textTheme.bodySmall, | ||||
|                               ), | ||||
|                               Text( | ||||
|                                 AbuseReportType.fromValue( | ||||
|                                   report.type, | ||||
|                                 ).displayName, | ||||
|                                 style: Theme.of(context).textTheme.bodyMedium, | ||||
|                               ), | ||||
|                             ], | ||||
|                           ), | ||||
|                           const SizedBox(height: 4), | ||||
|                           Row( | ||||
|                             mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                             children: [ | ||||
|                               Text( | ||||
|                                 'Created at', | ||||
|                                 style: Theme.of(context).textTheme.bodySmall, | ||||
|                               ), | ||||
|                               Text( | ||||
|                                 '${report.createdAt.formatRelative(context)} · ${report.createdAt.formatSystem()}', | ||||
|                                 style: Theme.of(context).textTheme.bodyMedium, | ||||
|                               ), | ||||
|                             ], | ||||
|                           ), | ||||
|                           const SizedBox(height: 4), | ||||
|                           Row( | ||||
|                             mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                             children: [ | ||||
|                               Text( | ||||
|                                 'Status', | ||||
|                                 style: Theme.of(context).textTheme.bodySmall, | ||||
|                               ), | ||||
|                               Text( | ||||
|                                 report.resolvedAt != null | ||||
|                                     ? 'Resolved' | ||||
|                                     : 'Unresolved', | ||||
|                                 style: Theme.of( | ||||
|                                   context, | ||||
|                                 ).textTheme.bodyMedium?.copyWith( | ||||
|                                   color: | ||||
|                                       report.resolvedAt != null | ||||
|                                           ? Colors.green | ||||
|                                           : Colors.orange, | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ], | ||||
|                           ), | ||||
|                         ], | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ); | ||||
|               }, | ||||
|             ); | ||||
|           } else { | ||||
|             return const Center(child: Text('No data')); | ||||
|           } | ||||
|         }, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -341,26 +341,6 @@ class SettingsScreen extends HookConsumerWidget { | ||||
|     ]; | ||||
|  | ||||
|     final behaviorSettings = [ | ||||
|       ListTile( | ||||
|         minLeadingWidth: 48, | ||||
|         title: Text('creatorHub').tr(), | ||||
|         contentPadding: const EdgeInsets.only(left: 24, right: 17), | ||||
|         leading: const Icon(Symbols.rocket_launch), | ||||
|         trailing: const Icon(Symbols.chevron_right), | ||||
|         onTap: () => context.push('/creators'), | ||||
|       ), | ||||
|  | ||||
|       // Developer Hub | ||||
|       ListTile( | ||||
|         minLeadingWidth: 48, | ||||
|         title: Text('developerHub').tr(), | ||||
|         contentPadding: const EdgeInsets.only(left: 24, right: 17), | ||||
|         leading: const Icon(Symbols.hub), | ||||
|         trailing: const Icon(Symbols.chevron_right), | ||||
|         onTap: () => context.push('/developers'), | ||||
|       ), | ||||
|  | ||||
|       // Auto translate settings | ||||
|       ListTile( | ||||
|         minLeadingWidth: 48, | ||||
|         title: Text('settingsAutoTranslate').tr(), | ||||
|   | ||||
							
								
								
									
										25
									
								
								lib/services/abuse_report_service.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								lib/services/abuse_report_service.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/abuse_report.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
|  | ||||
| final abuseReportServiceProvider = Provider<AbuseReportService>((ref) { | ||||
|   return AbuseReportService(ref); | ||||
| }); | ||||
|  | ||||
| class AbuseReportService { | ||||
|   final Ref ref; | ||||
|   AbuseReportService(this.ref); | ||||
|  | ||||
|   Future<SnAbuseReport> getReport(String id) async { | ||||
|     final response = | ||||
|         await ref.read(apiClientProvider).get('/safety/reports/me/$id'); | ||||
|     return SnAbuseReport.fromJson(response.data); | ||||
|   } | ||||
|  | ||||
|   Future<List<SnAbuseReport>> getReports() async { | ||||
|     final response = await ref.read(apiClientProvider).get('/safety/reports/me'); | ||||
|     return (response.data as List) | ||||
|         .map((json) => SnAbuseReport.fromJson(json)) | ||||
|         .toList(); | ||||
|   } | ||||
| } | ||||
| @@ -63,9 +63,11 @@ StreamSubscription<WebSocketPacket> setupNotificationListener( | ||||
|   }); | ||||
| } | ||||
|  | ||||
| Future<void> subscribePushNotification(Dio apiClient) async { | ||||
| Future<void> subscribePushNotification( | ||||
|   Dio apiClient, { | ||||
|   bool detailedErrors = false, | ||||
| }) async { | ||||
|   await FirebaseMessaging.instance.requestPermission( | ||||
|     provisional: true, | ||||
|     alert: true, | ||||
|     badge: true, | ||||
|     sound: true, | ||||
| @@ -97,6 +99,8 @@ Future<void> subscribePushNotification(Dio apiClient) async { | ||||
|       deviceToken, | ||||
|       !kIsWeb && (Platform.isIOS || Platform.isMacOS) ? 0 : 1, | ||||
|     ); | ||||
|   } else if (detailedErrors) { | ||||
|     throw Exception("Failed to get device token for push notifications."); | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -3,7 +3,6 @@ import 'dart:convert'; | ||||
| import 'package:flutter/widgets.dart'; | ||||
| import 'package:freezed_annotation/freezed_annotation.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/widgets/tour/techincal_review_intro.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
|  | ||||
| part 'tour.g.dart'; | ||||
| @@ -12,7 +11,7 @@ part 'tour.freezed.dart'; | ||||
| const kAppTourStatusKey = "app_tour_statuses"; | ||||
|  | ||||
| const List<Tour> kAllTours = [ | ||||
|   Tour(id: 'technical_review_intro', isStartup: true), | ||||
|   // Tour(id: 'technical_review_intro', isStartup: true), | ||||
| ]; | ||||
|  | ||||
| @freezed | ||||
| @@ -22,7 +21,7 @@ sealed class Tour with _$Tour { | ||||
|   const factory Tour({required String id, required bool isStartup}) = _Tour; | ||||
|  | ||||
|   Widget get widget => switch (id) { | ||||
|     'technical_review_intro' => const TechicalReviewIntroWidget(), | ||||
|     // 'technical_review_intro' => const TechicalReviewIntroWidget(), | ||||
|     _ => throw UnimplementedError(), | ||||
|   }; | ||||
| } | ||||
|   | ||||
							
								
								
									
										22
									
								
								lib/utils/abuse_report_utils.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								lib/utils/abuse_report_utils.dart
									
									
									
									
									
										Normal 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'; | ||||
|   } | ||||
| } | ||||
| @@ -21,11 +21,23 @@ class AccountName extends StatelessWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     var nameStyle = (style ?? TextStyle()); | ||||
|     if (account.profile.stellarMembership != null) { | ||||
|       nameStyle = nameStyle.copyWith( | ||||
|         color: (switch (account.profile.stellarMembership!.identifier) { | ||||
|           'solian.stellar.primary' => Colors.blueAccent, | ||||
|           'solian.stellar.nova' => Colors.indigoAccent, | ||||
|           'solian.stellar.supernova' => Colors.amberAccent, | ||||
|           _ => null, | ||||
|         }), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return Row( | ||||
|       mainAxisSize: MainAxisSize.min, | ||||
|       spacing: 4, | ||||
|       children: [ | ||||
|         Flexible(child: Text(account.nick, style: style)), | ||||
|         Flexible(child: Text(account.nick, style: nameStyle)), | ||||
|         if (account.profile.stellarMembership != null) | ||||
|           StellarMembershipMark(membership: account.profile.stellarMembership!), | ||||
|         if (account.profile.verification != null) | ||||
| @@ -87,36 +99,23 @@ class StellarMembershipMark extends StatelessWidget { | ||||
|   Color _getMembershipTierColor(String identifier) { | ||||
|     switch (identifier) { | ||||
|       case 'solian.stellar.primary': | ||||
|         return Colors.amber; | ||||
|       case 'solian.stellar.nova': | ||||
|         return Colors.blue; | ||||
|       case 'solian.stellar.nova': | ||||
|         return Colors.indigo; | ||||
|       case 'solian.stellar.supernova': | ||||
|         return Colors.purple; | ||||
|         return Colors.amber; | ||||
|       default: | ||||
|         return Colors.grey; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   IconData _getMembershipTierIcon(String identifier) { | ||||
|     switch (identifier) { | ||||
|       case 'solian.stellar.primary': | ||||
|         return Symbols.star; | ||||
|       case 'solian.stellar.nova': | ||||
|         return Symbols.auto_awesome; | ||||
|       case 'solian.stellar.supernova': | ||||
|         return Symbols.diamond; | ||||
|       default: | ||||
|         return Symbols.workspace_premium; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     if (!membership.isActive) return const SizedBox.shrink(); | ||||
|  | ||||
|     final tierName = _getMembershipTierName(membership.identifier); | ||||
|     final tierColor = _getMembershipTierColor(membership.identifier); | ||||
|     final tierIcon = _getMembershipTierIcon(membership.identifier); | ||||
|     final tierIcon = Symbols.award_star; | ||||
|  | ||||
|     return Tooltip( | ||||
|       richMessage: TextSpan( | ||||
| @@ -124,7 +123,7 @@ class StellarMembershipMark extends StatelessWidget { | ||||
|         children: [ | ||||
|           TextSpan(text: '\n'), | ||||
|           TextSpan( | ||||
|             text: 'currentMembership'.tr(args: [tierName]), | ||||
|             text: 'currentMembershipMember'.tr(args: [tierName]), | ||||
|             style: TextStyle(fontWeight: FontWeight.normal), | ||||
|           ), | ||||
|         ], | ||||
|   | ||||
| @@ -59,7 +59,7 @@ class AccountStatusCreationSheet extends HookConsumerWidget { | ||||
|           }, | ||||
|           options: Options(method: initialStatus == null ? 'POST' : 'PATCH'), | ||||
|         ); | ||||
|         if (user.hasValue) { | ||||
|         if (user.value != null) { | ||||
|           ref.invalidate(accountStatusProvider(user.value!.name)); | ||||
|         } | ||||
|         if (!context.mounted) return; | ||||
|   | ||||
| @@ -350,7 +350,7 @@ class _WebSocketIndicator extends HookConsumerWidget { | ||||
|     return AnimatedPositioned( | ||||
|       duration: Duration(milliseconds: 1850), | ||||
|       top: | ||||
|           !user.hasValue || | ||||
|           user.value == null || | ||||
|                   user.value == null || | ||||
|                   websocketState == WebSocketState.connected() | ||||
|               ? -indicatorHeight | ||||
| @@ -362,7 +362,7 @@ class _WebSocketIndicator extends HookConsumerWidget { | ||||
|       child: IgnorePointer( | ||||
|         child: Material( | ||||
|           elevation: | ||||
|               !user.hasValue || websocketState == WebSocketState.connected() | ||||
|               user.value == null || websocketState == WebSocketState.connected() | ||||
|                   ? 0 | ||||
|                   : 4, | ||||
|           child: AnimatedContainer( | ||||
|   | ||||
| @@ -15,6 +15,7 @@ class AttachmentPreview extends StatelessWidget { | ||||
|   final double? progress; | ||||
|   final Function(int)? onMove; | ||||
|   final Function? onDelete; | ||||
|   final Function? onInsert; | ||||
|   final Function? onRequestUpload; | ||||
|   const AttachmentPreview({ | ||||
|     super.key, | ||||
| @@ -23,6 +24,7 @@ class AttachmentPreview extends StatelessWidget { | ||||
|     this.onRequestUpload, | ||||
|     this.onMove, | ||||
|     this.onDelete, | ||||
|     this.onInsert, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
| @@ -104,7 +106,11 @@ class AttachmentPreview extends StatelessWidget { | ||||
|                           style: TextStyle(color: Colors.white), | ||||
|                         ), | ||||
|                       Gap(6), | ||||
|                       Center(child: LinearProgressIndicator(value: progress)), | ||||
|                       Center( | ||||
|                         child: LinearProgressIndicator( | ||||
|                           value: progress != null ? progress! / 100.0 : null, | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
| @@ -166,6 +172,18 @@ class AttachmentPreview extends StatelessWidget { | ||||
|                               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(); | ||||
|                             }, | ||||
|                           ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|   | ||||
| @@ -244,7 +244,7 @@ class CloudFileZoomIn extends HookConsumerWidget { | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     String _formatFileSize(int bytes) { | ||||
|     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'; | ||||
| @@ -274,7 +274,7 @@ class CloudFileZoomIn extends HookConsumerWidget { | ||||
|                     buildInfoRow( | ||||
|                       Icons.storage, | ||||
|                       'Size', | ||||
|                       _formatFileSize(item.size), | ||||
|                       formatFileSize(item.size), | ||||
|                     ), | ||||
|                     const Divider(height: 1), | ||||
|                     buildInfoRow( | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| @@ -6,11 +7,14 @@ 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:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/file.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/content/markdown_latex.dart'; | ||||
| import 'package:markdown/markdown.dart' as markdown; | ||||
| import 'package:markdown_widget/markdown_widget.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
| import 'package:url_launcher/url_launcher.dart'; | ||||
|  | ||||
| import 'image.dart'; | ||||
| @@ -23,6 +27,7 @@ class MarkdownTextContent extends HookConsumerWidget { | ||||
|   final TextStyle? linkStyle; | ||||
|   final EdgeInsets? linesMargin; | ||||
|   final bool isSelectable; | ||||
|   final List<SnCloudFile>? attachments; | ||||
|  | ||||
|   const MarkdownTextContent({ | ||||
|     super.key, | ||||
| @@ -33,6 +38,7 @@ class MarkdownTextContent extends HookConsumerWidget { | ||||
|     this.linkStyle, | ||||
|     this.isSelectable = false, | ||||
|     this.linesMargin, | ||||
|     this.attachments, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
| @@ -109,6 +115,29 @@ class MarkdownTextContent extends HookConsumerWidget { | ||||
|               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(); | ||||
|                     } | ||||
|  | ||||
|                     return 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, | ||||
|                           fit: BoxFit.cover, | ||||
|                         ).clipRRect(all: 8), | ||||
|                       ), | ||||
|                     ); | ||||
|                   case 'stickers': | ||||
|                     final size = doesEnlargeSticker ? 96.0 : 24.0; | ||||
|                     return ClipRRect( | ||||
| @@ -132,9 +161,9 @@ class MarkdownTextContent extends HookConsumerWidget { | ||||
|                     ); | ||||
|                 } | ||||
|               } | ||||
|               final content = UniversalImage( | ||||
|                 uri: uri.toString(), | ||||
|                 fit: BoxFit.cover, | ||||
|               final content = ConstrainedBox( | ||||
|                 constraints: BoxConstraints(maxHeight: 360), | ||||
|                 child: UniversalImage(uri: uri.toString(), fit: BoxFit.contain), | ||||
|               ); | ||||
|               return content; | ||||
|             }, | ||||
|   | ||||
| @@ -286,7 +286,7 @@ class _PaymentContentState extends ConsumerState<_PaymentContent> { | ||||
|   } | ||||
|  | ||||
|   String _formatCurrency(int amount, String currency) { | ||||
|     final value = amount / 100.0; | ||||
|     final value = amount; | ||||
|     return '${value.toStringAsFixed(2)} $currency'; | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -98,19 +98,11 @@ class ComposeLogic { | ||||
|       descriptionController: TextEditingController( | ||||
|         text: originalPost?.description, | ||||
|       ), | ||||
|       contentController: TextEditingController( | ||||
|         text: | ||||
|             originalPost?.content ?? | ||||
|             (forwardedPost != null | ||||
|                 ? '''> ${forwardedPost.content} | ||||
|  | ||||
| ''' | ||||
|                 : null), | ||||
|       ), | ||||
|       contentController: TextEditingController(text: originalPost?.content), | ||||
|       visibility: ValueNotifier<int>(originalPost?.visibility ?? 0), | ||||
|       submitting: ValueNotifier<bool>(false), | ||||
|       attachmentProgress: ValueNotifier<Map<int, double>>({}), | ||||
|       currentPublisher: ValueNotifier<SnPublisher?>(null), | ||||
|       currentPublisher: ValueNotifier<SnPublisher?>(originalPost?.publisher), | ||||
|       tagsController: tagsController, | ||||
|       categoriesController: categoriesController, | ||||
|       draftId: id, | ||||
| @@ -482,6 +474,23 @@ class ComposeLogic { | ||||
|     state.attachments.value = clone; | ||||
|   } | ||||
|  | ||||
|   static void insertAttachment(WidgetRef ref, ComposeState state, int index) { | ||||
|     final attachment = state.attachments.value[index]; | ||||
|     if (!attachment.isOnCloud) { | ||||
|       return; | ||||
|     } | ||||
|     final cloudFile = attachment.data as SnCloudFile; | ||||
|     final markdown = ''; | ||||
|     final controller = state.contentController; | ||||
|     final text = controller.text; | ||||
|     final selection = controller.selection; | ||||
|     final newText = text.replaceRange(selection.start, selection.end, markdown); | ||||
|     controller.text = newText; | ||||
|     controller.selection = TextSelection.fromPosition( | ||||
|       TextPosition(offset: selection.start + markdown.length), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   static Future<void> performAction( | ||||
|     WidgetRef ref, | ||||
|     ComposeState state, | ||||
|   | ||||
| @@ -11,6 +11,7 @@ import 'package:island/models/post.dart'; | ||||
| import 'package:island/pods/config.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/screens/posts/compose.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
| import 'package:island/services/time.dart'; | ||||
| import 'package:island/widgets/account/account_name.dart'; | ||||
| @@ -55,106 +56,192 @@ class PostItem extends HookConsumerWidget { | ||||
|  | ||||
|     final user = ref.watch(userInfoProvider); | ||||
|     final isAuthor = useMemoized( | ||||
|       () => user.hasValue && user.value?.id == item.publisher.accountId, | ||||
|       () => user.value != null && user.value?.id == item.publisher.accountId, | ||||
|       [user], | ||||
|     ); | ||||
|  | ||||
|     final hasBackground = | ||||
|         ref.watch(backgroundImageFileProvider).valueOrNull != null; | ||||
|  | ||||
|     return ContextMenuWidget( | ||||
|       menuProvider: (_) { | ||||
|         return Menu( | ||||
|     Widget child; | ||||
|     if (item.type == 1 && isFullPost) { | ||||
|       child = Padding( | ||||
|         padding: renderingPadding, | ||||
|         child: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             if (isAuthor) | ||||
|               MenuAction( | ||||
|                 title: 'edit'.tr(), | ||||
|                 image: MenuImage.icon(Symbols.edit), | ||||
|                 callback: () { | ||||
|                   context.push('/posts/${item.id}/edit').then((value) { | ||||
|                     if (value != null) { | ||||
|                       onRefresh?.call(); | ||||
|                     } | ||||
|                   }); | ||||
|             GestureDetector( | ||||
|               onTap: () { | ||||
|                 context.push('/publishers/${item.publisher.name}'); | ||||
|               }, | ||||
|               child: Row( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                 children: [ | ||||
|                   ProfilePictureWidget(file: item.publisher.picture), | ||||
|                   const Gap(12), | ||||
|                   Expanded( | ||||
|                     child: Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         Text(item.publisher.nick).bold(), | ||||
|                         if (item.publisher.verification != null) | ||||
|                           VerificationMark( | ||||
|                             mark: item.publisher.verification!, | ||||
|                           ).padding(left: 4), | ||||
|                       ], | ||||
|                     ), | ||||
|             if (isAuthor) | ||||
|               MenuAction( | ||||
|                 title: 'delete'.tr(), | ||||
|                 image: MenuImage.icon(Symbols.delete), | ||||
|                 callback: () { | ||||
|                   showConfirmAlert( | ||||
|                     'deletePostHint'.tr(), | ||||
|                     'deletePost'.tr(), | ||||
|                   ).then((confirm) { | ||||
|                     if (confirm) { | ||||
|                       final client = ref.watch(apiClientProvider); | ||||
|                       client | ||||
|                           .delete('/posts/${item.id}') | ||||
|                           .catchError((err) { | ||||
|                             showErrorAlert(err); | ||||
|                             return err; | ||||
|                           }) | ||||
|                           .then((_) { | ||||
|                             onRefresh?.call(); | ||||
|                           }); | ||||
|                     } | ||||
|                   }); | ||||
|                 }, | ||||
|                   ), | ||||
|             if (isAuthor) MenuSeparator(), | ||||
|             MenuAction( | ||||
|               title: 'copyLink'.tr(), | ||||
|               image: MenuImage.icon(Symbols.link), | ||||
|               callback: () { | ||||
|                 Clipboard.setData( | ||||
|                   ClipboardData(text: 'https://solsynth.dev/posts/${item.id}'), | ||||
|                 ); | ||||
|               }, | ||||
|                   Text( | ||||
|                     isFullPost | ||||
|                         ? item.publishedAt?.formatSystem() ?? '' | ||||
|                         : item.publishedAt?.formatRelative(context) ?? '', | ||||
|                   ).fontSize(11), | ||||
|                 ], | ||||
|               ), | ||||
|             MenuAction( | ||||
|               title: 'reply'.tr(), | ||||
|               image: MenuImage.icon(Symbols.reply), | ||||
|               callback: () { | ||||
|                 context.push('/posts/compose', extra: {'repliedPost': item}); | ||||
|               }, | ||||
|             ), | ||||
|             MenuAction( | ||||
|               title: 'forward'.tr(), | ||||
|               image: MenuImage.icon(Symbols.forward), | ||||
|               callback: () { | ||||
|                 context.push('/posts/compose', extra: {'forwardedPost': item}); | ||||
|               }, | ||||
|             if (item.visibility != 0) | ||||
|               Row( | ||||
|                 mainAxisSize: MainAxisSize.min, | ||||
|                 children: [ | ||||
|                   Icon( | ||||
|                     _getVisibilityIcon(item.visibility), | ||||
|                     size: 14, | ||||
|                     color: Theme.of(context).colorScheme.secondary, | ||||
|                   ), | ||||
|             MenuSeparator(), | ||||
|             MenuAction( | ||||
|               title: 'share'.tr(), | ||||
|               image: MenuImage.icon(Symbols.share), | ||||
|               callback: () { | ||||
|                 showShareSheetLink( | ||||
|                   context: context, | ||||
|                   link: '${ref.read(serverUrlProvider)}/posts/${item.id}', | ||||
|                   title: 'sharePost'.tr(), | ||||
|                   toSystem: true, | ||||
|                 ); | ||||
|               }, | ||||
|                   const SizedBox(width: 4), | ||||
|                   Text( | ||||
|                     _getVisibilityText(item.visibility).tr(), | ||||
|                     style: TextStyle( | ||||
|                       fontSize: 12, | ||||
|                       color: Theme.of(context).colorScheme.secondary, | ||||
|                     ), | ||||
|             MenuAction( | ||||
|               title: 'abuseReport'.tr(), | ||||
|               image: MenuImage.icon(Symbols.flag), | ||||
|               callback: () { | ||||
|                 showAbuseReportSheet( | ||||
|                   context, | ||||
|                   resourceIdentifier: 'posts:${item.id}', | ||||
|                 ); | ||||
|               }, | ||||
|                   ), | ||||
|                 ], | ||||
|               ).padding(top: 10, bottom: 2), | ||||
|             const Gap(16), | ||||
|             _ArticlePostDisplay(item: item, isFullPost: isFullPost), | ||||
|             if (item.tags.isNotEmpty || item.categories.isNotEmpty) | ||||
|               Column( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                 children: [ | ||||
|                   if (item.tags.isNotEmpty) | ||||
|                     Wrap( | ||||
|                       children: [ | ||||
|                         for (final tag in item.tags) | ||||
|                           InkWell( | ||||
|                             child: Row( | ||||
|                               spacing: 4, | ||||
|                               children: [ | ||||
|                                 const Icon(Symbols.label, size: 13), | ||||
|                                 Text(tag.name ?? '#${tag.slug}').fontSize(13), | ||||
|                               ], | ||||
|                             ), | ||||
|                             onTap: () {}, | ||||
|                           ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   if (item.categories.isNotEmpty) | ||||
|                     Wrap( | ||||
|                       children: [ | ||||
|                         for (final category in item.categories) | ||||
|                           InkWell( | ||||
|                             child: Row( | ||||
|                               spacing: 4, | ||||
|                               children: [ | ||||
|                                 const Icon(Symbols.category, size: 13), | ||||
|                                 Text( | ||||
|                                   category.name ?? '#${category.slug}', | ||||
|                                 ).fontSize(13), | ||||
|                               ], | ||||
|                             ), | ||||
|                             onTap: () {}, | ||||
|                           ), | ||||
|                       ], | ||||
|                     ), | ||||
|                 ], | ||||
|               ), | ||||
|             if ((item.repliedPost != null || item.forwardedPost != null) && | ||||
|                 showReferencePost) | ||||
|               _buildReferencePost(context, item), | ||||
|             if (item.attachments.isNotEmpty && item.type != 1) | ||||
|               CloudFileList( | ||||
|                 files: item.attachments, | ||||
|                 maxWidth: math.min( | ||||
|                   MediaQuery.of(context).size.width, | ||||
|                   kWideScreenWidth, | ||||
|                 ), | ||||
|                 minWidth: math.min( | ||||
|                   MediaQuery.of(context).size.width, | ||||
|                   kWideScreenWidth, | ||||
|                 ), | ||||
|               ), | ||||
|             if (item.meta?['embeds'] != null) | ||||
|               ...((item.meta!['embeds'] as List<dynamic>) | ||||
|                   .where((embed) => embed['Type'] == 'link') | ||||
|                   .map( | ||||
|                     (embedData) => EmbedLinkWidget( | ||||
|                       link: SnEmbedLink.fromJson( | ||||
|                         embedData as Map<String, dynamic>, | ||||
|                       ), | ||||
|                       maxWidth: math.min( | ||||
|                         MediaQuery.of(context).size.width, | ||||
|                         kWideScreenWidth, | ||||
|                       ), | ||||
|                       margin: EdgeInsets.only(top: 8), | ||||
|                     ), | ||||
|                   )), | ||||
|             const Gap(8), | ||||
|             Row( | ||||
|               children: [ | ||||
|                 Padding( | ||||
|                   padding: const EdgeInsets.only(right: 12), | ||||
|                   child: ActionChip( | ||||
|                     avatar: Icon(Symbols.reply, size: 16), | ||||
|                     label: Text( | ||||
|                       (item.repliesCount > 0) | ||||
|                           ? 'repliesCount'.plural(item.repliesCount) | ||||
|                           : 'reply'.tr(), | ||||
|                     ), | ||||
|                     visualDensity: const VisualDensity( | ||||
|                       horizontal: VisualDensity.minimumDensity, | ||||
|                       vertical: VisualDensity.minimumDensity, | ||||
|                     ), | ||||
|                     onPressed: () { | ||||
|                       if (isOpenable) { | ||||
|                         showModalBottomSheet( | ||||
|                           context: context, | ||||
|                           isScrollControlled: true, | ||||
|                           useRootNavigator: true, | ||||
|                           builder: (context) => PostRepliesSheet(post: item), | ||||
|                         ); | ||||
|                       } | ||||
|                     }, | ||||
|                   ), | ||||
|                 ), | ||||
|                 Expanded( | ||||
|                   child: PostReactionList( | ||||
|                     parentId: item.id, | ||||
|                     reactions: item.reactionsCount, | ||||
|                     padding: EdgeInsets.zero, | ||||
|                     onReact: (symbol, attitude, delta) { | ||||
|                       final reactionsCount = Map<String, int>.from( | ||||
|                         item.reactionsCount, | ||||
|                       ); | ||||
|                       reactionsCount[symbol] = | ||||
|                           (reactionsCount[symbol] ?? 0) + delta; | ||||
|                       onUpdate?.call( | ||||
|                         item.copyWith(reactionsCount: reactionsCount), | ||||
|                       ); | ||||
|                     }, | ||||
|       child: Material( | ||||
|         color: hasBackground ? Colors.transparent : backgroundColor, | ||||
|         child: Padding( | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
|     } else { | ||||
|       child = Padding( | ||||
|         padding: renderingPadding, | ||||
|         child: Column( | ||||
|           spacing: 8, | ||||
| @@ -186,9 +273,7 @@ class PostItem extends HookConsumerWidget { | ||||
|                             Text( | ||||
|                               isFullPost | ||||
|                                   ? item.publishedAt?.formatSystem() ?? '' | ||||
|                                     : item.publishedAt?.formatRelative( | ||||
|                                           context, | ||||
|                                         ) ?? | ||||
|                                   : item.publishedAt?.formatRelative(context) ?? | ||||
|                                       '', | ||||
|                             ).fontSize(11).alignment(Alignment.bottomRight), | ||||
|                             const Gap(4), | ||||
| @@ -202,8 +287,7 @@ class PostItem extends HookConsumerWidget { | ||||
|                               Icon( | ||||
|                                 _getVisibilityIcon(item.visibility), | ||||
|                                 size: 14, | ||||
|                                   color: | ||||
|                                       Theme.of(context).colorScheme.secondary, | ||||
|                                 color: Theme.of(context).colorScheme.secondary, | ||||
|                               ), | ||||
|                               const SizedBox(width: 4), | ||||
|                               Text( | ||||
| @@ -216,6 +300,12 @@ class PostItem extends HookConsumerWidget { | ||||
|                               ), | ||||
|                             ], | ||||
|                           ).padding(top: 2, bottom: 2), | ||||
|                         if (item.type == 1) | ||||
|                           _ArticlePostDisplay( | ||||
|                             item: item, | ||||
|                             isFullPost: isFullPost, | ||||
|                           ) | ||||
|                         else ...[ | ||||
|                           if (item.title?.isNotEmpty ?? false) | ||||
|                             Text( | ||||
|                               item.title!, | ||||
| @@ -241,10 +331,11 @@ class PostItem extends HookConsumerWidget { | ||||
|                                   item.type == 0 | ||||
|                                       ? EdgeInsets.only(bottom: 8) | ||||
|                                       : null, | ||||
|                               attachments: item.attachments, | ||||
|                             ), | ||||
|                         ], | ||||
|                         // Render tags and categories if they exist | ||||
|                           if (item.tags.isNotEmpty || | ||||
|                               item.categories.isNotEmpty) | ||||
|                         if (item.tags.isNotEmpty || item.categories.isNotEmpty) | ||||
|                           Column( | ||||
|                             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                             children: [ | ||||
| @@ -256,10 +347,7 @@ class PostItem extends HookConsumerWidget { | ||||
|                                         child: Row( | ||||
|                                           spacing: 4, | ||||
|                                           children: [ | ||||
|                                               const Icon( | ||||
|                                                 Symbols.label, | ||||
|                                                 size: 13, | ||||
|                                               ), | ||||
|                                             const Icon(Symbols.label, size: 13), | ||||
|                                             Text( | ||||
|                                               tag.name ?? '#${tag.slug}', | ||||
|                                             ).fontSize(13), | ||||
| @@ -294,15 +382,20 @@ class PostItem extends HookConsumerWidget { | ||||
|                             ], | ||||
|                           ), | ||||
|                         // Show truncation hint if post is truncated | ||||
|                           if (item.isTruncated && !isFullPost) | ||||
|                         if (item.isTruncated && !isFullPost && item.type != 1) | ||||
|                           _PostTruncateHint().padding( | ||||
|                               bottom: item.attachments.isNotEmpty ? 8 : null, | ||||
|                             bottom: | ||||
|                                 (item.attachments.isNotEmpty || | ||||
|                                         item.repliedPost != null || | ||||
|                                         item.forwardedPost != null) | ||||
|                                     ? 8 | ||||
|                                     : null, | ||||
|                           ), | ||||
|                         if ((item.repliedPost != null || | ||||
|                                 item.forwardedPost != null) && | ||||
|                             showReferencePost) | ||||
|                           _buildReferencePost(context, item), | ||||
|                           if (item.attachments.isNotEmpty) | ||||
|                         if (item.attachments.isNotEmpty && item.type != 1) | ||||
|                           CloudFileList( | ||||
|                             files: item.attachments, | ||||
|                             maxWidth: math.min( | ||||
| @@ -391,7 +484,108 @@ class PostItem extends HookConsumerWidget { | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return ContextMenuWidget( | ||||
|       menuProvider: (_) { | ||||
|         return Menu( | ||||
|           children: [ | ||||
|             if (isAuthor) | ||||
|               MenuAction( | ||||
|                 title: 'edit'.tr(), | ||||
|                 image: MenuImage.icon(Symbols.edit), | ||||
|                 callback: () { | ||||
|                   context.push('/posts/${item.id}/edit').then((value) { | ||||
|                     if (value != null) { | ||||
|                       onRefresh?.call(); | ||||
|                     } | ||||
|                   }); | ||||
|                 }, | ||||
|               ), | ||||
|             if (isAuthor) | ||||
|               MenuAction( | ||||
|                 title: 'delete'.tr(), | ||||
|                 image: MenuImage.icon(Symbols.delete), | ||||
|                 callback: () { | ||||
|                   showConfirmAlert( | ||||
|                     'deletePostHint'.tr(), | ||||
|                     'deletePost'.tr(), | ||||
|                   ).then((confirm) { | ||||
|                     if (confirm) { | ||||
|                       final client = ref.watch(apiClientProvider); | ||||
|                       client | ||||
|                           .delete('/posts/${item.id}') | ||||
|                           .catchError((err) { | ||||
|                             showErrorAlert(err); | ||||
|                             return err; | ||||
|                           }) | ||||
|                           .then((_) { | ||||
|                             onRefresh?.call(); | ||||
|                           }); | ||||
|                     } | ||||
|                   }); | ||||
|                 }, | ||||
|               ), | ||||
|             if (isAuthor) MenuSeparator(), | ||||
|             MenuAction( | ||||
|               title: 'copyLink'.tr(), | ||||
|               image: MenuImage.icon(Symbols.link), | ||||
|               callback: () { | ||||
|                 Clipboard.setData( | ||||
|                   ClipboardData(text: 'https://solsynth.dev/posts/${item.id}'), | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|             MenuAction( | ||||
|               title: 'reply'.tr(), | ||||
|               image: MenuImage.icon(Symbols.reply), | ||||
|               callback: () { | ||||
|                 context.push( | ||||
|                   '/posts/compose', | ||||
|                   extra: PostComposeInitialState(replyingTo: item), | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|             MenuAction( | ||||
|               title: 'forward'.tr(), | ||||
|               image: MenuImage.icon(Symbols.forward), | ||||
|               callback: () { | ||||
|                 context.push( | ||||
|                   '/posts/compose', | ||||
|                   extra: PostComposeInitialState(forwardingTo: item), | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|             MenuSeparator(), | ||||
|             MenuAction( | ||||
|               title: 'share'.tr(), | ||||
|               image: MenuImage.icon(Symbols.share), | ||||
|               callback: () { | ||||
|                 showShareSheetLink( | ||||
|                   context: context, | ||||
|                   link: '${ref.read(serverUrlProvider)}/posts/${item.id}', | ||||
|                   title: 'sharePost'.tr(), | ||||
|                   toSystem: true, | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|             MenuAction( | ||||
|               title: 'abuseReport'.tr(), | ||||
|               image: MenuImage.icon(Symbols.flag), | ||||
|               callback: () { | ||||
|                 showAbuseReportSheet( | ||||
|                   context, | ||||
|                   resourceIdentifier: 'post/${item.id}', | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|           ], | ||||
|         ); | ||||
|       }, | ||||
|       child: Material( | ||||
|         color: hasBackground ? Colors.transparent : backgroundColor, | ||||
|         child: child, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| @@ -501,6 +695,7 @@ Widget _buildReferencePost(BuildContext context, SnPost item) { | ||||
|                           referencePost.type == 0 | ||||
|                               ? EdgeInsets.only(bottom: 4) | ||||
|                               : null, | ||||
|                       attachments: item.attachments, | ||||
|                     ).padding(bottom: 4), | ||||
|                   // Truncation hint for referenced post | ||||
|                   if (referencePost.isTruncated) | ||||
| @@ -508,7 +703,8 @@ Widget _buildReferencePost(BuildContext context, SnPost item) { | ||||
|                       isCompact: true, | ||||
|                       margin: const EdgeInsets.only(top: 4, bottom: 8), | ||||
|                     ), | ||||
|                   if (referencePost.attachments.isNotEmpty) | ||||
|                   if (referencePost.attachments.isNotEmpty && | ||||
|                       referencePost.type != 1) | ||||
|                     Row( | ||||
|                       mainAxisSize: MainAxisSize.min, | ||||
|                       children: [ | ||||
| @@ -805,6 +1001,129 @@ class _PostTruncateHint extends StatelessWidget { | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _ArticlePostDisplay extends StatelessWidget { | ||||
|   final SnPost item; | ||||
|   final bool isFullPost; | ||||
|  | ||||
|   const _ArticlePostDisplay({required this.item, required this.isFullPost}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     if (isFullPost) { | ||||
|       // Full article view | ||||
|       return Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           if (item.title?.isNotEmpty ?? false) | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.only(bottom: 8.0), | ||||
|               child: Text( | ||||
|                 item.title!, | ||||
|                 style: Theme.of(context).textTheme.headlineSmall?.copyWith( | ||||
|                   fontWeight: FontWeight.bold, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           if (item.description?.isNotEmpty ?? false) | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.only(bottom: 16.0), | ||||
|               child: Text( | ||||
|                 item.description!, | ||||
|                 style: Theme.of(context).textTheme.bodyLarge?.copyWith( | ||||
|                   color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           if (item.content?.isNotEmpty ?? false) | ||||
|             MarkdownTextContent( | ||||
|               content: item.content!, | ||||
|               textStyle: Theme.of(context).textTheme.bodyLarge, | ||||
|               attachments: item.attachments, | ||||
|             ), | ||||
|         ], | ||||
|       ); | ||||
|     } else { | ||||
|       // Truncated/Card view | ||||
|       String? previewContent; | ||||
|       if (item.description?.isNotEmpty ?? false) { | ||||
|         previewContent = item.description!; | ||||
|       } else if (item.content?.isNotEmpty ?? false) { | ||||
|         previewContent = item.content!; | ||||
|       } | ||||
|  | ||||
|       return Card( | ||||
|         elevation: 0, | ||||
|         margin: const EdgeInsets.only(top: 4), | ||||
|         color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), | ||||
|         shape: RoundedRectangleBorder( | ||||
|           borderRadius: BorderRadius.circular(12), | ||||
|           side: BorderSide( | ||||
|             color: Theme.of(context).colorScheme.outline.withOpacity(0.2), | ||||
|           ), | ||||
|         ), | ||||
|         child: Padding( | ||||
|           padding: const EdgeInsets.all(16.0), | ||||
|           child: Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             mainAxisSize: MainAxisSize.min, | ||||
|             children: [ | ||||
|               if (item.title?.isNotEmpty ?? false) | ||||
|                 Text( | ||||
|                   item.title!, | ||||
|                   style: Theme.of(context).textTheme.titleMedium?.copyWith( | ||||
|                     fontWeight: FontWeight.bold, | ||||
|                   ), | ||||
|                   maxLines: 2, | ||||
|                   overflow: TextOverflow.ellipsis, | ||||
|                 ), | ||||
|               if (previewContent != null) ...[ | ||||
|                 const Gap(8), | ||||
|                 Text( | ||||
|                   previewContent, | ||||
|                   style: Theme.of(context).textTheme.bodyMedium?.copyWith( | ||||
|                     color: Theme.of(context).colorScheme.onSurfaceVariant, | ||||
|                   ), | ||||
|                   maxLines: 3, | ||||
|                   overflow: TextOverflow.ellipsis, | ||||
|                 ), | ||||
|               ], | ||||
|               Container( | ||||
|                 margin: const EdgeInsets.only(top: 8), | ||||
|                 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), | ||||
|                 decoration: BoxDecoration( | ||||
|                   color: Theme.of( | ||||
|                     context, | ||||
|                   ).colorScheme.surfaceContainerHighest.withOpacity(0.5), | ||||
|                   borderRadius: BorderRadius.circular(20), | ||||
|                 ), | ||||
|                 child: Row( | ||||
|                   mainAxisSize: MainAxisSize.min, | ||||
|                   children: [ | ||||
|                     Icon( | ||||
|                       Symbols.article, | ||||
|                       size: 16, | ||||
|                       color: Theme.of(context).colorScheme.secondary, | ||||
|                     ), | ||||
|                     const SizedBox(width: 6), | ||||
|                     Text( | ||||
|                       'postArticle'.tr(), | ||||
|                       style: TextStyle( | ||||
|                         color: Theme.of(context).colorScheme.secondary, | ||||
|                         fontSize: 12, | ||||
|                       ), | ||||
|                     ), | ||||
|                     const SizedBox(width: 4), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Helper method to get the appropriate icon for each visibility status | ||||
| IconData _getVisibilityIcon(int visibility) { | ||||
|   switch (visibility) { | ||||
|   | ||||
| @@ -87,7 +87,7 @@ class PostItemCreator extends HookConsumerWidget { | ||||
|         ); | ||||
|       }, | ||||
|       child: Material( | ||||
|         color: Colors.transparent, | ||||
|         color: backgroundColor ?? Theme.of(context).colorScheme.surface, | ||||
|         borderRadius: BorderRadius.circular(12), | ||||
|         elevation: 1, | ||||
|         child: InkWell( | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/pods/userinfo.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:island/widgets/post/post_replies.dart'; | ||||
| import 'package:island/widgets/post/post_quick_reply.dart'; | ||||
| @@ -14,6 +15,8 @@ class PostRepliesSheet extends HookConsumerWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final user = ref.watch(userInfoProvider); | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'repliesCount'.plural(post.repliesCount), | ||||
|       child: Column( | ||||
| @@ -21,13 +24,16 @@ class PostRepliesSheet extends HookConsumerWidget { | ||||
|           // Replies list | ||||
|           Expanded( | ||||
|             child: CustomScrollView( | ||||
|               slivers: [PostRepliesList( | ||||
|               slivers: [ | ||||
|                 PostRepliesList( | ||||
|                   postId: post.id.toString(), | ||||
|                   backgroundColor: Colors.transparent, | ||||
|               )], | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|           // Quick reply section | ||||
|           if (user.value != null) | ||||
|             Material( | ||||
|               elevation: 2, | ||||
|               child: PostQuickReply( | ||||
|   | ||||
| @@ -1025,7 +1025,7 @@ packages: | ||||
|     source: hosted | ||||
|     version: "4.0.0" | ||||
|   flutter_web_plugins: | ||||
|     dependency: transitive | ||||
|     dependency: "direct main" | ||||
|     description: flutter | ||||
|     source: sdk | ||||
|     version: "0.0.0" | ||||
| @@ -1097,10 +1097,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: go_router | ||||
|       sha256: ac294be30ba841830cfa146e5a3b22bb09f8dc5a0fdd9ca9332b04b0bde99ebf | ||||
|       sha256: c489908a54ce2131f1d1b7cc631af9c1a06fac5ca7c449e959192089f9489431 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "15.2.4" | ||||
|     version: "16.0.0" | ||||
|   google_fonts: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|   | ||||
| @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev | ||||
| # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html | ||||
| # In Windows, build-name is used as the major, minor, and patch parts | ||||
| # of the product and file versions while build-number is used as the build suffix. | ||||
| version: 3.0.0+110 | ||||
| version: 3.0.0+112 | ||||
|  | ||||
| environment: | ||||
|   sdk: ^3.7.2 | ||||
| @@ -30,6 +30,8 @@ environment: | ||||
| dependencies: | ||||
|   flutter: | ||||
|     sdk: flutter | ||||
|   flutter_web_plugins: | ||||
|     sdk: flutter | ||||
|  | ||||
|   # The following adds the Cupertino Icons font to your application. | ||||
|   # Use with the CupertinoIcons class for iOS style icons. | ||||
| @@ -37,7 +39,7 @@ dependencies: | ||||
|   flutter_hooks: ^0.21.2 | ||||
|   hooks_riverpod: ^2.6.1 | ||||
|   bitsdojo_window: ^0.1.6 | ||||
|   go_router: ^15.1.3 | ||||
|   go_router: ^16.0.0 | ||||
|   styled_widget: ^0.4.1 | ||||
|   shared_preferences: ^2.5.3 | ||||
|   flutter_riverpod: ^2.6.1 | ||||
|   | ||||
							
								
								
									
										25
									
								
								web/.well-known/apple-app-site-association
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								web/.well-known/apple-app-site-association
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| { | ||||
|   "applinks": { | ||||
|     "apps": [], | ||||
|     "details": [ | ||||
|       { | ||||
|         "appIDs": [ | ||||
|           "W7HPZ53V6B.dev.solsynth.solian" | ||||
|         ], | ||||
|         "paths": [ | ||||
|           "*" | ||||
|         ], | ||||
|         "components": [ | ||||
|           { | ||||
|             "/": "/*" | ||||
|           } | ||||
|         ] | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
|   "webcredentials": { | ||||
|     "apps": [ | ||||
|       "W7HPZ53V6B.dev.solsynth.solian" | ||||
|     ] | ||||
|   } | ||||
| } | ||||
							
								
								
									
										12
									
								
								web/.well-known/assetlinks.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								web/.well-known/assetlinks.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| [ | ||||
|   { | ||||
|     "relation": ["delegate_permission/common.handle_all_urls"], | ||||
|     "target": { | ||||
|       "namespace": "android_app", | ||||
|       "package_name": "dev.solsynth.solian", | ||||
|       "sha256_cert_fingerprints": [ | ||||
|         "57:0C:A4:E6:1F:57:DF:56:70:42:05:4B:43:E2:DD:9E:00:E6:77:C3:D8:3C:5F:D5:A0:05:59:30:5A:85:F9:BC" | ||||
|       ] | ||||
|     } | ||||
|   } | ||||
| ] | ||||
		Reference in New Issue
	
	Block a user