diff --git a/lib/models/webfeed.dart b/lib/models/webfeed.dart new file mode 100644 index 0000000..8849b70 --- /dev/null +++ b/lib/models/webfeed.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:island/models/embed.dart'; + +part 'webfeed.freezed.dart'; +part 'webfeed.g.dart'; + +@freezed +sealed class WebFeedConfig with _$WebFeedConfig { + const factory WebFeedConfig({@Default(false) bool scrapPage}) = + _WebFeedConfig; + + factory WebFeedConfig.fromJson(Map json) => + _$WebFeedConfigFromJson(json); +} + +@freezed +sealed class WebFeed with _$WebFeed { + const factory WebFeed({ + required String id, + required String url, + required String title, + String? description, + SnScrappedLink? preview, + @Default(WebFeedConfig()) WebFeedConfig config, + required String publisherId, + @Default([]) List articles, + required DateTime createdAt, + required DateTime updatedAt, + DateTime? deletedAt, + }) = _WebFeed; + + factory WebFeed.fromJson(Map json) => + _$WebFeedFromJson(json); + + factory WebFeed.fromJsonString(String jsonString) => + WebFeed.fromJson(jsonDecode(jsonString) as Map); +} + +@freezed +sealed class WebArticle with _$WebArticle { + const factory WebArticle({ + required String id, + required String title, + required String url, + String? author, + Map? meta, + SnScrappedLink? preview, + String? content, + DateTime? publishedAt, + required String feedId, + required DateTime createdAt, + required DateTime updatedAt, + DateTime? deletedAt, + }) = _WebArticle; + + factory WebArticle.fromJson(Map json) => + _$WebArticleFromJson(json); + + factory WebArticle.fromJsonString(String jsonString) => + WebArticle.fromJson(jsonDecode(jsonString) as Map); +} diff --git a/lib/models/webfeed.freezed.dart b/lib/models/webfeed.freezed.dart new file mode 100644 index 0000000..c133f39 --- /dev/null +++ b/lib/models/webfeed.freezed.dart @@ -0,0 +1,557 @@ +// 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 'webfeed.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$WebFeedConfig { + + bool get scrapPage; +/// Create a copy of WebFeedConfig +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$WebFeedConfigCopyWith get copyWith => _$WebFeedConfigCopyWithImpl(this as WebFeedConfig, _$identity); + + /// Serializes this WebFeedConfig to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is WebFeedConfig&&(identical(other.scrapPage, scrapPage) || other.scrapPage == scrapPage)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,scrapPage); + +@override +String toString() { + return 'WebFeedConfig(scrapPage: $scrapPage)'; +} + + +} + +/// @nodoc +abstract mixin class $WebFeedConfigCopyWith<$Res> { + factory $WebFeedConfigCopyWith(WebFeedConfig value, $Res Function(WebFeedConfig) _then) = _$WebFeedConfigCopyWithImpl; +@useResult +$Res call({ + bool scrapPage +}); + + + + +} +/// @nodoc +class _$WebFeedConfigCopyWithImpl<$Res> + implements $WebFeedConfigCopyWith<$Res> { + _$WebFeedConfigCopyWithImpl(this._self, this._then); + + final WebFeedConfig _self; + final $Res Function(WebFeedConfig) _then; + +/// Create a copy of WebFeedConfig +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? scrapPage = null,}) { + return _then(_self.copyWith( +scrapPage: null == scrapPage ? _self.scrapPage : scrapPage // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// @nodoc +@JsonSerializable() + +class _WebFeedConfig implements WebFeedConfig { + const _WebFeedConfig({this.scrapPage = false}); + factory _WebFeedConfig.fromJson(Map json) => _$WebFeedConfigFromJson(json); + +@override@JsonKey() final bool scrapPage; + +/// Create a copy of WebFeedConfig +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$WebFeedConfigCopyWith<_WebFeedConfig> get copyWith => __$WebFeedConfigCopyWithImpl<_WebFeedConfig>(this, _$identity); + +@override +Map toJson() { + return _$WebFeedConfigToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WebFeedConfig&&(identical(other.scrapPage, scrapPage) || other.scrapPage == scrapPage)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,scrapPage); + +@override +String toString() { + return 'WebFeedConfig(scrapPage: $scrapPage)'; +} + + +} + +/// @nodoc +abstract mixin class _$WebFeedConfigCopyWith<$Res> implements $WebFeedConfigCopyWith<$Res> { + factory _$WebFeedConfigCopyWith(_WebFeedConfig value, $Res Function(_WebFeedConfig) _then) = __$WebFeedConfigCopyWithImpl; +@override @useResult +$Res call({ + bool scrapPage +}); + + + + +} +/// @nodoc +class __$WebFeedConfigCopyWithImpl<$Res> + implements _$WebFeedConfigCopyWith<$Res> { + __$WebFeedConfigCopyWithImpl(this._self, this._then); + + final _WebFeedConfig _self; + final $Res Function(_WebFeedConfig) _then; + +/// Create a copy of WebFeedConfig +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? scrapPage = null,}) { + return _then(_WebFeedConfig( +scrapPage: null == scrapPage ? _self.scrapPage : scrapPage // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + + +/// @nodoc +mixin _$WebFeed { + + String get id; String get url; String get title; String? get description; SnScrappedLink? get preview; WebFeedConfig get config; String get publisherId; List get articles; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; +/// Create a copy of WebFeed +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$WebFeedCopyWith get copyWith => _$WebFeedCopyWithImpl(this as WebFeed, _$identity); + + /// Serializes this WebFeed to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is WebFeed&&(identical(other.id, id) || other.id == id)&&(identical(other.url, url) || other.url == url)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.preview, preview) || other.preview == preview)&&(identical(other.config, config) || other.config == config)&&(identical(other.publisherId, publisherId) || other.publisherId == publisherId)&&const DeepCollectionEquality().equals(other.articles, articles)&&(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,url,title,description,preview,config,publisherId,const DeepCollectionEquality().hash(articles),createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'WebFeed(id: $id, url: $url, title: $title, description: $description, preview: $preview, config: $config, publisherId: $publisherId, articles: $articles, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $WebFeedCopyWith<$Res> { + factory $WebFeedCopyWith(WebFeed value, $Res Function(WebFeed) _then) = _$WebFeedCopyWithImpl; +@useResult +$Res call({ + String id, String url, String title, String? description, SnScrappedLink? preview, WebFeedConfig config, String publisherId, List articles, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + +$SnScrappedLinkCopyWith<$Res>? get preview;$WebFeedConfigCopyWith<$Res> get config; + +} +/// @nodoc +class _$WebFeedCopyWithImpl<$Res> + implements $WebFeedCopyWith<$Res> { + _$WebFeedCopyWithImpl(this._self, this._then); + + final WebFeed _self; + final $Res Function(WebFeed) _then; + +/// Create a copy of WebFeed +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? url = null,Object? title = null,Object? description = freezed,Object? preview = freezed,Object? config = null,Object? publisherId = null,Object? articles = 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,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,title: null == 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?,preview: freezed == preview ? _self.preview : preview // ignore: cast_nullable_to_non_nullable +as SnScrappedLink?,config: null == config ? _self.config : config // ignore: cast_nullable_to_non_nullable +as WebFeedConfig,publisherId: null == publisherId ? _self.publisherId : publisherId // ignore: cast_nullable_to_non_nullable +as String,articles: null == articles ? _self.articles : articles // ignore: cast_nullable_to_non_nullable +as List,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?, + )); +} +/// Create a copy of WebFeed +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnScrappedLinkCopyWith<$Res>? get preview { + if (_self.preview == null) { + return null; + } + + return $SnScrappedLinkCopyWith<$Res>(_self.preview!, (value) { + return _then(_self.copyWith(preview: value)); + }); +}/// Create a copy of WebFeed +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$WebFeedConfigCopyWith<$Res> get config { + + return $WebFeedConfigCopyWith<$Res>(_self.config, (value) { + return _then(_self.copyWith(config: value)); + }); +} +} + + +/// @nodoc +@JsonSerializable() + +class _WebFeed implements WebFeed { + const _WebFeed({required this.id, required this.url, required this.title, this.description, this.preview, this.config = const WebFeedConfig(), required this.publisherId, final List articles = const [], required this.createdAt, required this.updatedAt, this.deletedAt}): _articles = articles; + factory _WebFeed.fromJson(Map json) => _$WebFeedFromJson(json); + +@override final String id; +@override final String url; +@override final String title; +@override final String? description; +@override final SnScrappedLink? preview; +@override@JsonKey() final WebFeedConfig config; +@override final String publisherId; + final List _articles; +@override@JsonKey() List get articles { + if (_articles is EqualUnmodifiableListView) return _articles; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_articles); +} + +@override final DateTime createdAt; +@override final DateTime updatedAt; +@override final DateTime? deletedAt; + +/// Create a copy of WebFeed +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$WebFeedCopyWith<_WebFeed> get copyWith => __$WebFeedCopyWithImpl<_WebFeed>(this, _$identity); + +@override +Map toJson() { + return _$WebFeedToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WebFeed&&(identical(other.id, id) || other.id == id)&&(identical(other.url, url) || other.url == url)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.preview, preview) || other.preview == preview)&&(identical(other.config, config) || other.config == config)&&(identical(other.publisherId, publisherId) || other.publisherId == publisherId)&&const DeepCollectionEquality().equals(other._articles, _articles)&&(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,url,title,description,preview,config,publisherId,const DeepCollectionEquality().hash(_articles),createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'WebFeed(id: $id, url: $url, title: $title, description: $description, preview: $preview, config: $config, publisherId: $publisherId, articles: $articles, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$WebFeedCopyWith<$Res> implements $WebFeedCopyWith<$Res> { + factory _$WebFeedCopyWith(_WebFeed value, $Res Function(_WebFeed) _then) = __$WebFeedCopyWithImpl; +@override @useResult +$Res call({ + String id, String url, String title, String? description, SnScrappedLink? preview, WebFeedConfig config, String publisherId, List articles, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + +@override $SnScrappedLinkCopyWith<$Res>? get preview;@override $WebFeedConfigCopyWith<$Res> get config; + +} +/// @nodoc +class __$WebFeedCopyWithImpl<$Res> + implements _$WebFeedCopyWith<$Res> { + __$WebFeedCopyWithImpl(this._self, this._then); + + final _WebFeed _self; + final $Res Function(_WebFeed) _then; + +/// Create a copy of WebFeed +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? url = null,Object? title = null,Object? description = freezed,Object? preview = freezed,Object? config = null,Object? publisherId = null,Object? articles = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_WebFeed( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,title: null == 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?,preview: freezed == preview ? _self.preview : preview // ignore: cast_nullable_to_non_nullable +as SnScrappedLink?,config: null == config ? _self.config : config // ignore: cast_nullable_to_non_nullable +as WebFeedConfig,publisherId: null == publisherId ? _self.publisherId : publisherId // ignore: cast_nullable_to_non_nullable +as String,articles: null == articles ? _self._articles : articles // ignore: cast_nullable_to_non_nullable +as List,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?, + )); +} + +/// Create a copy of WebFeed +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnScrappedLinkCopyWith<$Res>? get preview { + if (_self.preview == null) { + return null; + } + + return $SnScrappedLinkCopyWith<$Res>(_self.preview!, (value) { + return _then(_self.copyWith(preview: value)); + }); +}/// Create a copy of WebFeed +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$WebFeedConfigCopyWith<$Res> get config { + + return $WebFeedConfigCopyWith<$Res>(_self.config, (value) { + return _then(_self.copyWith(config: value)); + }); +} +} + + +/// @nodoc +mixin _$WebArticle { + + String get id; String get title; String get url; String? get author; Map? get meta; SnScrappedLink? get preview; String? get content; DateTime? get publishedAt; String get feedId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; +/// Create a copy of WebArticle +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$WebArticleCopyWith get copyWith => _$WebArticleCopyWithImpl(this as WebArticle, _$identity); + + /// Serializes this WebArticle to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is WebArticle&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.url, url) || other.url == url)&&(identical(other.author, author) || other.author == author)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.preview, preview) || other.preview == preview)&&(identical(other.content, content) || other.content == content)&&(identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt)&&(identical(other.feedId, feedId) || other.feedId == feedId)&&(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,title,url,author,const DeepCollectionEquality().hash(meta),preview,content,publishedAt,feedId,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'WebArticle(id: $id, title: $title, url: $url, author: $author, meta: $meta, preview: $preview, content: $content, publishedAt: $publishedAt, feedId: $feedId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $WebArticleCopyWith<$Res> { + factory $WebArticleCopyWith(WebArticle value, $Res Function(WebArticle) _then) = _$WebArticleCopyWithImpl; +@useResult +$Res call({ + String id, String title, String url, String? author, Map? meta, SnScrappedLink? preview, String? content, DateTime? publishedAt, String feedId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + +$SnScrappedLinkCopyWith<$Res>? get preview; + +} +/// @nodoc +class _$WebArticleCopyWithImpl<$Res> + implements $WebArticleCopyWith<$Res> { + _$WebArticleCopyWithImpl(this._self, this._then); + + final WebArticle _self; + final $Res Function(WebArticle) _then; + +/// Create a copy of WebArticle +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = null,Object? url = null,Object? author = freezed,Object? meta = freezed,Object? preview = freezed,Object? content = freezed,Object? publishedAt = freezed,Object? feedId = 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,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,author: freezed == author ? _self.author : author // ignore: cast_nullable_to_non_nullable +as String?,meta: freezed == meta ? _self.meta : meta // ignore: cast_nullable_to_non_nullable +as Map?,preview: freezed == preview ? _self.preview : preview // ignore: cast_nullable_to_non_nullable +as SnScrappedLink?,content: freezed == content ? _self.content : content // ignore: cast_nullable_to_non_nullable +as String?,publishedAt: freezed == publishedAt ? _self.publishedAt : publishedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,feedId: null == feedId ? _self.feedId : feedId // 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?, + )); +} +/// Create a copy of WebArticle +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnScrappedLinkCopyWith<$Res>? get preview { + if (_self.preview == null) { + return null; + } + + return $SnScrappedLinkCopyWith<$Res>(_self.preview!, (value) { + return _then(_self.copyWith(preview: value)); + }); +} +} + + +/// @nodoc +@JsonSerializable() + +class _WebArticle implements WebArticle { + const _WebArticle({required this.id, required this.title, required this.url, this.author, final Map? meta, this.preview, this.content, this.publishedAt, required this.feedId, required this.createdAt, required this.updatedAt, this.deletedAt}): _meta = meta; + factory _WebArticle.fromJson(Map json) => _$WebArticleFromJson(json); + +@override final String id; +@override final String title; +@override final String url; +@override final String? author; + final Map? _meta; +@override Map? get meta { + final value = _meta; + if (value == null) return null; + if (_meta is EqualUnmodifiableMapView) return _meta; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); +} + +@override final SnScrappedLink? preview; +@override final String? content; +@override final DateTime? publishedAt; +@override final String feedId; +@override final DateTime createdAt; +@override final DateTime updatedAt; +@override final DateTime? deletedAt; + +/// Create a copy of WebArticle +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$WebArticleCopyWith<_WebArticle> get copyWith => __$WebArticleCopyWithImpl<_WebArticle>(this, _$identity); + +@override +Map toJson() { + return _$WebArticleToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WebArticle&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.url, url) || other.url == url)&&(identical(other.author, author) || other.author == author)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.preview, preview) || other.preview == preview)&&(identical(other.content, content) || other.content == content)&&(identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt)&&(identical(other.feedId, feedId) || other.feedId == feedId)&&(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,title,url,author,const DeepCollectionEquality().hash(_meta),preview,content,publishedAt,feedId,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'WebArticle(id: $id, title: $title, url: $url, author: $author, meta: $meta, preview: $preview, content: $content, publishedAt: $publishedAt, feedId: $feedId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$WebArticleCopyWith<$Res> implements $WebArticleCopyWith<$Res> { + factory _$WebArticleCopyWith(_WebArticle value, $Res Function(_WebArticle) _then) = __$WebArticleCopyWithImpl; +@override @useResult +$Res call({ + String id, String title, String url, String? author, Map? meta, SnScrappedLink? preview, String? content, DateTime? publishedAt, String feedId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + +@override $SnScrappedLinkCopyWith<$Res>? get preview; + +} +/// @nodoc +class __$WebArticleCopyWithImpl<$Res> + implements _$WebArticleCopyWith<$Res> { + __$WebArticleCopyWithImpl(this._self, this._then); + + final _WebArticle _self; + final $Res Function(_WebArticle) _then; + +/// Create a copy of WebArticle +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = null,Object? url = null,Object? author = freezed,Object? meta = freezed,Object? preview = freezed,Object? content = freezed,Object? publishedAt = freezed,Object? feedId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_WebArticle( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,author: freezed == author ? _self.author : author // ignore: cast_nullable_to_non_nullable +as String?,meta: freezed == meta ? _self._meta : meta // ignore: cast_nullable_to_non_nullable +as Map?,preview: freezed == preview ? _self.preview : preview // ignore: cast_nullable_to_non_nullable +as SnScrappedLink?,content: freezed == content ? _self.content : content // ignore: cast_nullable_to_non_nullable +as String?,publishedAt: freezed == publishedAt ? _self.publishedAt : publishedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,feedId: null == feedId ? _self.feedId : feedId // 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?, + )); +} + +/// Create a copy of WebArticle +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnScrappedLinkCopyWith<$Res>? get preview { + if (_self.preview == null) { + return null; + } + + return $SnScrappedLinkCopyWith<$Res>(_self.preview!, (value) { + return _then(_self.copyWith(preview: value)); + }); +} +} + +// dart format on diff --git a/lib/models/webfeed.g.dart b/lib/models/webfeed.g.dart new file mode 100644 index 0000000..a56ed42 --- /dev/null +++ b/lib/models/webfeed.g.dart @@ -0,0 +1,94 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'webfeed.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_WebFeedConfig _$WebFeedConfigFromJson(Map json) => + _WebFeedConfig(scrapPage: json['scrap_page'] as bool? ?? false); + +Map _$WebFeedConfigToJson(_WebFeedConfig instance) => + {'scrap_page': instance.scrapPage}; + +_WebFeed _$WebFeedFromJson(Map json) => _WebFeed( + id: json['id'] as String, + url: json['url'] as String, + title: json['title'] as String, + description: json['description'] as String?, + preview: + json['preview'] == null + ? null + : SnScrappedLink.fromJson(json['preview'] as Map), + config: + json['config'] == null + ? const WebFeedConfig() + : WebFeedConfig.fromJson(json['config'] as Map), + publisherId: json['publisher_id'] as String, + articles: + (json['articles'] as List?) + ?.map((e) => WebArticle.fromJson(e as Map)) + .toList() ?? + const [], + 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 _$WebFeedToJson(_WebFeed instance) => { + 'id': instance.id, + 'url': instance.url, + 'title': instance.title, + 'description': instance.description, + 'preview': instance.preview?.toJson(), + 'config': instance.config.toJson(), + 'publisher_id': instance.publisherId, + 'articles': instance.articles.map((e) => e.toJson()).toList(), + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt?.toIso8601String(), +}; + +_WebArticle _$WebArticleFromJson(Map json) => _WebArticle( + id: json['id'] as String, + title: json['title'] as String, + url: json['url'] as String, + author: json['author'] as String?, + meta: json['meta'] as Map?, + preview: + json['preview'] == null + ? null + : SnScrappedLink.fromJson(json['preview'] as Map), + content: json['content'] as String?, + publishedAt: + json['published_at'] == null + ? null + : DateTime.parse(json['published_at'] as String), + feedId: json['feed_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 _$WebArticleToJson(_WebArticle instance) => + { + 'id': instance.id, + 'title': instance.title, + 'url': instance.url, + 'author': instance.author, + 'meta': instance.meta, + 'preview': instance.preview?.toJson(), + 'content': instance.content, + 'published_at': instance.publishedAt?.toIso8601String(), + 'feed_id': instance.feedId, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt?.toIso8601String(), + }; diff --git a/lib/pods/webfeed.dart b/lib/pods/webfeed.dart new file mode 100644 index 0000000..8a970ed --- /dev/null +++ b/lib/pods/webfeed.dart @@ -0,0 +1,114 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:island/models/webfeed.dart'; +import 'package:island/pods/network.dart'; + +final webFeedListProvider = FutureProvider.family, String>(( + ref, + pubName, +) async { + final client = ref.watch(apiClientProvider); + final response = await client.get('/publishers/$pubName/feeds'); + return (response.data as List).map((json) => WebFeed.fromJson(json)).toList(); +}); + +class WebFeedNotifier + extends + AutoDisposeFamilyAsyncNotifier< + WebFeed, + ({String pubName, String? feedId}) + > { + @override + FutureOr build(({String pubName, String? feedId}) arg) async { + if (arg.feedId == null || arg.feedId!.isEmpty) { + return WebFeed( + id: '', + url: '', + title: '', + publisherId: arg.pubName, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + deletedAt: null, + ); + } + + try { + final client = ref.read(apiClientProvider); + final response = await client.get( + '/publishers/${arg.pubName}/feeds/${arg.feedId}', + ); + return WebFeed.fromJson(response.data); + } catch (e) { + rethrow; + } + } + + Future saveFeed(WebFeed feed) async { + state = const AsyncValue.loading(); + try { + final client = ref.read(apiClientProvider); + final url = '/publishers/${feed.publisherId}/feeds'; + + final response = + feed.id.isEmpty + ? await client.post(url, data: feed.toJson()) + : await client.patch('$url/${feed.id}', data: feed.toJson()); + + state = AsyncValue.data(WebFeed.fromJson(response.data)); + } catch (error, stackTrace) { + state = AsyncValue.error(error, stackTrace); + rethrow; + } + } + + Future deleteFeed() async { + final feedId = arg.feedId; + if (feedId == null || feedId.isEmpty) return; + + state = const AsyncValue.loading(); + try { + final client = ref.read(apiClientProvider); + await client.delete('/publishers/${arg.pubName}/feeds/$feedId'); + state = AsyncValue.data( + WebFeed( + id: '', + url: '', + title: '', + publisherId: arg.pubName, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + deletedAt: null, + ), + ); + } catch (error, stackTrace) { + state = AsyncValue.error(error, stackTrace); + rethrow; + } + } + + Future scrapFeed() async { + final feedId = arg.feedId; + if (feedId == null || feedId.isEmpty) return; + + state = const AsyncValue.loading(); + try { + final client = ref.read(apiClientProvider); + await client.post('/publishers/${arg.pubName}/feeds/$feedId/scrap'); + + // Reload the feed + final response = await client.get( + '/publishers/${arg.pubName}/feeds/$feedId', + ); + state = AsyncValue.data(WebFeed.fromJson(response.data)); + } catch (error, stackTrace) { + state = AsyncValue.error(error, stackTrace); + rethrow; + } + } +} + +final webFeedNotifierProvider = AsyncNotifierProvider.autoDispose + .family( + WebFeedNotifier.new, + ); diff --git a/lib/route.dart b/lib/route.dart index b3f2e71..305a579 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -22,10 +22,12 @@ import 'package:island/screens/chat/room.dart'; import 'package:island/screens/chat/room_detail.dart'; import 'package:island/screens/chat/call.dart'; import 'package:island/screens/creators/hub.dart'; -import 'package:island/screens/creators/posts/list.dart'; +import 'package:island/screens/creators/posts/post_manage_list.dart'; import 'package:island/screens/creators/stickers/stickers.dart'; import 'package:island/screens/creators/stickers/pack_detail.dart'; import 'package:island/screens/creators/publishers.dart'; +import 'package:island/screens/creators/webfeed/webfeed_list.dart'; +import 'package:island/screens/creators/webfeed/webfeed_edit.dart'; import 'package:island/screens/posts/compose.dart'; import 'package:island/screens/posts/detail.dart'; import 'package:island/screens/posts/pub_profile.dart'; @@ -91,6 +93,33 @@ final routerProvider = Provider((ref) { path: '/creators', builder: (context, state) => const CreatorHubScreen(), ), + // Web Feed Routes + GoRoute( + path: '/creators/:name/feeds', + builder: (context, state) { + final name = state.pathParameters['name']!; + return WebFeedListScreen(pubName: name); + }, + routes: [ + GoRoute( + path: 'new', + builder: (context, state) { + return WebFeedNewScreen( + pubName: state.pathParameters['name']!, + ); + }, + ), + GoRoute( + path: ':feedId', + builder: (context, state) { + return WebFeedEditScreen( + pubName: state.pathParameters['name']!, + feedId: state.pathParameters['feedId'], + ); + }, + ), + ], + ), GoRoute( path: '/creators/:name/posts', builder: (context, state) { @@ -167,22 +196,25 @@ final routerProvider = Provider((ref) { ), GoRoute( path: '/developers/:name/apps', - builder: (context, state) => CustomAppsScreen( - publisherName: state.pathParameters['name']!, - ), + builder: + (context, state) => CustomAppsScreen( + publisherName: state.pathParameters['name']!, + ), ), GoRoute( path: '/developers/:name/apps/new', - builder: (context, state) => NewCustomAppScreen( - publisherName: state.pathParameters['name']!, - ), + builder: + (context, state) => NewCustomAppScreen( + publisherName: state.pathParameters['name']!, + ), ), GoRoute( path: '/developers/:name/apps/:id', - builder: (context, state) => EditAppScreen( - publisherName: state.pathParameters['name']!, - id: state.pathParameters['id']!, - ), + builder: + (context, state) => EditAppScreen( + publisherName: state.pathParameters['name']!, + id: state.pathParameters['id']!, + ), ), ], ), diff --git a/lib/screens/account/me/update.dart b/lib/screens/account/me/update.dart index 91a2091..3d03fa7 100644 --- a/lib/screens/account/me/update.dart +++ b/lib/screens/account/me/update.dart @@ -341,7 +341,10 @@ class UpdateProfileScreen extends HookConsumerWidget { ), TextFormField( - decoration: InputDecoration(labelText: 'bio'.tr()), + decoration: InputDecoration( + labelText: 'bio'.tr(), + alignLabelWithHint: true, + ), maxLines: null, minLines: 3, controller: bioController, diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart index 2c8e7df..b341778 100644 --- a/lib/screens/chat/chat.dart +++ b/lib/screens/chat/chat.dart @@ -669,7 +669,10 @@ class EditChatScreen extends HookConsumerWidget { const SizedBox(height: 16), TextFormField( controller: descriptionController, - decoration: const InputDecoration(labelText: 'Description'), + decoration: const InputDecoration( + labelText: 'Description', + alignLabelWithHint: true, + ), minLines: 3, maxLines: null, onTapOutside: diff --git a/lib/screens/creators/hub.dart b/lib/screens/creators/hub.dart index 5712da9..598f9c5 100644 --- a/lib/screens/creators/hub.dart +++ b/lib/screens/creators/hub.dart @@ -370,9 +370,9 @@ class CreatorHubScreen extends HookConsumerWidget { ListTile( minTileHeight: 48, title: Text('publisherMembers').tr(), - trailing: Icon(Symbols.chevron_right), + trailing: const Icon(Symbols.chevron_right), leading: const Icon(Symbols.group), - contentPadding: EdgeInsets.symmetric( + contentPadding: const EdgeInsets.symmetric( horizontal: 24, ), onTap: () { @@ -387,6 +387,20 @@ class CreatorHubScreen extends HookConsumerWidget { ); }, ), + ListTile( + minTileHeight: 48, + title: const Text('Web Feeds').tr(), + trailing: const Icon(Symbols.chevron_right), + leading: const Icon(Symbols.rss_feed), + contentPadding: const EdgeInsets.symmetric( + horizontal: 24, + ), + onTap: () { + context.push( + '/creators/${currentPublisher.value!.name}/feeds', + ); + }, + ), ExpansionTile( title: Text('publisherFeatures').tr(), leading: const Icon(Symbols.flag), diff --git a/lib/screens/creators/posts/list.dart b/lib/screens/creators/posts/post_manage_list.dart similarity index 100% rename from lib/screens/creators/posts/list.dart rename to lib/screens/creators/posts/post_manage_list.dart diff --git a/lib/screens/creators/publishers.dart b/lib/screens/creators/publishers.dart index fd994cf..90fb1f8 100644 --- a/lib/screens/creators/publishers.dart +++ b/lib/screens/creators/publishers.dart @@ -270,7 +270,10 @@ class EditPublisherScreen extends HookConsumerWidget { ), TextFormField( controller: bioController, - decoration: InputDecoration(labelText: 'bio'.tr()), + decoration: InputDecoration( + labelText: 'bio'.tr(), + alignLabelWithHint: true, + ), minLines: 3, maxLines: null, onTapOutside: diff --git a/lib/screens/creators/stickers/stickers.dart b/lib/screens/creators/stickers/stickers.dart index 21ac345..f7fa71f 100644 --- a/lib/screens/creators/stickers/stickers.dart +++ b/lib/screens/creators/stickers/stickers.dart @@ -71,9 +71,7 @@ class SliverStickerPacksList extends HookConsumerWidget { subtitle: Text(sticker.description), trailing: const Icon(Symbols.chevron_right), onTap: () { - context.push( - '/creators/$pubName/stickers/${sticker.id}', - ); + context.push('/creators/$pubName/stickers/${sticker.id}'); }, ); }, @@ -230,6 +228,7 @@ class EditStickerPacksScreen extends HookConsumerWidget { decoration: InputDecoration( labelText: 'description'.tr(), border: const UnderlineInputBorder(), + alignLabelWithHint: true, ), minLines: 3, maxLines: null, diff --git a/lib/screens/creators/webfeed/webfeed_edit.dart b/lib/screens/creators/webfeed/webfeed_edit.dart new file mode 100644 index 0000000..3b4071b --- /dev/null +++ b/lib/screens/creators/webfeed/webfeed_edit.dart @@ -0,0 +1,287 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +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:material_symbols_icons/symbols.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class WebFeedNewScreen extends StatelessWidget { + final String pubName; + const WebFeedNewScreen({super.key, required this.pubName}); + + @override + Widget build(BuildContext context) { + return WebFeedEditScreen(pubName: pubName, feedId: null); + } +} + +class WebFeedEditScreen extends HookConsumerWidget { + final String pubName; + final String? feedId; + + const WebFeedEditScreen({super.key, required this.pubName, this.feedId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formKey = useMemoized(() => GlobalKey()); + final titleController = useTextEditingController(); + final urlController = useTextEditingController(); + final descriptionController = useTextEditingController(); + final isLoading = useState(false); + final isScrapEnabled = useState(false); + + final saveFeed = useCallback(() async { + if (!formKey.currentState!.validate()) return; + + isLoading.value = true; + + try { + final feed = WebFeed( + id: feedId ?? '', + title: titleController.text, + url: urlController.text, + description: descriptionController.text, + config: WebFeedConfig(scrapPage: isScrapEnabled.value), + publisherId: pubName, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + deletedAt: null, + ); + + await ref + .read( + webFeedNotifierProvider(( + pubName: pubName, + feedId: feedId, + )).notifier, + ) + .saveFeed(feed); + + // Refresh the feed list + ref.invalidate(webFeedListProvider(pubName)); + + if (context.mounted) { + showSnackBar('Web feed saved successfully'); + context.pop(); + } + } catch (e) { + showErrorAlert(e); + } finally { + isLoading.value = false; + } + }, [pubName, feedId, isScrapEnabled.value, context]); + + final deleteFeed = useCallback(() async { + final confirmed = await showConfirmAlert( + 'Are you sure you want to delete this web feed? This action cannot be undone.', + 'Delete Web Feed', + ); + if (confirmed != true) return; + + isLoading.value = true; + + try { + await ref + .read( + webFeedNotifierProvider(( + pubName: pubName, + feedId: feedId!, + )).notifier, + ) + .deleteFeed(); + + ref.invalidate(webFeedListProvider(pubName)); + + if (context.mounted) { + showSnackBar('Web feed deleted successfully'); + context.pop(); + } + } catch (e) { + showErrorAlert(e); + } finally { + isLoading.value = false; + } + }, [pubName, feedId, context, ref]); + + final feedAsync = ref.watch( + webFeedNotifierProvider((pubName: pubName, feedId: feedId)), + ); + + return feedAsync.when( + loading: + () => + const Scaffold(body: Center(child: CircularProgressIndicator())), + error: + (error, stack) => Scaffold( + appBar: AppBar(title: const Text('Error')), + body: Center(child: Text('Error: $error')), + ), + data: (feed) { + // Initialize form fields if they're empty and we have a feed + if (titleController.text.isEmpty) { + titleController.text = feed.title; + urlController.text = feed.url; + descriptionController.text = feed.description ?? ''; + isScrapEnabled.value = feed.config.scrapPage; + } + + return _buildForm( + context, + formKey: formKey, + titleController: titleController, + urlController: urlController, + descriptionController: descriptionController, + isScrapEnabled: isScrapEnabled.value, + onScrapEnabledChanged: (value) => isScrapEnabled.value = value, + onSave: saveFeed, + onDelete: deleteFeed, + isLoading: isLoading.value, + ref: ref, + hasFeedId: feedId != null, + ); + }, + ); + } + + Widget _buildForm( + BuildContext context, { + required WidgetRef ref, + required GlobalKey formKey, + required TextEditingController titleController, + required TextEditingController urlController, + required TextEditingController descriptionController, + required bool isScrapEnabled, + required ValueChanged onScrapEnabledChanged, + required VoidCallback onSave, + required VoidCallback onDelete, + required bool isLoading, + required bool hasFeedId, + }) { + final scrapNow = useCallback(() async { + showLoadingModal(context); + try { + await ref + .read( + webFeedNotifierProvider(( + pubName: pubName, + feedId: feedId!, + )).notifier, + ) + .scrapFeed(); + + if (context.mounted) { + showSnackBar('Feed scraping successfully.'); + } + } catch (e) { + showErrorAlert(e); + } finally { + if (context.mounted) hideLoadingModal(context); + } + }, [pubName, feedId, ref, context]); + + return Scaffold( + appBar: AppBar( + title: Text(hasFeedId ? 'Edit Web Feed' : 'New Web Feed'), + actions: [ + if (hasFeedId) + IconButton( + icon: const Icon(Symbols.delete_forever), + onPressed: isLoading ? null : onDelete, + ), + const SizedBox(width: 8), + ], + ), + body: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + children: [ + TextFormField( + controller: titleController, + decoration: const InputDecoration(labelText: 'Title'), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a title'; + } + return null; + }, + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + const SizedBox(height: 16), + TextFormField( + controller: urlController, + decoration: const InputDecoration( + labelText: 'URL', + hintText: 'https://example.com/feed', + ), + keyboardType: TextInputType.url, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a URL'; + } + final uri = Uri.tryParse(value); + if (uri == null || !uri.hasAbsolutePath) { + return 'Please enter a valid URL'; + } + return null; + }, + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + const SizedBox(height: 16), + TextFormField( + controller: descriptionController, + decoration: const InputDecoration( + labelText: 'Description', + alignLabelWithHint: true, + ), + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), + maxLines: 3, + ), + const SizedBox(height: 24), + Card( + margin: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + title: const Text('Scrape web page for content'), + subtitle: const Text( + 'When enabled, the system will attempt to extract full content from the web page', + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + value: isScrapEnabled, + onChanged: onScrapEnabledChanged, + ), + ], + ), + ), + const SizedBox(height: 20), + if (hasFeedId) ...[ + FilledButton.tonalIcon( + onPressed: isLoading ? null : scrapNow, + icon: const Icon(Symbols.refresh), + label: const Text('Scrape Now'), + ).alignment(Alignment.centerRight), + const SizedBox(height: 16), + ], + FilledButton.icon( + onPressed: isLoading ? null : onSave, + icon: const Icon(Symbols.save), + label: Text('saveChanges').tr(), + ).alignment(Alignment.centerRight), + ], + ).padding(all: 20), + ), + ), + ); + } +} diff --git a/lib/screens/creators/webfeed/webfeed_list.dart b/lib/screens/creators/webfeed/webfeed_list.dart new file mode 100644 index 0000000..c09367d --- /dev/null +++ b/lib/screens/creators/webfeed/webfeed_list.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:island/pods/webfeed.dart'; +import 'package:island/widgets/app_scaffold.dart'; +import 'package:island/widgets/empty_state.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class WebFeedListScreen extends ConsumerWidget { + final String pubName; + + const WebFeedListScreen({super.key, required this.pubName}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final feedsAsync = ref.watch(webFeedListProvider(pubName)); + + return AppScaffold( + appBar: AppBar(title: const Text('Web Feeds')), + floatingActionButton: FloatingActionButton( + child: const Icon(Symbols.add), + onPressed: () { + context.push('/creators/$pubName/feeds/new'); + }, + ), + body: feedsAsync.when( + data: (feeds) { + if (feeds.isEmpty) { + return EmptyState( + icon: Symbols.rss_feed, + title: 'No Web Feeds', + description: 'Add a new web feed to get started', + ); + } + return RefreshIndicator( + onRefresh: () => ref.refresh(webFeedListProvider(pubName).future), + child: ListView.builder( + padding: EdgeInsets.only(top: 8), + itemCount: feeds.length, + itemBuilder: (context, index) { + final feed = feeds[index]; + return Card( + margin: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + child: ListTile( + leading: const Icon(Symbols.rss_feed, size: 32), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + title: Text( + feed.title, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + feed.url, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: const Icon(Symbols.chevron_right), + onTap: () { + context.push('/creators/$pubName/feeds/${feed.id}'); + }, + ), + ); + }, + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('Error: $error')), + ), + ); + } +} diff --git a/lib/screens/developers/edit_app.dart b/lib/screens/developers/edit_app.dart index 200dce9..1c5111a 100644 --- a/lib/screens/developers/edit_app.dart +++ b/lib/screens/developers/edit_app.dart @@ -378,6 +378,7 @@ class EditAppScreen extends HookConsumerWidget { controller: descriptionController, decoration: InputDecoration( labelText: 'description'.tr(), + alignLabelWithHint: true, ), maxLines: 3, onTapOutside: diff --git a/lib/screens/realm/realms.dart b/lib/screens/realm/realms.dart index 2556586..7d01696 100644 --- a/lib/screens/realm/realms.dart +++ b/lib/screens/realm/realms.dart @@ -344,7 +344,10 @@ class EditRealmScreen extends HookConsumerWidget { const SizedBox(height: 16), TextFormField( controller: descriptionController, - decoration: InputDecoration(labelText: 'description'.tr()), + decoration: InputDecoration( + labelText: 'description'.tr(), + alignLabelWithHint: true, + ), minLines: 3, maxLines: null, onTapOutside: diff --git a/lib/widgets/empty_state.dart b/lib/widgets/empty_state.dart new file mode 100644 index 0000000..7075342 --- /dev/null +++ b/lib/widgets/empty_state.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +class EmptyState extends StatelessWidget { + final IconData icon; + final String title; + final String description; + final Widget? action; + + const EmptyState({ + super.key, + required this.icon, + required this.title, + required this.description, + this.action, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 64, + color: Theme.of(context).colorScheme.outline, + ), + const SizedBox(height: 16), + Text( + title, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + description, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + if (action != null) ...[ + const SizedBox(height: 24), + action!, + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index fc31844..c18e6bc 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -542,14 +542,14 @@ Widget _buildReferencePost(BuildContext context, SnPost item) { class PostReactionList extends HookConsumerWidget { final String parentId; final Map reactions; - final Function(String symbol, int attitude, int delta) onReact; + final Function(String symbol, int attitude, int delta)? onReact; final EdgeInsets? padding; const PostReactionList({ super.key, required this.parentId, required this.reactions, this.padding, - required this.onReact, + this.onReact, }); @override @@ -570,7 +570,7 @@ class PostReactionList extends HookConsumerWidget { }) .then((resp) { var isRemoving = resp.statusCode == 204; - onReact(symbol, attitude, isRemoving ? -1 : 1); + onReact?.call(symbol, attitude, isRemoving ? -1 : 1); HapticFeedback.heavyImpact(); }); submitting.value = false; @@ -582,33 +582,34 @@ class PostReactionList extends HookConsumerWidget { scrollDirection: Axis.horizontal, padding: padding ?? EdgeInsets.zero, children: [ - Padding( - padding: const EdgeInsets.only(right: 8), - child: ActionChip( - avatar: Icon(Symbols.add_reaction), - label: Text('react').tr(), - visualDensity: const VisualDensity( - horizontal: VisualDensity.minimumDensity, - vertical: VisualDensity.minimumDensity, + if (onReact != null) + Padding( + padding: const EdgeInsets.only(right: 8), + child: ActionChip( + avatar: Icon(Symbols.add_reaction), + label: Text('react').tr(), + visualDensity: const VisualDensity( + horizontal: VisualDensity.minimumDensity, + vertical: VisualDensity.minimumDensity, + ), + onPressed: + submitting.value + ? null + : () { + showModalBottomSheet( + context: context, + builder: (BuildContext context) { + return _PostReactionSheet( + reactionsCount: reactions, + onReact: (symbol, attitude) { + reactPost(symbol, attitude); + }, + ); + }, + ); + }, ), - onPressed: - submitting.value - ? null - : () { - showModalBottomSheet( - context: context, - builder: (BuildContext context) { - return _PostReactionSheet( - reactionsCount: reactions, - onReact: (symbol, attitude) { - reactPost(symbol, attitude); - }, - ); - }, - ); - }, ), - ), for (final symbol in reactions.keys) Padding( padding: const EdgeInsets.only(right: 8), diff --git a/lib/widgets/post/post_item_creator.dart b/lib/widgets/post/post_item_creator.dart index 13a02f4..d8153e9 100644 --- a/lib/widgets/post/post_item_creator.dart +++ b/lib/widgets/post/post_item_creator.dart @@ -365,11 +365,6 @@ class PostItemCreator extends HookConsumerWidget { parentId: item.id, reactions: item.reactionsCount, padding: EdgeInsets.zero, - onReact: (symbol, attitude, delta) { - final reactionsCount = Map.from(item.reactionsCount); - reactionsCount[symbol] = (reactionsCount[symbol] ?? 0) + delta; - onUpdate?.call(item.copyWith(reactionsCount: reactionsCount)); - }, ), const Gap(16), ],