diff --git a/melos.yaml b/melos.yaml index b79c3c38..a0b0ff35 100644 --- a/melos.yaml +++ b/melos.yaml @@ -24,10 +24,14 @@ command: dio: ^5.9.0 equatable: ^2.0.5 flutter_state_notifier: ^1.0.0 + flutter_svg: ^2.2.0 freezed_annotation: ^3.0.0 get_it: ^8.0.3 + google_fonts: ^6.3.0 + injectable: ^2.5.1 http: ^1.1.0 intl: ">=0.18.1 <=0.21.0" + jiffy: ^6.4.3 json_annotation: ^4.9.0 meta: ^1.9.1 retrofit: ^4.6.0 @@ -36,6 +40,7 @@ command: state_notifier: ^1.0.0 uuid: ^4.5.1 + # TODO Replace with hosted version when published stream_core: git: url: https://github.com/GetStream/stream-core-flutter.git @@ -47,6 +52,7 @@ command: auto_route_generator: ^10.0.0 build_runner: ^2.4.15 freezed: ^3.0.0 + injectable_generator: ^2.7.0 json_serializable: ^6.9.5 mocktail: ^1.0.4 retrofit_generator: ^9.6.0 diff --git a/packages/stream_feeds/lib/src/models/request/activity_update_comment_request.dart b/packages/stream_feeds/lib/src/models/request/activity_update_comment_request.dart new file mode 100644 index 00000000..36a2e3ba --- /dev/null +++ b/packages/stream_feeds/lib/src/models/request/activity_update_comment_request.dart @@ -0,0 +1,32 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../stream_feeds.dart' as api; + +part 'activity_update_comment_request.freezed.dart'; + +/// A request for updating a comment to an activity. +@freezed +class ActivityUpdateCommentRequest with _$ActivityUpdateCommentRequest { + const ActivityUpdateCommentRequest({ + this.comment, + this.custom, + this.skipPush, + }); + + @override + final String? comment; + + @override + final Map? custom; + + @override + final bool? skipPush; +} + +extension ActivityUpdateCommentRequestMapper on ActivityUpdateCommentRequest { + api.UpdateCommentRequest toRequest() => api.UpdateCommentRequest( + comment: comment, + custom: custom, + skipPush: skipPush, + ); +} diff --git a/packages/stream_feeds/lib/src/models/request/activity_update_comment_request.freezed.dart b/packages/stream_feeds/lib/src/models/request/activity_update_comment_request.freezed.dart new file mode 100644 index 00000000..9a1adca9 --- /dev/null +++ b/packages/stream_feeds/lib/src/models/request/activity_update_comment_request.freezed.dart @@ -0,0 +1,96 @@ +// 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 'activity_update_comment_request.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$ActivityUpdateCommentRequest { + String? get comment; + Map? get custom; + bool? get skipPush; + + /// Create a copy of ActivityUpdateCommentRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $ActivityUpdateCommentRequestCopyWith + get copyWith => _$ActivityUpdateCommentRequestCopyWithImpl< + ActivityUpdateCommentRequest>( + this as ActivityUpdateCommentRequest, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is ActivityUpdateCommentRequest && + (identical(other.comment, comment) || other.comment == comment) && + const DeepCollectionEquality().equals(other.custom, custom) && + (identical(other.skipPush, skipPush) || + other.skipPush == skipPush)); + } + + @override + int get hashCode => Object.hash(runtimeType, comment, + const DeepCollectionEquality().hash(custom), skipPush); + + @override + String toString() { + return 'ActivityUpdateCommentRequest(comment: $comment, custom: $custom, skipPush: $skipPush)'; + } +} + +/// @nodoc +abstract mixin class $ActivityUpdateCommentRequestCopyWith<$Res> { + factory $ActivityUpdateCommentRequestCopyWith( + ActivityUpdateCommentRequest value, + $Res Function(ActivityUpdateCommentRequest) _then) = + _$ActivityUpdateCommentRequestCopyWithImpl; + @useResult + $Res call({String? comment, Map? custom, bool? skipPush}); +} + +/// @nodoc +class _$ActivityUpdateCommentRequestCopyWithImpl<$Res> + implements $ActivityUpdateCommentRequestCopyWith<$Res> { + _$ActivityUpdateCommentRequestCopyWithImpl(this._self, this._then); + + final ActivityUpdateCommentRequest _self; + final $Res Function(ActivityUpdateCommentRequest) _then; + + /// Create a copy of ActivityUpdateCommentRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? comment = freezed, + Object? custom = freezed, + Object? skipPush = freezed, + }) { + return _then(ActivityUpdateCommentRequest( + comment: freezed == comment + ? _self.comment + : comment // ignore: cast_nullable_to_non_nullable + as String?, + custom: freezed == custom + ? _self.custom + : custom // ignore: cast_nullable_to_non_nullable + as Map?, + skipPush: freezed == skipPush + ? _self.skipPush + : skipPush // ignore: cast_nullable_to_non_nullable + as bool?, + )); + } +} + +// dart format on diff --git a/packages/stream_feeds/lib/src/models/threaded_comment_data.dart b/packages/stream_feeds/lib/src/models/threaded_comment_data.dart index afce8d87..68224d30 100644 --- a/packages/stream_feeds/lib/src/models/threaded_comment_data.dart +++ b/packages/stream_feeds/lib/src/models/threaded_comment_data.dart @@ -298,7 +298,8 @@ extension ThreadedCommentDataMutations on ThreadedCommentData { ThreadedCommentData reply, Comparator comparator, ) { - final updatedReplies = replies?.sortedUpsert( + final currentReplies = replies ?? []; + final updatedReplies = currentReplies.sortedUpsert( reply, key: (it) => it.id, compare: comparator, @@ -315,7 +316,10 @@ extension ThreadedCommentDataMutations on ThreadedCommentData { /// @param comment The reply comment to remove. /// @return A new [ThreadedCommentData] instance with the updated replies and reply count. ThreadedCommentData removeReply(ThreadedCommentData reply) { - final updatedReplies = replies?.where((it) => it.id != reply.id).toList(); + final currentReplies = replies ?? []; + final updatedReplies = currentReplies.where((it) { + return it.id != reply.id; + }).toList(); return copyWith( replies: updatedReplies, @@ -331,7 +335,8 @@ extension ThreadedCommentDataMutations on ThreadedCommentData { ThreadedCommentData reply, Comparator comparator, ) { - final updatedReplies = replies?.sortedUpsert( + final currentReplies = replies ?? []; + final updatedReplies = currentReplies.sortedUpsert( reply, key: (it) => it.id, compare: comparator, diff --git a/packages/stream_feeds/lib/src/state/activity.dart b/packages/stream_feeds/lib/src/state/activity.dart index 5046fcfd..5eda75b8 100644 --- a/packages/stream_feeds/lib/src/state/activity.dart +++ b/packages/stream_feeds/lib/src/state/activity.dart @@ -13,6 +13,7 @@ import '../models/poll_data.dart'; import '../models/poll_option_data.dart'; import '../models/poll_vote_data.dart'; import '../models/request/activity_add_comment_request.dart'; +import '../models/request/activity_update_comment_request.dart'; import '../models/threaded_comment_data.dart'; import '../repository/activities_repository.dart'; import '../repository/comments_repository.dart'; @@ -196,9 +197,10 @@ class Activity with Disposable { /// Returns a [Result] containing the updated [CommentData] or an error. Future> updateComment( String commentId, - api.UpdateCommentRequest request, + ActivityUpdateCommentRequest request, ) async { - final result = await commentsRepository.updateComment(commentId, request); + final result = + await commentsRepository.updateComment(commentId, request.toRequest()); result.onSuccess(_commentsList.notifier.onCommentUpdated); diff --git a/packages/stream_feeds/lib/src/state/activity_state.dart b/packages/stream_feeds/lib/src/state/activity_state.dart index 815644b4..dad2e395 100644 --- a/packages/stream_feeds/lib/src/state/activity_state.dart +++ b/packages/stream_feeds/lib/src/state/activity_state.dart @@ -3,6 +3,7 @@ import 'package:state_notifier/state_notifier.dart'; import 'package:stream_core/stream_core.dart'; import '../models/activity_data.dart'; +import '../models/pagination_data.dart'; import '../models/poll_data.dart'; import '../models/poll_vote_data.dart'; import '../models/threaded_comment_data.dart'; @@ -31,7 +32,10 @@ class ActivityStateNotifier extends StateNotifier { void _setupCommentListSynchronization() { _removeCommentListListener = commentList.addListener((commentListState) { // Synchronize state with the comment list state - state = state.copyWith(comments: commentListState.comments); + state = state.copyWith( + comments: commentListState.comments, + commentsPagination: commentListState.pagination, + ); }); } @@ -174,6 +178,7 @@ class ActivityState with _$ActivityState { const ActivityState({ this.activity, this.comments = const [], + this.commentsPagination, this.poll, }); @@ -190,6 +195,15 @@ class ActivityState with _$ActivityState { @override final List comments; + /// Pagination information for [comments]. + @override + final PaginationData? commentsPagination; + + /// Indicates whether there are more [comments] available to load. + /// + /// Returns true if there is a next page available for pagination. + bool get canLoadMoreComments => commentsPagination?.next != null; + /// The poll associated with this activity, if any. /// /// Contains poll information including options, votes, and poll state. diff --git a/packages/stream_feeds/lib/src/state/activity_state.freezed.dart b/packages/stream_feeds/lib/src/state/activity_state.freezed.dart index 91704003..dc6f374e 100644 --- a/packages/stream_feeds/lib/src/state/activity_state.freezed.dart +++ b/packages/stream_feeds/lib/src/state/activity_state.freezed.dart @@ -17,6 +17,7 @@ T _$identity(T value) => value; mixin _$ActivityState { ActivityData? get activity; List get comments; + PaginationData? get commentsPagination; PollData? get poll; /// Create a copy of ActivityState @@ -35,16 +36,18 @@ mixin _$ActivityState { (identical(other.activity, activity) || other.activity == activity) && const DeepCollectionEquality().equals(other.comments, comments) && + (identical(other.commentsPagination, commentsPagination) || + other.commentsPagination == commentsPagination) && (identical(other.poll, poll) || other.poll == poll)); } @override int get hashCode => Object.hash(runtimeType, activity, - const DeepCollectionEquality().hash(comments), poll); + const DeepCollectionEquality().hash(comments), commentsPagination, poll); @override String toString() { - return 'ActivityState(activity: $activity, comments: $comments, poll: $poll)'; + return 'ActivityState(activity: $activity, comments: $comments, commentsPagination: $commentsPagination, poll: $poll)'; } } @@ -57,6 +60,7 @@ abstract mixin class $ActivityStateCopyWith<$Res> { $Res call( {ActivityData? activity, List comments, + PaginationData? commentsPagination, PollData? poll}); } @@ -75,6 +79,7 @@ class _$ActivityStateCopyWithImpl<$Res> $Res call({ Object? activity = freezed, Object? comments = null, + Object? commentsPagination = freezed, Object? poll = freezed, }) { return _then(ActivityState( @@ -86,6 +91,10 @@ class _$ActivityStateCopyWithImpl<$Res> ? _self.comments : comments // ignore: cast_nullable_to_non_nullable as List, + commentsPagination: freezed == commentsPagination + ? _self.commentsPagination + : commentsPagination // ignore: cast_nullable_to_non_nullable + as PaginationData?, poll: freezed == poll ? _self.poll : poll // ignore: cast_nullable_to_non_nullable diff --git a/packages/stream_feeds/lib/src/state/bookmark_folder_list.dart b/packages/stream_feeds/lib/src/state/bookmark_folder_list.dart index a0ed472a..148bad00 100644 --- a/packages/stream_feeds/lib/src/state/bookmark_folder_list.dart +++ b/packages/stream_feeds/lib/src/state/bookmark_folder_list.dart @@ -52,7 +52,8 @@ class BookmarkFolderList extends Disposable { /// Queries the initial list of bookmark folders based on the provided [BookmarkFoldersQuery]. /// /// Returns a [Result] containing a list of [BookmarkFolderData] or an error. - Future>> get() => _queryBookmarkFolders(query); + Future>> get() => + _queryBookmarkFolders(query); /// Loads more bookmark folders based on the current pagination state. /// diff --git a/packages/stream_feeds/lib/src/state/feed.dart b/packages/stream_feeds/lib/src/state/feed.dart index b958f676..1ccb76db 100644 --- a/packages/stream_feeds/lib/src/state/feed.dart +++ b/packages/stream_feeds/lib/src/state/feed.dart @@ -66,6 +66,7 @@ class Feed with Disposable { _eventsSubscription = eventsEmitter.listen(handler.handleEvent); } + FeedId get fid => query.fid; final FeedQuery query; final String currentUserId; diff --git a/packages/stream_feeds/lib/stream_feeds.dart b/packages/stream_feeds/lib/stream_feeds.dart index 12228e2b..73188587 100644 --- a/packages/stream_feeds/lib/stream_feeds.dart +++ b/packages/stream_feeds/lib/stream_feeds.dart @@ -7,6 +7,13 @@ export 'src/models/feed_id.dart'; export 'src/models/feed_input_data.dart'; export 'src/models/feed_member_request_data.dart'; export 'src/models/poll_data.dart'; +export 'src/models/request/activity_add_comment_request.dart' + show ActivityAddCommentRequest; +export 'src/models/request/activity_update_comment_request.dart' + show ActivityUpdateCommentRequest; +export 'src/models/threaded_comment_data.dart'; export 'src/models/user_data.dart'; +export 'src/state/activity.dart'; export 'src/state/feed.dart'; +export 'src/state/feed_state.dart'; export 'src/state/query/feed_query.dart'; diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index 5a35ccaa..9fe18088 100644 --- a/packages/stream_feeds/pubspec.yaml +++ b/packages/stream_feeds/pubspec.yaml @@ -33,7 +33,6 @@ dependencies: retrofit: ^4.6.0 rxdart: ^0.28.0 state_notifier: ^1.0.0 - # TODO Replace with hosted version when published stream_core: git: url: https://github.com/GetStream/stream-core-flutter.git diff --git a/sample_app/assets/images/app_logo.svg b/sample_app/assets/images/app_logo.svg new file mode 100644 index 00000000..1589ee87 --- /dev/null +++ b/sample_app/assets/images/app_logo.svg @@ -0,0 +1,5 @@ + + + diff --git a/sample_app/lib/app/app.dart b/sample_app/lib/app/app.dart new file mode 100644 index 00000000..2d73e422 --- /dev/null +++ b/sample_app/lib/app/app.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +import '../widgets/app_failure.dart'; +import '../widgets/app_splash.dart'; +import 'app_state.dart'; +import 'content/app_content.dart'; + +class StreamFeedsSampleApp extends StatefulWidget { + const StreamFeedsSampleApp({super.key}); + + @override + State createState() => _StreamFeedsSampleAppState(); +} + +class _StreamFeedsSampleAppState extends State { + late final _appStateNotifier = AppStateNotifier(); + + @override + void initState() { + super.initState(); + _appStateNotifier.init(); + } + + @override + void dispose() { + _appStateNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: _appStateNotifier, + builder: (context, state, child) { + // If there was an error initializing the app, show failure widget. + if (state is AppStateFailed) { + return Directionality( + textDirection: TextDirection.ltr, + child: AppFailure( + error: state.error, + stackTrace: state.stackTrace, + ), + ); + } + + // If the app is initialized, show the app content. + if (state is AppStateInitialized) { + final credentials = state.credentials; + return StreamFeedsSampleAppContent(credentials: credentials); + } + + // Otherwise, show splash whilst waiting for initialization + return const Directionality( + textDirection: TextDirection.ltr, + child: AppSplash(), + ); + }, + ); + } +} diff --git a/sample_app/lib/app/app_state.dart b/sample_app/lib/app/app_state.dart new file mode 100644 index 00000000..c0ee4598 --- /dev/null +++ b/sample_app/lib/app/app_state.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:injectable/injectable.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../core/di/di_initializer.dart'; +import '../core/models/user_credentials.dart'; +import '../services/app_preferences.dart'; + +@singleton +class AppStateNotifier extends ValueNotifier { + AppStateNotifier() : super(const AppStateLoading()); + + Future init([FutureOr Function()? fn]) async { + final result = await runSafely(() async { + // Initialize the dependency injection system + await initDI(); + + await Future.delayed(const Duration(seconds: 3)); + + // Ensure all fonts are loaded before rendering the app + await GoogleFonts.pendingFonts(); + + // Invoke additional initialization logic (if any) + await fn?.call(); + + // Load saved user credentials from secure storage (if any) + final preferences = locator(); + return preferences.getUserCredentials(); + }); + + value = result.fold( + onSuccess: AppStateInitialized.new, + onFailure: AppStateFailed.new, + ); + } +} + +sealed class AppState { + const AppState(); +} + +final class AppStateLoading implements AppState { + const AppStateLoading(); +} + +final class AppStateInitialized implements AppState { + const AppStateInitialized(this.credentials); + + final UserCredentials? credentials; +} + +final class AppStateFailed implements AppState { + const AppStateFailed(this.error, this.stackTrace); + + final Object error; + final StackTrace? stackTrace; +} diff --git a/sample_app/lib/app/content/app_content.dart b/sample_app/lib/app/content/app_content.dart new file mode 100644 index 00000000..551e477f --- /dev/null +++ b/sample_app/lib/app/content/app_content.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import '../../core/di/di_initializer.dart'; +import '../../core/models/user_credentials.dart'; +import '../../navigation/app_router.dart'; +import '../../theme/theme.dart'; +import 'auth_controller.dart'; + +class StreamFeedsSampleAppContent extends StatefulWidget { + const StreamFeedsSampleAppContent({super.key, this.credentials}); + + final UserCredentials? credentials; + + @override + State createState() => + _StreamFeedsSampleAppContentState(); +} + +class _StreamFeedsSampleAppContentState + extends State { + late final _appRouter = locator(); + late final _authController = locator(); + + @override + void initState() { + super.initState(); + // If credentials are provided, connect the user automatically. + if (widget.credentials case final credentials?) { + _authController.connect(credentials).ignore(); + } + } + + @override + void dispose() { + _authController.disconnect().ignore(); + _authController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + debugShowCheckedModeBanner: false, + theme: ThemeConfig.fromBrightness(Brightness.light), + darkTheme: ThemeConfig.fromBrightness(Brightness.dark), + routerConfig: _appRouter.config( + reevaluateListenable: _authController, + ), + ); + } +} diff --git a/sample_app/lib/app/content/auth_controller.dart b/sample_app/lib/app/content/auth_controller.dart new file mode 100644 index 00000000..dc5cc60b --- /dev/null +++ b/sample_app/lib/app/content/auth_controller.dart @@ -0,0 +1,55 @@ +import 'package:flutter/cupertino.dart'; +import 'package:injectable/injectable.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../core/di/di_initializer.dart'; +import '../../core/models/user_credentials.dart'; +import '../../services/app_preferences.dart'; + +@lazySingleton +class AuthController extends ValueNotifier { + AuthController( + this._appPreferences, + ) : super(const Unauthenticated()); + + final AppPreferences _appPreferences; + + Future connect(UserCredentials credentials) async { + final client = locator(param1: credentials); + + final result = await runSafely(client.connect); + result.onSuccess((_) => _appPreferences.storeUserCredentials(credentials)); + + value = result.fold( + onSuccess: (_) => Authenticated(credentials.user, client), + onFailure: (_, __) => const Unauthenticated(), + ); + } + + Future disconnect() async { + final authState = value; + if (authState is! Authenticated) return; + + final client = authState.client; + + client.disconnect().ignore(); + await _appPreferences.clearUserCredentials(); + + value = const Unauthenticated(); + } +} + +sealed class AuthState { + const AuthState(); +} + +final class Authenticated extends AuthState { + const Authenticated(this.user, this.client); + + final User user; + final StreamFeedsClient client; +} + +final class Unauthenticated extends AuthState { + const Unauthenticated(); +} diff --git a/sample_app/lib/login_screen/demo_app_config.dart b/sample_app/lib/config/demo_app_config.dart similarity index 92% rename from sample_app/lib/login_screen/demo_app_config.dart rename to sample_app/lib/config/demo_app_config.dart index 7523bb7b..aa94c4fa 100644 --- a/sample_app/lib/login_screen/demo_app_config.dart +++ b/sample_app/lib/config/demo_app_config.dart @@ -1,30 +1,27 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -class DemoAppConfig { - const DemoAppConfig({required this.apiKey, required this.tokenForUser}); - - final String apiKey; - final String Function(String) tokenForUser; - - static const current = production; - - static const staging = DemoAppConfig( +enum DemoAppConfig { + staging( apiKey: 'pd67s34fzpgw', tokenForUser: _stagingTokenForUser, - ); - - static const localhost = DemoAppConfig( + ), + localhost( apiKey: '892s22ypvt6m', tokenForUser: _localhostTokenForUser, - ); - - static const production = DemoAppConfig( + ), + production( apiKey: 'fa5xpkvxrdw4', tokenForUser: _productionTokenForUser, ); + const DemoAppConfig({ + required this.apiKey, + required this.tokenForUser, + }); + + final String apiKey; + final String Function(String) tokenForUser; + + static const current = production; + static String _stagingTokenForUser(String userId) { switch (userId) { case 'luke_skywalker': diff --git a/sample_app/lib/core/di/di_initializer.config.dart b/sample_app/lib/core/di/di_initializer.config.dart new file mode 100644 index 00000000..fd93423a --- /dev/null +++ b/sample_app/lib/core/di/di_initializer.config.dart @@ -0,0 +1,58 @@ +// dart format width=80 +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// InjectableConfigGenerator +// ************************************************************************** + +// ignore_for_file: type=lint +// coverage:ignore-file + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:get_it/get_it.dart' as _i174; +import 'package:injectable/injectable.dart' as _i526; +import 'package:sample/app/app_state.dart' as _i870; +import 'package:sample/app/content/auth_controller.dart' as _i27; +import 'package:sample/core/di/di_module.dart' as _i238; +import 'package:sample/core/models/user_credentials.dart' as _i845; +import 'package:sample/navigation/app_router.dart' as _i701; +import 'package:sample/navigation/guards/auth_guard.dart' as _i1031; +import 'package:sample/services/app_preferences.dart' as _i354; +import 'package:shared_preferences/shared_preferences.dart' as _i460; +import 'package:stream_feeds/stream_feeds.dart' as _i250; + +extension GetItInjectableX on _i174.GetIt { +// initializes the registration of main-scope dependencies inside of GetIt + Future<_i174.GetIt> init({ + String? environment, + _i526.EnvironmentFilter? environmentFilter, + }) async { + final gh = _i526.GetItHelper( + this, + environment, + environmentFilter, + ); + final appModule = _$AppModule(); + await gh.singletonAsync<_i460.SharedPreferences>( + () => appModule.prefs, + preResolve: true, + ); + gh.singleton<_i870.AppStateNotifier>(() => _i870.AppStateNotifier()); + gh.singleton<_i354.AppPreferences>( + () => _i354.AppPreferences(gh<_i460.SharedPreferences>())); + gh.lazySingleton<_i27.AuthController>( + () => _i27.AuthController(gh<_i354.AppPreferences>())); + gh.factory<_i1031.AuthGuard>( + () => _i1031.AuthGuard(gh<_i27.AuthController>())); + gh.factoryParam<_i250.StreamFeedsClient, _i845.UserCredentials, dynamic>(( + credentials, + _, + ) => + appModule.feed(credentials)); + gh.lazySingleton<_i701.AppRouter>( + () => _i701.AppRouter(gh<_i1031.AuthGuard>())); + return this; + } +} + +class _$AppModule extends _i238.AppModule {} diff --git a/sample_app/lib/core/di/di_initializer.dart b/sample_app/lib/core/di/di_initializer.dart new file mode 100644 index 00000000..4766b867 --- /dev/null +++ b/sample_app/lib/core/di/di_initializer.dart @@ -0,0 +1,10 @@ +import 'package:get_it/get_it.dart'; +import 'package:injectable/injectable.dart'; + +import 'di_initializer.config.dart'; + +final locator = GetIt.instance; + +@injectableInit +Future initDI({String? env}) => locator.init(environment: env); +Future resetDI({bool dispose = true}) => locator.reset(dispose: dispose); diff --git a/sample_app/lib/core/di/di_module.dart b/sample_app/lib/core/di/di_module.dart new file mode 100644 index 00000000..aa2b6a21 --- /dev/null +++ b/sample_app/lib/core/di/di_module.dart @@ -0,0 +1,24 @@ +import 'package:injectable/injectable.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../config/demo_app_config.dart'; +import '../models/user_credentials.dart'; + +@module +abstract class AppModule { + @singleton + @preResolve + Future get prefs => SharedPreferences.getInstance(); + + @factoryMethod + StreamFeedsClient feed(@factoryParam UserCredentials credentials) { + final token = UserToken(credentials.token); + + return StreamFeedsClient( + user: credentials.user, + apiKey: DemoAppConfig.current.apiKey, + tokenProvider: TokenProvider.static(token), + ); + } +} diff --git a/sample_app/lib/login_screen/user_credentials.dart b/sample_app/lib/core/models/user_credentials.dart similarity index 63% rename from sample_app/lib/login_screen/user_credentials.dart rename to sample_app/lib/core/models/user_credentials.dart index 2bbad838..512d08d1 100644 --- a/sample_app/lib/login_screen/user_credentials.dart +++ b/sample_app/lib/core/models/user_credentials.dart @@ -1,13 +1,26 @@ +import 'package:collection/collection.dart'; import 'package:stream_feeds/stream_feeds.dart'; -import 'demo_app_config.dart'; + +import '../../config/demo_app_config.dart'; class UserCredentials { - const UserCredentials({required this.user, required this.token}); + const UserCredentials({ + required this.user, + required this.token, + }); final User user; final String token; - // Individual user credentials + // Helper method to get feed ID + String get fid => 'user:${user.id}'; + + static String _tokenForUser(String userId) { + return DemoAppConfig.current.tokenForUser(userId); + } + + // region Individual user credentials + static final luke = UserCredentials( user: const User( id: 'luke_skywalker', @@ -15,7 +28,7 @@ class UserCredentials { image: 'https://vignette.wikia.nocookie.net/starwars/images/2/20/LukeTLJ.jpg', ), - token: DemoAppConfig.current.tokenForUser('luke_skywalker'), + token: _tokenForUser('luke_skywalker'), ); static final martin = UserCredentials( @@ -25,7 +38,7 @@ class UserCredentials { image: 'https://getstream.io/static/2796a305dd07651fcceb4721a94f4505/802d2/martin-mitrevski.webp', ), - token: DemoAppConfig.current.tokenForUser('martin'), + token: _tokenForUser('martin'), ); static final tommaso = UserCredentials( @@ -35,7 +48,7 @@ class UserCredentials { image: 'https://getstream.io/static/712bb5c0bd5ed8d3fa6e5842f6cfbeed/c59de/tommaso.webp', ), - token: DemoAppConfig.current.tokenForUser('tommaso'), + token: _tokenForUser('tommaso'), ); static final thierry = UserCredentials( @@ -45,7 +58,7 @@ class UserCredentials { image: 'https://getstream.io/static/237f45f28690696ad8fff92726f45106/c59de/thierry.webp', ), - token: DemoAppConfig.current.tokenForUser('thierry'), + token: _tokenForUser('thierry'), ); static final marcelo = UserCredentials( @@ -55,40 +68,31 @@ class UserCredentials { image: 'https://getstream.io/static/aaf5fb17dcfd0a3dd885f62bd21b325a/802d2/marcelo-pires.webp', ), - token: DemoAppConfig.current.tokenForUser('marcelo'), + token: _tokenForUser('marcelo'), ); static final kanat = UserCredentials( user: const User(id: 'kanat', name: 'Kanat'), - token: DemoAppConfig.current.tokenForUser('kanat'), + token: _tokenForUser('kanat'), ); static final toomas = UserCredentials( user: const User(id: 'toomas', name: 'Toomas'), - token: DemoAppConfig.current.tokenForUser('toomas'), + token: _tokenForUser('toomas'), ); - // Built-in list sorted by name - static List get builtIn => [ - luke, - martin, - tommaso, - thierry, - marcelo, - kanat, - toomas, - ]..sort( - (a, b) => - a.user.name.toLowerCase().compareTo(b.user.name.toLowerCase()), - ); + // endregion - // Helper method to get feed ID - String get fid => 'user:${user.id}'; + // Built-in list sorted by name + static List get builtIn { + final users = [luke, martin, tommaso, thierry, marcelo, kanat, toomas]; + return users.sorted( + (a, b) => a.user.name.toLowerCase().compareTo(b.user.name.toLowerCase()), + ); + } // Helper method to get credentials by ID static UserCredentials credentialsFor(String id) { - final found = - builtIn.where((credentials) => credentials.user.id == id).firstOrNull; - return found ?? tommaso; + return builtIn.firstWhere((it) => it.user.id == id, orElse: () => tommaso); } } diff --git a/sample_app/lib/home_screen/home_screen.dart b/sample_app/lib/home_screen/home_screen.dart deleted file mode 100644 index 71d288e9..00000000 --- a/sample_app/lib/home_screen/home_screen.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_state_notifier/flutter_state_notifier.dart'; -import 'package:get_it/get_it.dart'; -import 'package:stream_feeds/stream_feeds.dart'; - -import '../navigation/app_state.dart'; - -@RoutePage() -class HomeScreen extends StatefulWidget { - const HomeScreen({super.key}); - - @override - State createState() => _HomeScreenState(); -} - -class _HomeScreenState extends State { - late final AppStateProvider appStateProvider = - GetIt.instance.get(); - - @override - void initState() { - super.initState(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Home'), - actions: [ - IconButton( - icon: const Icon(Icons.logout), - onPressed: () { - appStateProvider.clearUserId(); - }, - ), - ], - ), - body: ValueListenableBuilder( - valueListenable: appStateProvider, - builder: (context, appState, child) => switch (appState) { - LoggedInState() => _FeedList(feedsClient: appState.feedsClient), - _ => const Center( - child: Text('Please login, you should not be here.'), - ) - }, - ), - ); - } -} - -class _FeedList extends StatefulWidget { - const _FeedList({required this.feedsClient}); - final StreamFeedsClient feedsClient; - - @override - State<_FeedList> createState() => _FeedListState(); -} - -class _FeedListState extends State<_FeedList> { - late Feed feed; - - @override - void initState() { - super.initState(); - _createFeed(); - } - - @override - void didUpdateWidget(covariant _FeedList oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.feedsClient.user.id != widget.feedsClient.user.id) { - _disposeFeed(); - _createFeed(); - } - } - - @override - void dispose() { - _disposeFeed(); - super.dispose(); - } - - void _createFeed() { - feed = widget.feedsClient.feed( - 'user', - widget.feedsClient.user.id, - )..getOrCreate(); - } - - void _disposeFeed() { - //todo stop listening to the feed - } - - @override - Widget build(BuildContext context) { - return StateNotifierBuilder( - stateNotifier: feed.state, - builder: (context, state, child) => RefreshIndicator( - onRefresh: () => feed.getOrCreate(), - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: ListView.builder( - itemCount: state.activities.length, - itemBuilder: (context, index) { - final activity = state.activities[index]; - - return ListTile( - leading: CircleAvatar( - backgroundImage: switch (activity.user.image) { - final String imageUrl => - CachedNetworkImageProvider(imageUrl), - _ => null, - }, - child: switch (activity.user.image) { - String _ => null, - _ => Text( - activity.user.name?.substring(0, 1).toUpperCase() ?? - '?', - ), - }, - ), - title: Text(activity.user.name ?? 'unknown user'), - subtitle: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(activity.text ?? 'empty message'), - Text('${activity.reactionCount} reactions'), - ], - ), - ); - }, - ), - ), - ), - ); - } -} diff --git a/sample_app/lib/login_screen/login_screen.dart b/sample_app/lib/login_screen/login_screen.dart deleted file mode 100644 index 3b6dafca..00000000 --- a/sample_app/lib/login_screen/login_screen.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; - -import '../navigation/app_state.dart'; -import 'user_credentials.dart'; - -@RoutePage() -class LoginScreen extends StatefulWidget { - const LoginScreen({ - super.key, - }); - - @override - State createState() => _LoginScreenState(); -} - -class _LoginScreenState extends State { - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Welcome to Stream Feeds')), - body: ListView( - children: [ - const Text( - 'Select a user to try the Flutter SDK', - textAlign: TextAlign.center, - ), - ...UserCredentials.builtIn - .map( - (credentials) => [ - _LoginUserListItem( - credentials, - onTap: () { - GetIt.instance - .get() - .setUser(credentials); - }, - ), - const Divider(), - ], - ) - .expand((e) => e), - ], - ), - ); - } -} - -class _LoginUserListItem extends StatelessWidget { - const _LoginUserListItem(this.credentials, {required this.onTap}); - - final UserCredentials credentials; - final GestureTapCallback onTap; - - @override - Widget build(BuildContext context) { - return ListTile( - onTap: onTap, - leading: CircleAvatar( - backgroundImage: switch (credentials.user.image) { - final String image => CachedNetworkImageProvider(image), - _ => null, - }, - child: switch (credentials.user.image) { - String _ => null, - _ => _LoginUserListItemPlaceholder(credentials), - }, - ), - title: Text(credentials.user.name), - subtitle: const Text('Stream test account'), - trailing: const Icon(Icons.arrow_forward), - ); - } -} - -class _LoginUserListItemPlaceholder extends StatelessWidget { - const _LoginUserListItemPlaceholder(this.credentials); - final UserCredentials credentials; - - @override - Widget build(BuildContext context) { - return Text(credentials.user.name.substring(0, 1).toUpperCase()); - } -} diff --git a/sample_app/lib/main.dart b/sample_app/lib/main.dart index a918e471..70ab3519 100644 --- a/sample_app/lib/main.dart +++ b/sample_app/lib/main.dart @@ -1,30 +1,9 @@ -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; +import 'package:flutter/widgets.dart'; -import 'navigation/app_router.dart'; -import 'navigation/app_state.dart'; -import 'widgets/theme.dart'; +import 'app/app.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - final appState = AppStateProvider(); - await appState.init(); - GetIt.instance.registerSingleton(appState); - runApp(MyApp(appState: appState)); -} - -class MyApp extends StatelessWidget { - MyApp({super.key, required this.appState}); - - final AppStateProvider appState; - late final AppRouter _router = AppRouter(appState: appState); - @override - Widget build(BuildContext context) { - return MaterialApp.router( - title: 'Flutter Demo', - theme: FeedsSampleThemeData.light, - routerConfig: _router.config(reevaluateListenable: appState), - ); - } + return runApp(const StreamFeedsSampleApp()); } diff --git a/sample_app/lib/navigation/app_router.dart b/sample_app/lib/navigation/app_router.dart index d5b26151..618a39dc 100644 --- a/sample_app/lib/navigation/app_router.dart +++ b/sample_app/lib/navigation/app_router.dart @@ -1,24 +1,33 @@ import 'package:auto_route/auto_route.dart'; +import 'package:injectable/injectable.dart'; -import '../home_screen/home_screen.dart'; -import '../login_screen/login_screen.dart'; -import 'app_state.dart'; -import 'auth_guard.dart'; +import '../screens/choose_user/choose_user_screen.dart'; +import '../screens/home/home_screen.dart'; +import 'guards/auth_guard.dart'; part 'app_router.gr.dart'; +@lazySingleton @AutoRouterConfig(replaceInRouteName: 'Screen|Page,Route') class AppRouter extends RootStackRouter { - AppRouter({required this.appState}); - final AppStateProvider appState; + AppRouter(this._authGuard); + + final AuthGuard _authGuard; @override - List get routes => [ - AutoRoute( - page: HomeRoute.page, - initial: true, - guards: [AuthGuard(appState: appState)], - ), - AutoRoute(page: LoginRoute.page), - ]; + List get routes { + return [ + AutoRoute( + path: '/', + initial: true, + page: HomeRoute.page, + guards: [_authGuard], + ), + AutoRoute( + path: '/choose_user', + page: ChooseUserRoute.page, + keepHistory: false, + ), + ]; + } } diff --git a/sample_app/lib/navigation/app_router.gr.dart b/sample_app/lib/navigation/app_router.gr.dart index fb57120d..97209c3a 100644 --- a/sample_app/lib/navigation/app_router.gr.dart +++ b/sample_app/lib/navigation/app_router.gr.dart @@ -11,33 +11,33 @@ part of 'app_router.dart'; /// generated route for -/// [HomeScreen] -class HomeRoute extends PageRouteInfo { - const HomeRoute({List? children}) - : super(HomeRoute.name, initialChildren: children); +/// [ChooseUserScreen] +class ChooseUserRoute extends PageRouteInfo { + const ChooseUserRoute({List? children}) + : super(ChooseUserRoute.name, initialChildren: children); - static const String name = 'HomeRoute'; + static const String name = 'ChooseUserRoute'; static PageInfo page = PageInfo( name, builder: (data) { - return const HomeScreen(); + return const ChooseUserScreen(); }, ); } /// generated route for -/// [LoginScreen] -class LoginRoute extends PageRouteInfo { - const LoginRoute({List? children}) - : super(LoginRoute.name, initialChildren: children); +/// [HomeScreen] +class HomeRoute extends PageRouteInfo { + const HomeRoute({List? children}) + : super(HomeRoute.name, initialChildren: children); - static const String name = 'LoginRoute'; + static const String name = 'HomeRoute'; static PageInfo page = PageInfo( name, builder: (data) { - return const LoginScreen(); + return const HomeScreen(); }, ); } diff --git a/sample_app/lib/navigation/app_state.dart b/sample_app/lib/navigation/app_state.dart deleted file mode 100644 index b80a72c7..00000000 --- a/sample_app/lib/navigation/app_state.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:stream_feeds/stream_feeds.dart'; - -import '../login_screen/demo_app_config.dart'; -import '../login_screen/user_credentials.dart'; - -class AppStateProvider extends ValueNotifier { - AppStateProvider() : super(InitialState()); - - late final SharedPreferences _prefs; - - String? get userId { - if (value is LoggedInState) { - return (value as LoggedInState).feedsClient.user.id; - } - return null; - } - - bool get isLoggedIn => value is! LoggedOutState; - - Future init() async { - _prefs = await SharedPreferences.getInstance(); - final userId = _prefs.getString('user_id'); - if (userId != null) { - final credentials = UserCredentials.credentialsFor(userId); - await setUser(credentials); - } else { - value = LoggedOutState(); - } - } - - Future setUser(UserCredentials userCredentials) async { - await _prefs.setString('user_id', userCredentials.user.id); - value = LoadingState(); - final client = StreamFeedsClient( - apiKey: DemoAppConfig.current.apiKey, - user: userCredentials.user, - tokenProvider: TokenProvider.static( - UserToken(userCredentials.token), - ), - ); - await client.connect(); - - value = LoggedInState( - feedsClient: client, - ); - } - - void clearUserId() { - _prefs.remove('user_id'); - if (value is LoggedInState) { - (value as LoggedInState).feedsClient.disconnect(); - } - value = LoggedOutState(); - } -} - -abstract class AppState {} - -class InitialState extends AppState {} - -class LoggedOutState extends AppState {} - -class LoadingState extends AppState {} - -class LoggedInState extends AppState { - LoggedInState({required this.feedsClient}); - - final StreamFeedsClient feedsClient; -} diff --git a/sample_app/lib/navigation/auth_guard.dart b/sample_app/lib/navigation/auth_guard.dart deleted file mode 100644 index 9b69d557..00000000 --- a/sample_app/lib/navigation/auth_guard.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:auto_route/auto_route.dart'; - -import 'app_router.dart'; -import 'app_state.dart'; - -class AuthGuard extends AutoRouteGuard { - AuthGuard({required this.appState}); - - final AppStateProvider appState; - - @override - void onNavigation(NavigationResolver resolver, StackRouter router) { - if (appState.isLoggedIn) { - // if user is authenticated we continue - resolver.next(); - } else { - // we redirect the user to our login page - // tip: use resolver.redirectUntil to have the redirected route - // automatically removed from the stack when the resolver is completed - resolver.redirectUntil( - const LoginRoute(), - ); - } - } -} diff --git a/sample_app/lib/navigation/guards/auth_guard.dart b/sample_app/lib/navigation/guards/auth_guard.dart new file mode 100644 index 00000000..e170af9d --- /dev/null +++ b/sample_app/lib/navigation/guards/auth_guard.dart @@ -0,0 +1,26 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/foundation.dart'; +import 'package:injectable/injectable.dart'; + +import '../../app/content/auth_controller.dart'; +import '../app_router.dart'; + +@injectable +class AuthGuard extends AutoRouteGuard { + const AuthGuard(this._authController); + + final AuthController _authController; + + @override + void onNavigation(NavigationResolver resolver, StackRouter router) { + final isAuthenticated = _authController.value is Authenticated; + debugPrint('AuthGuard: isAuthenticated = $isAuthenticated'); + + // If the user is authenticated, allow navigation to the requested route. + if (isAuthenticated) return resolver.next(); + + print('AuthGuard: User is not authenticated, redirecting to login.'); + // Otherwise, redirect to the Choose user page. + resolver.redirectUntil(const ChooseUserRoute(), replace: true); + } +} diff --git a/sample_app/lib/screens/choose_user/choose_user_screen.dart b/sample_app/lib/screens/choose_user/choose_user_screen.dart new file mode 100644 index 00000000..ada30fe0 --- /dev/null +++ b/sample_app/lib/screens/choose_user/choose_user_screen.dart @@ -0,0 +1,98 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../app/content/auth_controller.dart'; +import '../../core/di/di_initializer.dart'; +import '../../core/models/user_credentials.dart'; +import '../../theme/extensions/theme_extensions.dart'; +import '../../widgets/user_avatar.dart'; + +@RoutePage() +class ChooseUserScreen extends StatelessWidget { + const ChooseUserScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 34), + Center( + child: SvgPicture.asset( + 'assets/images/app_logo.svg', + height: 40, + colorFilter: ColorFilter.mode( + context.appColors.accentPrimary, + BlendMode.srcIn, + ), + ), + ), + const SizedBox(height: 20), + Text( + 'Welcome to Stream Feeds', + style: context.appTextStyles.title, + ), + const SizedBox(height: 12), + Text( + 'Select a user to try the Flutter SDK:', + style: context.appTextStyles.body, + ), + const SizedBox(height: 32), + Expanded( + child: UserSelectionList( + onUserSelected: (credentials) { + final authController = locator(); + return authController.connect(credentials).ignore(); + }, + ), + ), + ], + ), + ); + } +} + +class UserSelectionList extends StatelessWidget { + const UserSelectionList({ + super.key, + this.onUserSelected, + }); + + final ValueSetter? onUserSelected; + + @override + Widget build(BuildContext context) { + return ListView.separated( + itemCount: UserCredentials.builtIn.length, + separatorBuilder: (context, index) => Divider( + height: 1, + color: context.appColors.borders, + ), + itemBuilder: (context, index) { + final credential = UserCredentials.builtIn[index]; + + return ListTile( + onTap: () => onUserSelected?.call(credential), + visualDensity: VisualDensity.compact, + leading: UserAvatar.listTile(user: credential.user), + title: Text( + credential.user.name, + style: context.appTextStyles.bodyBold, + ), + subtitle: Text( + 'Stream test account', + style: context.appTextStyles.footnote.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + trailing: Icon( + Icons.arrow_forward_rounded, + color: context.appColors.accentPrimary, + ), + ); + }, + ); + } +} diff --git a/sample_app/lib/screens/home/home_screen.dart b/sample_app/lib/screens/home/home_screen.dart new file mode 100644 index 00000000..a06e21bc --- /dev/null +++ b/sample_app/lib/screens/home/home_screen.dart @@ -0,0 +1,50 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; + +import '../../app/content/auth_controller.dart'; +import '../../core/di/di_initializer.dart'; +import '../../theme/theme.dart'; +import '../../widgets/user_avatar.dart'; +import 'widgets/user_feed_appbar.dart'; +import 'widgets/user_feed_view.dart'; + +@RoutePage() +class HomeScreen extends StatelessWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + final authController = locator(); + final state = authController.value; + final (user, client) = switch (state) { + Authenticated(:final user, :final client) => (user, client), + _ => throw Exception('User not authenticated'), + }; + + return Scaffold( + backgroundColor: context.appColors.appBg, + appBar: UserFeedAppbar( + leading: Center( + child: UserAvatar.appBar(user: user), + ), + title: Text( + 'Stream Feeds', + style: context.appTextStyles.headlineBold, + ), + actions: [ + IconButton( + onPressed: authController.disconnect, + icon: Icon( + Icons.logout, + color: context.appColors.textLowEmphasis, + ), + ), + ], + ), + body: UserFeedView( + client: client, + currentUser: user, + ), + ); + } +} diff --git a/sample_app/lib/screens/home/widgets/activity_comments_view.dart b/sample_app/lib/screens/home/widgets/activity_comments_view.dart new file mode 100644 index 00000000..052ebad0 --- /dev/null +++ b/sample_app/lib/screens/home/widgets/activity_comments_view.dart @@ -0,0 +1,361 @@ +// ignore_for_file: avoid_positional_boolean_parameters + +import 'package:flutter/material.dart'; +import 'package:flutter_state_notifier/flutter_state_notifier.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../../theme/extensions/theme_extensions.dart'; +import '../../../utils/date_time_extensions.dart'; +import '../../../widgets/user_avatar.dart'; +import 'activity_content.dart'; + +class ActivityCommentsView extends StatefulWidget { + const ActivityCommentsView({ + required this.activityId, + required this.feed, + required this.client, + super.key, + }); + final String activityId; + final Feed feed; + final StreamFeedsClient client; + + @override + State createState() => _ActivityCommentsViewState(); +} + +class _ActivityCommentsViewState extends State { + late Activity activity; + RemoveListener? _removeFeedListener; + late List capabilities; + + @override + void initState() { + super.initState(); + _getActivity(); + _observeFeedCapabilities(); + } + + @override + void didUpdateWidget(covariant ActivityCommentsView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.activityId != widget.activityId || + oldWidget.feed != widget.feed) { + activity.dispose(); + _getActivity(); + } + if (oldWidget.feed != widget.feed) { + _observeFeedCapabilities(); + } + } + + @override + void dispose() { + _removeFeedListener?.call(); + activity.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(16), + child: StateNotifierBuilder( + stateNotifier: activity.state, + builder: (context, state, child) { + return CommentsList( + totalComments: state.activity?.commentCount ?? 0, + comments: state.comments, + onHeartClick: _onHeartClick, + onLoadMore: + state.canLoadMoreComments ? activity.queryMoreComments : null, + onReplyClick: (comment) => _reply(context, comment), + onLongPressComment: (comment) => + _onLongPressComment(context, comment), + ); + }, + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _reply(context, null), + child: const Icon(Icons.add), + ), + ); + } + + void _observeFeedCapabilities() { + _removeFeedListener?.call(); + _removeFeedListener = widget.feed.state.addListener(_onFeedStateChange); + } + + void _onFeedStateChange(FeedState state) { + capabilities = state.ownCapabilities; + } + + Future _getActivity() async { + activity = widget.client.activity(widget.activityId, widget.feed.fid); + await activity.get(); + } + + void _onHeartClick(ThreadedCommentData comment, bool isAdding) { + const type = 'heart'; + + if (isAdding) { + activity.addCommentReaction( + comment.id, + const AddCommentReactionRequest(type: type), + ); + } else { + activity.deleteCommentReaction( + comment.id, + type, + ); + } + } + + Future _reply( + BuildContext context, + ThreadedCommentData? parentComment, + ) async { + final text = await _displayTextInputDialog(context, title: 'Add comment'); + if (text == null) return; + + await activity.addComment( + ActivityAddCommentRequest(comment: text, parentId: parentComment?.id), + ); + } + + void _onLongPressComment(BuildContext context, ThreadedCommentData comment) { + final isOwnComment = comment.user.id == widget.client.user.id; + if (!isOwnComment) return; + final canEdit = capabilities.contains(FeedOwnCapability.updateComment); + final canDelete = capabilities.contains(FeedOwnCapability.deleteComment); + if (!canEdit && !canDelete) return; + + final chooseActionDialog = SimpleDialog( + children: [ + if (canEdit) + SimpleDialogOption( + child: const Text('Edit'), + onPressed: () { + Navigator.pop(context); + _editComment(context, comment); + }, + ), + if (canDelete) + SimpleDialogOption( + child: const Text('Delete'), + onPressed: () { + activity.deleteComment(comment.id); + Navigator.pop(context); + }, + ), + ], + ); + + showDialog( + context: context, + builder: (context) { + return chooseActionDialog; + }, + ); + } + + Future _editComment( + BuildContext context, + ThreadedCommentData comment, + ) async { + final text = await _displayTextInputDialog( + context, + title: 'Edit comment', + initialText: comment.text, + positiveAction: 'Edit', + ); + + if (text == null) return; + + await activity.updateComment( + comment.id, + ActivityUpdateCommentRequest(comment: text), + ); + } + + Future _displayTextInputDialog( + BuildContext context, { + required String title, + String? initialText, + String positiveAction = 'Add', + }) async { + final textFieldController = TextEditingController(); + textFieldController.text = initialText ?? ''; + return showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(title), + content: TextField(controller: textFieldController), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.pop(context); + }, + ), + TextButton( + child: Text(positiveAction), + onPressed: () { + Navigator.pop(context, textFieldController.text); + }, + ), + ], + ); + }, + ); + } +} + +class CommentsList extends StatelessWidget { + const CommentsList({ + super.key, + required this.totalComments, + required this.comments, + required this.onHeartClick, + required this.onLoadMore, + required this.onReplyClick, + required this.onLongPressComment, + }); + + final int totalComments; + final List comments; + final void Function(ThreadedCommentData comment, bool isAdding) onHeartClick; + final ValueSetter onReplyClick; + final ValueSetter onLongPressComment; + final VoidCallback? onLoadMore; + + @override + Widget build(BuildContext context) { + return ListView.separated( + separatorBuilder: (context, index) => const Divider(), + itemCount: comments.length + 2, + itemBuilder: (context, index) { + if (index == 0) { + return Text( + 'Comments', + style: context.appTextStyles.headlineBold, + ); + } + + if (index == comments.length + 1) { + if (onLoadMore != null) { + return TextButton( + onPressed: onLoadMore, + child: const Text('Load more...'), + ); + } + return Padding( + padding: const EdgeInsets.all(16), + child: + Text('End of comments', style: context.appTextStyles.footnote), + ); + } + + final comment = comments[index - 1]; + + return CommentWidget( + comment: comment, + onHeartClick: onHeartClick, + onReplyClick: onReplyClick, + onLongPressComment: onLongPressComment, + ); + }, + ); + } +} + +class CommentWidget extends StatelessWidget { + const CommentWidget({ + super.key, + required this.comment, + required this.onHeartClick, + required this.onReplyClick, + required this.onLongPressComment, + }); + final ThreadedCommentData comment; + final ValueSetter onReplyClick; + final void Function(ThreadedCommentData comment, bool isAdding) onHeartClick; + final ValueSetter onLongPressComment; + + @override + Widget build(BuildContext context) { + final user = comment.user; + final heartsCount = comment.reactionGroups['heart']?.count ?? 0; + final hasOwnHeart = comment.ownReactions.any((it) => it.type == 'heart'); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + GestureDetector( + onLongPress: () => onLongPressComment(comment), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UserAvatar.appBar( + user: User(id: user.id, name: user.name, image: user.image), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.name ?? user.id, + style: context.appTextStyles.footnoteBold, + ), + Text( + comment.createdAt.displayRelativeTime, + style: context.appTextStyles.footnote, + ), + const SizedBox(height: 8), + Text(comment.text ?? ''), + ], + ), + ), + ], + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () => onReplyClick(comment), + child: const Text('Reply'), + ), + ActionButton( + icon: Icon( + hasOwnHeart + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + size: 16, + color: hasOwnHeart ? Colors.red : null, + ), + count: heartsCount, + onTap: () => onHeartClick(comment, !hasOwnHeart), + ), + ], + ), + for (final reply in comment.replies ?? []) + Padding( + padding: const EdgeInsets.only(left: 32), + child: CommentWidget( + comment: reply, + onHeartClick: onHeartClick, + onReplyClick: onReplyClick, + onLongPressComment: onLongPressComment, + ), + ), + ], + ); + } +} diff --git a/sample_app/lib/screens/home/widgets/activity_content.dart b/sample_app/lib/screens/home/widgets/activity_content.dart new file mode 100644 index 00000000..f35a90f5 --- /dev/null +++ b/sample_app/lib/screens/home/widgets/activity_content.dart @@ -0,0 +1,258 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../../theme/extensions/theme_extensions.dart'; +import '../../../utils/date_time_extensions.dart'; +import '../../../widgets/user_avatar.dart'; + +class ActivityContent extends StatelessWidget { + const ActivityContent({ + super.key, + required this.user, + required this.text, + required this.attachments, + required this.data, + required this.currentUserId, + this.onCommentClick, + this.onHeartClick, + this.onRepostClick, + this.onBookmarkClick, + this.onDeleteClick, + this.onEditSave, + }); + + final UserData user; + final String text; + final List attachments; + final ActivityData data; + final String currentUserId; + final VoidCallback? onCommentClick; + final ValueSetter? onHeartClick; + final ValueSetter? onRepostClick; + final VoidCallback? onBookmarkClick; + final VoidCallback? onDeleteClick; + final ValueChanged? onEditSave; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _UserContent( + user: user, + data: data, + text: text, + attachments: attachments, + ), + const SizedBox(height: 8), + Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 500, + ), + child: _UserActions( + user: user, + data: data, + currentUserId: currentUserId, + onCommentClick: onCommentClick, + onHeartClick: onHeartClick, + onRepostClick: onRepostClick, + onBookmarkClick: onBookmarkClick, + ), + ), + ), + ], + ); + } +} + +class _UserContent extends StatelessWidget { + const _UserContent({ + super.key, + required this.user, + required this.data, + required this.text, + required this.attachments, + }); + + final UserData user; + final ActivityData data; + final String text; + final List attachments; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UserAvatar.appBar( + user: User(id: user.id, name: user.name, image: user.image), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.name ?? user.id, + style: context.appTextStyles.footnoteBold, + ), + Text( + data.createdAt.displayRelativeTime, + style: context.appTextStyles.footnote, + ), + const SizedBox(height: 8), + _ActivityBody( + user: user, + text: text, + attachments: attachments, + data: data, + ), + ], + ), + ), + ], + ); + } +} + +class _ActivityBody extends StatelessWidget { + const _ActivityBody({ + super.key, + required this.user, + required this.text, + required this.attachments, + required this.data, + }); + + final UserData user; + final String text; + final List attachments; + final ActivityData data; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (text.isNotEmpty) Text(text), + if (attachments.isNotEmpty) ...[ + const SizedBox(height: 8), + SizedBox( + height: 100, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: attachments.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final attachment = attachments[index]; + return Image.network( + attachment.imageUrl ?? attachment.assetUrl!, + width: 100, + height: 100, + fit: BoxFit.cover, + ); + }, + ), + ), + ], + ], + ); + } +} + +class _UserActions extends StatelessWidget { + const _UserActions({ + super.key, + required this.user, + required this.data, + required this.currentUserId, + this.onCommentClick, + this.onHeartClick, + this.onRepostClick, + this.onBookmarkClick, + }); + + final UserData user; + final ActivityData data; + final String currentUserId; + final VoidCallback? onCommentClick; + final ValueSetter? onHeartClick; + final ValueSetter? onRepostClick; + final VoidCallback? onBookmarkClick; + + @override + Widget build(BuildContext context) { + //val heartsCount = activity.reactionGroups["heart"]?.count ?: 0 + // val hasOwnHeart = activity.ownReactions.any { it.type == "heart" } + + final heartsCount = data.reactionGroups['heart']?.count ?? 0; + final hasOwnHeart = data.ownReactions.any((it) => it.type == 'heart'); + + final hasOwnBookmark = data.ownReactions.any((it) => it.type == 'bookmark'); + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + ActionButton( + icon: const Icon(Icons.comment, size: 16), + count: data.commentCount, + onTap: onCommentClick, + ), + ActionButton( + icon: Icon( + hasOwnHeart + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + size: 16, + color: hasOwnHeart ? Colors.red : null, + ), + count: heartsCount, + onTap: () => onHeartClick?.call(!hasOwnHeart), + ), + ActionButton( + icon: const Icon(Icons.share_rounded, size: 16), + count: data.shareCount, + onTap: () => onRepostClick?.call(null), + ), + ActionButton( + icon: Icon( + hasOwnBookmark + ? Icons.bookmark_rounded + : Icons.bookmark_border_rounded, + size: 16, + color: hasOwnBookmark ? Colors.blue : null, + ), + count: data.bookmarkCount, + onTap: onBookmarkClick, + ), + ], + ); + } +} + +class ActionButton extends StatelessWidget { + const ActionButton({ + super.key, + this.icon, + required this.count, + this.onTap, + }); + + final Widget? icon; + final int count; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return TextButton.icon( + onPressed: onTap, + label: Text( + count > 0 ? count.toString() : '', + style: context.appTextStyles.footnote, + ), + icon: icon, + ); + } +} diff --git a/sample_app/lib/screens/home/widgets/user_feed_appbar.dart b/sample_app/lib/screens/home/widgets/user_feed_appbar.dart new file mode 100644 index 00000000..625c6d84 --- /dev/null +++ b/sample_app/lib/screens/home/widgets/user_feed_appbar.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import '../../../theme/extensions/theme_extensions.dart'; + +class UserFeedAppbar extends StatelessWidget implements PreferredSizeWidget { + const UserFeedAppbar({ + super.key, + required this.title, + this.leading, + this.centerTitle, + required this.actions, + }); + + final Widget title; + final Widget? leading; + final bool? centerTitle; + final List actions; + + @override + Widget build(BuildContext context) { + return AppBar( + elevation: 0, + backgroundColor: context.appColors.barsBg, + foregroundColor: context.appColors.textHighEmphasis, + leading: leading, + actions: actions, + centerTitle: centerTitle, + title: title, + ); + } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} diff --git a/sample_app/lib/screens/home/widgets/user_feed_view.dart b/sample_app/lib/screens/home/widgets/user_feed_view.dart new file mode 100644 index 00000000..85be04bb --- /dev/null +++ b/sample_app/lib/screens/home/widgets/user_feed_view.dart @@ -0,0 +1,184 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_state_notifier/flutter_state_notifier.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../../theme/extensions/theme_extensions.dart'; +import 'activity_comments_view.dart'; +import 'activity_content.dart'; + +class UserFeedView extends StatefulWidget { + const UserFeedView({ + super.key, + required this.client, + required this.currentUser, + }); + + final User currentUser; + final StreamFeedsClient client; + + @override + State createState() => _UserFeedViewState(); +} + +class _UserFeedViewState extends State { + late final feed = widget.client.feedFromQuery( + FeedQuery( + fid: FeedId(group: 'user', id: widget.currentUser.id), + data: FeedInputData( + visibility: FeedVisibility.public, + members: [FeedMemberRequestData(userId: widget.currentUser.id)], + ), + ), + ); + + @override + void initState() { + super.initState(); + feed.getOrCreate(); + } + + @override + void dispose() { + feed.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StateNotifierBuilder( + stateNotifier: feed.state, + builder: (context, state, child) { + final activities = state.activities; + final canLoadMore = state.canLoadMoreActivities; + + if (activities.isEmpty) return const EmptyActivities(); + + return RefreshIndicator( + onRefresh: () => feed.getOrCreate(), + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }, + ), + child: ListView.separated( + itemCount: activities.length + 1, + separatorBuilder: (context, index) => Divider( + height: 1, + color: context.appColors.borders, + ), + itemBuilder: (context, index) { + if (index == activities.length) { + return canLoadMore + ? TextButton( + onPressed: () => feed.queryMoreActivities(), + child: const Text('Load more...'), + ) + : const Text('End of feed'); + } + + final activity = activities[index]; + final parentActivity = activity.parent; + final baseActivity = activity.parent ?? activity; + + return Padding( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + if (parentActivity != null) ...[ + ActivityRepostIndicator( + user: activity.user, + data: parentActivity, + ), + const SizedBox(height: 8), + ], + ActivityContent( + user: baseActivity.user, + text: baseActivity.text ?? '', + attachments: baseActivity.attachments, + data: activity, + currentUserId: widget.client.user.id, + onCommentClick: () => + _onCommentClick(context, activity), + onHeartClick: (isAdding) => + _onHeartClick(activity, isAdding), + onRepostClick: (message) {}, + onBookmarkClick: () {}, + onDeleteClick: () {}, + onEditSave: (text) {}, + ), + ], + ), + ); + }, + ), + ), + ); + }, + ); + } + + void _onCommentClick(BuildContext context, ActivityData activity) { + showModalBottomSheet( + context: context, + builder: (context) => ActivityCommentsView( + activityId: activity.id, + feed: feed, + client: widget.client, + ), + ); + } + + void _onHeartClick(ActivityData activity, bool isAdding) { + if (isAdding) { + feed.addReaction( + activityId: activity.id, + request: const AddReactionRequest(type: 'heart'), + ); + } else { + feed.deleteReaction( + activityId: activity.id, + type: 'heart', + ); + } + } +} + +class ActivityRepostIndicator extends StatelessWidget { + const ActivityRepostIndicator({ + super.key, + required this.user, + required this.data, + }); + + final UserData user; + final ActivityData data; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Icon( + Icons.repeat, + size: 16, + ), + const SizedBox(width: 4), + Text('${user.name} reposted'), + ], + ); + } +} + +class EmptyActivities extends StatelessWidget { + const EmptyActivities({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: Text('No activities yet. Start by creating a post!'), + ); + } +} diff --git a/sample_app/lib/services/app_preferences.dart b/sample_app/lib/services/app_preferences.dart new file mode 100644 index 00000000..73e64489 --- /dev/null +++ b/sample_app/lib/services/app_preferences.dart @@ -0,0 +1,33 @@ +import 'package:collection/collection.dart'; +import 'package:injectable/injectable.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../core/models/user_credentials.dart'; + +@singleton +class AppPreferences { + const AppPreferences(this._prefs); + + final SharedPreferences _prefs; + + static const String _themeModeKey = 'theme_mode'; + static const String _loggedUserId = 'logged_user_id'; + + int getThemeMode() => _prefs.getInt(_themeModeKey) ?? 0; + + Future setThemeMode(int mode) => _prefs.setInt(_themeModeKey, mode); + + UserCredentials? getUserCredentials() { + final userId = _prefs.getString(_loggedUserId); + if (userId == null) return null; + + final builtInUsers = UserCredentials.builtIn; + return builtInUsers.firstWhereOrNull((it) => it.user.id == userId); + } + + Future storeUserCredentials(UserCredentials credentials) { + return _prefs.setString(_loggedUserId, credentials.user.id); + } + + Future clearUserCredentials() => _prefs.remove(_loggedUserId); +} diff --git a/sample_app/lib/theme/app_theme.dart b/sample_app/lib/theme/app_theme.dart new file mode 100644 index 00000000..df9f37b5 --- /dev/null +++ b/sample_app/lib/theme/app_theme.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +import 'schemes/app_color_scheme.dart'; +import 'schemes/app_effects.dart'; +import 'schemes/app_text_theme.dart'; + +/// {@template app_theme} +/// Main theme extension for the Stream Feeds sample app. +/// +/// Wraps [AppColorScheme], [AppTextTheme], and [AppEffects] in a single +/// extension following Flutter's ThemeData pattern. +/// {@endtemplate} +@immutable +class AppTheme extends ThemeExtension { + /// Creates an [AppTheme]. + const AppTheme({ + required this.colorScheme, + required this.textTheme, + required this.effects, + }); + + /// Creates a light app theme. + factory AppTheme.light() { + return AppTheme.fromBrightness(Brightness.light); + } + + /// Creates a dark app theme. + factory AppTheme.dark() { + return AppTheme.fromBrightness(Brightness.dark); + } + + /// Creates an app theme for the given [brightness]. + /// + /// This is the recommended way to create app themes as it automatically + /// selects the appropriate colors, text styles, and effects based on brightness. + /// + /// Example: + /// ```dart + /// final lightTheme = AppTheme.fromBrightness(Brightness.light); + /// final darkTheme = AppTheme.fromBrightness(Brightness.dark); + /// ``` + factory AppTheme.fromBrightness(Brightness brightness) { + return AppTheme( + colorScheme: AppColorScheme.fromBrightness(brightness), + textTheme: AppTextTheme.fromBrightness(brightness), + effects: AppEffects.fromBrightness(brightness), + ); + } + + /// The color scheme for this theme. + final AppColorScheme colorScheme; + + /// The text theme for this theme. + final AppTextTheme textTheme; + + /// The effects for this theme. + final AppEffects effects; + + @override + AppTheme copyWith({ + AppColorScheme? colorScheme, + AppTextTheme? textTheme, + AppEffects? effects, + }) { + return AppTheme( + colorScheme: colorScheme ?? this.colorScheme, + textTheme: textTheme ?? this.textTheme, + effects: effects ?? this.effects, + ); + } + + @override + AppTheme lerp(ThemeExtension? other, double t) { + if (other is! AppTheme) { + return this; + } + return AppTheme( + colorScheme: AppColorScheme.lerp(colorScheme, other.colorScheme, t), + textTheme: AppTextTheme.lerp(textTheme, other.textTheme, t), + effects: AppEffects.lerp(effects, other.effects, t), + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is AppTheme && + other.colorScheme == colorScheme && + other.textTheme == textTheme && + other.effects == effects; + } + + @override + int get hashCode { + return Object.hash( + colorScheme, + textTheme, + effects, + ); + } +} diff --git a/sample_app/lib/theme/config/theme_config.dart b/sample_app/lib/theme/config/theme_config.dart new file mode 100644 index 00000000..29e3c20c --- /dev/null +++ b/sample_app/lib/theme/config/theme_config.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../app_theme.dart'; + +/// Configuration for the Stream Feeds sample app themes. +/// +/// Provides convenient methods to create complete [ThemeData] instances +/// with custom app theme extensions. +class ThemeConfig { + const ThemeConfig._(); + + /// Creates light [ThemeData] with app theme extension. + static ThemeData get lightTheme => fromBrightness(Brightness.light); + + /// Creates dark [ThemeData] with app theme extension. + static ThemeData get darkTheme => fromBrightness(Brightness.dark); + + /// Creates a [ThemeData] for the given [brightness]. + /// + /// This method automatically configures both Flutter's built-in theming + /// and the custom app theme extension based on the brightness. + static ThemeData fromBrightness(Brightness brightness) { + final appTheme = AppTheme.fromBrightness(brightness); + + final theme = ThemeData( + brightness: brightness, + useMaterial3: true, + + // Use app theme primary color for Flutter's built-in ColorScheme + colorScheme: ColorScheme.fromSeed( + seedColor: appTheme.colorScheme.accentPrimary, + brightness: brightness, + ), + + // Custom app theme extension - this is what we'll use throughout the app + extensions: [appTheme], + ); + + return theme.copyWith( + // Apply Google Fonts across the entire app + textTheme: GoogleFonts.openSansTextTheme(theme.textTheme), + ); + } +} diff --git a/sample_app/lib/theme/extensions/theme_extensions.dart b/sample_app/lib/theme/extensions/theme_extensions.dart new file mode 100644 index 00000000..53706465 --- /dev/null +++ b/sample_app/lib/theme/extensions/theme_extensions.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +import '../app_theme.dart'; +import '../schemes/app_color_scheme.dart'; +import '../schemes/app_effects.dart'; +import '../schemes/app_text_theme.dart'; + +/// Extension on [BuildContext] for convenient theme access. +extension BuildContextAppTheme on BuildContext { + /// Gets the [AppTheme] from the current context. + /// + /// Returns a fallback theme based on the current brightness if [AppTheme] + /// is not found in the theme extensions. This ensures the app never crashes + /// due to missing theme configuration. + AppTheme get appTheme { + final theme = Theme.of(this).extension(); + if (theme != null) return theme; + + // Fallback: create theme based on current brightness + final brightness = Theme.of(this).brightness; + + // Debug warning in debug mode + assert(() { + debugPrint( + 'WARNING: AppTheme extension not found in ThemeData. ' + 'Using fallback theme with brightness: $brightness. ' + 'Make sure to add AppTheme to your ThemeData.extensions.', + ); + return true; + }()); + + return AppTheme.fromBrightness(brightness); + } + + /// Gets the [AppColorScheme] from the current context. + /// + /// Convenience method for accessing `appTheme.colorScheme`. + /// Always returns a valid color scheme, even if the app theme isn't configured. + AppColorScheme get appColors => appTheme.colorScheme; + + /// Gets the [AppTextTheme] from the current context. + /// + /// Convenience method for accessing `appTheme.textTheme`. + /// Always returns a valid text theme, even if the app theme isn't configured. + AppTextTheme get appTextStyles => appTheme.textTheme; + + /// Gets the [AppEffects] from the current context. + /// + /// Convenience method for accessing `appTheme.effects`. + /// Always returns valid effects, even if the app theme isn't configured. + AppEffects get appEffects => appTheme.effects; +} diff --git a/sample_app/lib/theme/schemes/app_color_scheme.dart b/sample_app/lib/theme/schemes/app_color_scheme.dart new file mode 100644 index 00000000..4ecaefa2 --- /dev/null +++ b/sample_app/lib/theme/schemes/app_color_scheme.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; + +import '../tokens/color_tokens.dart'; + +/// {@template app_color_scheme} +/// Color scheme for the Stream Feeds sample app. +/// +/// Uses [AppColorTokens] for consistent color values across themes. +/// {@endtemplate} +@immutable +class AppColorScheme { + /// Creates an [AppColorScheme]. + const AppColorScheme({ + required this.textHighEmphasis, + required this.textLowEmphasis, + required this.disabled, + required this.borders, + required this.inputBg, + required this.appBg, + required this.barsBg, + required this.linkBg, + required this.accentPrimary, + required this.accentError, + required this.accentInfo, + required this.highlight, + required this.overlay, + required this.overlayDark, + required this.bgGradient, + }); + + /// Creates a light color scheme using design tokens. + factory AppColorScheme.light() { + return const AppColorScheme( + textHighEmphasis: AppColorTokens.black, + textLowEmphasis: AppColorTokens.gray500, + disabled: AppColorTokens.gray200, + borders: AppColorTokens.gray100, + inputBg: AppColorTokens.gray300, + appBg: AppColorTokens.gray50, + barsBg: AppColorTokens.white, + linkBg: AppColorTokens.blue50, + accentPrimary: AppColorTokens.blue500, + accentError: AppColorTokens.red500, + accentInfo: AppColorTokens.green500, + highlight: AppColorTokens.yellow100, + overlay: AppColorTokens.blackAlpha20, + overlayDark: AppColorTokens.blackAlpha60, + bgGradient: AppColorTokens.lightGradient, + ); + } + + /// Creates a dark color scheme using design tokens. + factory AppColorScheme.dark() { + return const AppColorScheme( + textHighEmphasis: AppColorTokens.white, + textLowEmphasis: AppColorTokens.gray500, + disabled: AppColorTokens.gray700, + borders: AppColorTokens.gray800, + inputBg: AppColorTokens.gray900, + appBg: AppColorTokens.black, + barsBg: AppColorTokens.gray850, + linkBg: AppColorTokens.blue900, + accentPrimary: AppColorTokens.blue600, + accentError: AppColorTokens.red600, + accentInfo: AppColorTokens.green500, + highlight: AppColorTokens.yellow800, + overlay: AppColorTokens.blackAlpha40, + overlayDark: AppColorTokens.whiteAlpha60, + bgGradient: AppColorTokens.darkGradient, + ); + } + + /// Creates a color scheme for the given [brightness] using design tokens. + /// + /// This is the recommended way to create color schemes as it automatically + /// selects the appropriate light or dark variant based on brightness. + factory AppColorScheme.fromBrightness(Brightness brightness) { + return switch (brightness) { + Brightness.light => AppColorScheme.light(), + Brightness.dark => AppColorScheme.dark(), + }; + } + + /// High emphasis text color. + final Color textHighEmphasis; + + /// Low emphasis text color. + final Color textLowEmphasis; + + /// Disabled element color. + final Color disabled; + + /// Border color. + final Color borders; + + /// Input background color. + final Color inputBg; + + /// App background color. + final Color appBg; + + /// Bars background color. + final Color barsBg; + + /// Link background color. + final Color linkBg; + + /// Primary accent color. + final Color accentPrimary; + + /// Error accent color. + final Color accentError; + + /// Info accent color. + final Color accentInfo; + + /// Highlight color. + final Color highlight; + + /// Overlay color. + final Color overlay; + + /// Dark overlay color. + final Color overlayDark; + + /// Background gradient. + final Gradient bgGradient; + + /// Creates a copy with the given fields replaced by new values. + AppColorScheme copyWith({ + Color? textHighEmphasis, + Color? textLowEmphasis, + Color? disabled, + Color? borders, + Color? inputBg, + Color? appBg, + Color? barsBg, + Color? linkBg, + Color? accentPrimary, + Color? accentError, + Color? accentInfo, + Color? highlight, + Color? overlay, + Color? overlayDark, + Gradient? bgGradient, + }) { + return AppColorScheme( + textHighEmphasis: textHighEmphasis ?? this.textHighEmphasis, + textLowEmphasis: textLowEmphasis ?? this.textLowEmphasis, + disabled: disabled ?? this.disabled, + borders: borders ?? this.borders, + inputBg: inputBg ?? this.inputBg, + appBg: appBg ?? this.appBg, + barsBg: barsBg ?? this.barsBg, + linkBg: linkBg ?? this.linkBg, + accentPrimary: accentPrimary ?? this.accentPrimary, + accentError: accentError ?? this.accentError, + accentInfo: accentInfo ?? this.accentInfo, + highlight: highlight ?? this.highlight, + overlay: overlay ?? this.overlay, + overlayDark: overlayDark ?? this.overlayDark, + bgGradient: bgGradient ?? this.bgGradient, + ); + } + + /// Linearly interpolates between two [AppColorScheme] instances. + static AppColorScheme lerp(AppColorScheme a, AppColorScheme b, double t) { + return AppColorScheme( + textHighEmphasis: Color.lerp(a.textHighEmphasis, b.textHighEmphasis, t)!, + textLowEmphasis: Color.lerp(a.textLowEmphasis, b.textLowEmphasis, t)!, + disabled: Color.lerp(a.disabled, b.disabled, t)!, + borders: Color.lerp(a.borders, b.borders, t)!, + inputBg: Color.lerp(a.inputBg, b.inputBg, t)!, + appBg: Color.lerp(a.appBg, b.appBg, t)!, + barsBg: Color.lerp(a.barsBg, b.barsBg, t)!, + linkBg: Color.lerp(a.linkBg, b.linkBg, t)!, + accentPrimary: Color.lerp(a.accentPrimary, b.accentPrimary, t)!, + accentError: Color.lerp(a.accentError, b.accentError, t)!, + accentInfo: Color.lerp(a.accentInfo, b.accentInfo, t)!, + highlight: Color.lerp(a.highlight, b.highlight, t)!, + overlay: Color.lerp(a.overlay, b.overlay, t)!, + overlayDark: Color.lerp(a.overlayDark, b.overlayDark, t)!, + bgGradient: Gradient.lerp(a.bgGradient, b.bgGradient, t)!, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is AppColorScheme && + other.textHighEmphasis == textHighEmphasis && + other.textLowEmphasis == textLowEmphasis && + other.disabled == disabled && + other.borders == borders && + other.inputBg == inputBg && + other.appBg == appBg && + other.barsBg == barsBg && + other.linkBg == linkBg && + other.accentPrimary == accentPrimary && + other.accentError == accentError && + other.accentInfo == accentInfo && + other.highlight == highlight && + other.overlay == overlay && + other.overlayDark == overlayDark && + other.bgGradient == bgGradient; + } + + @override + int get hashCode { + return Object.hash( + textHighEmphasis, + textLowEmphasis, + disabled, + borders, + inputBg, + appBg, + barsBg, + linkBg, + accentPrimary, + accentError, + accentInfo, + highlight, + overlay, + overlayDark, + bgGradient, + ); + } +} diff --git a/sample_app/lib/theme/schemes/app_effects.dart b/sample_app/lib/theme/schemes/app_effects.dart new file mode 100644 index 00000000..5e68fdcc --- /dev/null +++ b/sample_app/lib/theme/schemes/app_effects.dart @@ -0,0 +1,123 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'package:flutter/material.dart'; + +import '../tokens/color_tokens.dart'; + +/// {@template app_effects} +/// Effects theme for the Stream Feeds sample app. +/// +/// Contains shadow and border effects using design tokens for consistency. +/// {@endtemplate} +@immutable +class AppEffects { + /// Creates an [AppEffects]. + const AppEffects({ + required this.borderTop, + required this.borderBottom, + required this.shadowIconButton, + required this.modalShadow, + }); + + /// Creates light effects. + factory AppEffects.light() { + return AppEffects.fromBrightness(Brightness.light); + } + + /// Creates dark effects. + factory AppEffects.dark() { + return AppEffects.fromBrightness(Brightness.dark); + } + + /// Creates effects for the given [brightness]. + /// + /// This is the recommended way to create effects as it automatically + /// selects the appropriate colors based on brightness. + factory AppEffects.fromBrightness(Brightness brightness) { + final borderColor = switch (brightness) { + Brightness.light => AppColorTokens.blackAlpha80, + Brightness.dark => AppColorTokens.blue950, + }; + + return AppEffects( + borderTop: BoxShadow( + offset: const Offset(0, -1), + color: borderColor, + blurRadius: 0, + ), + borderBottom: BoxShadow( + offset: const Offset(0, 1), + color: borderColor, + blurRadius: 0, + ), + shadowIconButton: const BoxShadow( + offset: Offset(0, 2), + color: AppColorTokens.whiteAlpha50, + blurRadius: 4, + ), + modalShadow: const BoxShadow( + offset: Offset.zero, + color: AppColorTokens.blackAlpha100, + blurRadius: 8, + ), + ); + } + + /// Top border shadow effect. + final BoxShadow borderTop; + + /// Bottom border shadow effect. + final BoxShadow borderBottom; + + /// Icon button shadow effect. + final BoxShadow shadowIconButton; + + /// Modal shadow effect. + final BoxShadow modalShadow; + + /// Creates a copy with the given fields replaced by new values. + AppEffects copyWith({ + BoxShadow? borderTop, + BoxShadow? borderBottom, + BoxShadow? shadowIconButton, + BoxShadow? modalShadow, + }) { + return AppEffects( + borderTop: borderTop ?? this.borderTop, + borderBottom: borderBottom ?? this.borderBottom, + shadowIconButton: shadowIconButton ?? this.shadowIconButton, + modalShadow: modalShadow ?? this.modalShadow, + ); + } + + /// Linearly interpolates between two [AppEffects] instances. + static AppEffects lerp(AppEffects a, AppEffects b, double t) { + return AppEffects( + borderTop: BoxShadow.lerp(a.borderTop, b.borderTop, t)!, + borderBottom: BoxShadow.lerp(a.borderBottom, b.borderBottom, t)!, + shadowIconButton: + BoxShadow.lerp(a.shadowIconButton, b.shadowIconButton, t)!, + modalShadow: BoxShadow.lerp(a.modalShadow, b.modalShadow, t)!, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is AppEffects && + other.borderTop == borderTop && + other.borderBottom == borderBottom && + other.shadowIconButton == shadowIconButton && + other.modalShadow == modalShadow; + } + + @override + int get hashCode { + return Object.hash( + borderTop, + borderBottom, + shadowIconButton, + modalShadow, + ); + } +} diff --git a/sample_app/lib/theme/schemes/app_text_theme.dart b/sample_app/lib/theme/schemes/app_text_theme.dart new file mode 100644 index 00000000..5ba30959 --- /dev/null +++ b/sample_app/lib/theme/schemes/app_text_theme.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; + +import '../tokens/color_tokens.dart'; + +/// {@template app_text_theme} +/// Text theme for the Stream Feeds sample app. +/// +/// Uses consistent typography tokens for size and weight. +/// {@endtemplate} +@immutable +class AppTextTheme { + /// Creates an [AppTextTheme]. + const AppTextTheme({ + required this.title, + required this.headline, + required this.headlineBold, + required this.body, + required this.bodyBold, + required this.footnote, + required this.footnoteBold, + required this.captionBold, + }); + + /// Creates a light text theme. + factory AppTextTheme.light() { + return AppTextTheme.fromBrightness(Brightness.light); + } + + /// Creates a dark text theme. + factory AppTextTheme.dark() { + return AppTextTheme.fromBrightness(Brightness.dark); + } + + /// Creates a text theme for the given [brightness]. + /// + /// This is the recommended way to create text themes as it automatically + /// selects the appropriate text colors based on brightness. + factory AppTextTheme.fromBrightness(Brightness brightness) { + final textColor = switch (brightness) { + Brightness.light => AppColorTokens.black, + Brightness.dark => AppColorTokens.white, + }; + + return AppTextTheme( + title: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w500, + color: textColor, + ), + headline: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + color: textColor, + ), + headlineBold: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: textColor, + ), + body: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: textColor, + ), + bodyBold: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: textColor, + ), + footnote: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, + color: textColor, + ), + footnoteBold: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: textColor, + ), + captionBold: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: textColor, + ), + ); + } + + /// Title text style. + final TextStyle title; + + /// Headline text style. + final TextStyle headline; + + /// Bold headline text style. + final TextStyle headlineBold; + + /// Body text style. + final TextStyle body; + + /// Bold body text style. + final TextStyle bodyBold; + + /// Footnote text style. + final TextStyle footnote; + + /// Bold footnote text style. + final TextStyle footnoteBold; + + /// Bold caption text style. + final TextStyle captionBold; + + /// Creates a copy with the given fields replaced by new values. + AppTextTheme copyWith({ + TextStyle? title, + TextStyle? headline, + TextStyle? headlineBold, + TextStyle? body, + TextStyle? bodyBold, + TextStyle? footnote, + TextStyle? footnoteBold, + TextStyle? captionBold, + }) { + return AppTextTheme( + title: title ?? this.title, + headline: headline ?? this.headline, + headlineBold: headlineBold ?? this.headlineBold, + body: body ?? this.body, + bodyBold: bodyBold ?? this.bodyBold, + footnote: footnote ?? this.footnote, + footnoteBold: footnoteBold ?? this.footnoteBold, + captionBold: captionBold ?? this.captionBold, + ); + } + + /// Linearly interpolates between two [AppTextTheme] instances. + static AppTextTheme lerp(AppTextTheme a, AppTextTheme b, double t) { + return AppTextTheme( + title: TextStyle.lerp(a.title, b.title, t)!, + headline: TextStyle.lerp(a.headline, b.headline, t)!, + headlineBold: TextStyle.lerp(a.headlineBold, b.headlineBold, t)!, + body: TextStyle.lerp(a.body, b.body, t)!, + bodyBold: TextStyle.lerp(a.bodyBold, b.bodyBold, t)!, + footnote: TextStyle.lerp(a.footnote, b.footnote, t)!, + footnoteBold: TextStyle.lerp(a.footnoteBold, b.footnoteBold, t)!, + captionBold: TextStyle.lerp(a.captionBold, b.captionBold, t)!, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is AppTextTheme && + other.title == title && + other.headline == headline && + other.headlineBold == headlineBold && + other.body == body && + other.bodyBold == bodyBold && + other.footnote == footnote && + other.footnoteBold == footnoteBold && + other.captionBold == captionBold; + } + + @override + int get hashCode { + return Object.hash( + title, + headline, + headlineBold, + body, + bodyBold, + footnote, + footnoteBold, + captionBold, + ); + } +} diff --git a/sample_app/lib/theme/theme.dart b/sample_app/lib/theme/theme.dart new file mode 100644 index 00000000..742694a5 --- /dev/null +++ b/sample_app/lib/theme/theme.dart @@ -0,0 +1,91 @@ +/// Stream Feeds sample app theme system. +/// +/// A comprehensive design system with organized directory structure, +/// color tokens, semantic themes, and Flutter ThemeExtension integration. +/// +/// ## Directory Structure +/// +/// ``` +/// theme/ +/// ├── tokens/ # Design tokens (raw values) +/// ├── schemes/ # Semantic theme definitions +/// ├── config/ # Theme configuration +/// ├── extensions/ # Convenience utilities +/// └── app_theme.dart # Main theme extension +/// ``` +/// +/// ## Architecture +/// +/// The theme system follows a **Design Token → Semantic → Component** approach: +/// 1. **Color Tokens** (`tokens/`): Raw color palette +/// 2. **Semantic Schemes** (`schemes/`): Token-based themes +/// 3. **Theme Config** (`config/`): Complete ThemeData setup +/// 4. **Extensions** (`extensions/`): Convenient access methods +/// +/// ## Usage +/// +/// ### Basic Setup +/// ```dart +/// MaterialApp( +/// theme: ThemeConfig.lightTheme, +/// darkTheme: ThemeConfig.darkTheme, +/// themeMode: ThemeMode.system, +/// ); +/// ``` +/// +/// ### Advanced Setup (Recommended) +/// ```dart +/// MaterialApp( +/// theme: ThemeConfig.fromBrightness(Brightness.light), +/// darkTheme: ThemeConfig.fromBrightness(Brightness.dark), +/// ); +/// ``` +/// +/// ### Using App Theme Throughout (Recommended) +/// ```dart +/// Widget build(BuildContext context) { +/// return Scaffold( +/// backgroundColor: context.appColors.appBg, +/// appBar: AppBar( +/// backgroundColor: context.appColors.barsBg, +/// title: Text( +/// 'My App', +/// style: context.appTextStyles.title.copyWith( +/// color: context.appColors.textHighEmphasis, +/// ), +/// ), +/// ), +/// body: Container( +/// decoration: BoxDecoration( +/// gradient: context.appColors.bgGradient, +/// ), +/// child: Text( +/// 'Content', +/// style: context.appTextStyles.body.copyWith( +/// color: context.appColors.textHighEmphasis, +/// ), +/// ), +/// ), +/// ); +/// } +/// ``` +/// +/// This approach ensures you always know exactly what colors and text styles +/// you're using throughout your app, providing full control and consistency. +/// +/// ### Using Color Tokens Directly +/// ```dart +/// Container( +/// color: AppColorTokens.blue500, +/// child: Text('Direct token usage'), +/// ); +/// ``` +library; + +export 'app_theme.dart'; +export 'config/theme_config.dart'; +export 'extensions/theme_extensions.dart'; +export 'schemes/app_color_scheme.dart'; +export 'schemes/app_effects.dart'; +export 'schemes/app_text_theme.dart'; +export 'tokens/color_tokens.dart'; diff --git a/sample_app/lib/theme/tokens/color_tokens.dart b/sample_app/lib/theme/tokens/color_tokens.dart new file mode 100644 index 00000000..e97a3fb8 --- /dev/null +++ b/sample_app/lib/theme/tokens/color_tokens.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +/// {@template color_tokens} +/// Design system color tokens for the Stream Feeds sample app. +/// +/// Contains the raw color palette that serves as the foundation for all theme colors. +/// Colors are organized by scale and purpose for consistent usage across light and dark themes. +/// {@endtemplate} +abstract class AppColorTokens { + // region Blue Scale + /// Blue color scale for primary brand colors + static const blue50 = Color(0xffe9f2ff); + static const blue500 = Color(0xff005FFF); + static const blue600 = Color(0xff337eff); + // endregion + + // region Gray Scale + /// Gray color scale for neutral colors + static const gray50 = Color(0xfff7f7f8); + static const gray100 = Color(0xffecebeb); + static const gray200 = Color(0xffdbdbdb); + static const gray300 = Color(0xffe9eaed); + static const gray500 = Color(0xff7a7a7a); + static const gray700 = Color(0xff2d2f2f); + static const gray800 = Color(0xff1c1e22); + static const gray850 = Color(0xff121416); + static const gray900 = Color(0xff13151b); + static const gray950 = Color(0xff000000); + // endregion + + // region Semantic Colors + /// Red color scale for error states + static const red500 = Color(0xffFF3842); + static const red600 = Color(0xffFF3742); + + /// Green color scale for success states + static const green500 = Color(0xff20E070); + + /// Special colors for highlights and overlays + static const yellow100 = Color(0xfffbf4dd); + static const yellow800 = Color(0xff302d22); + + /// Blue dark variant for dark theme links + static const blue900 = Color(0xff00193D); + static const blue950 = Color(0xff141924); + // endregion + + // region Pure Colors + /// Pure black and white + static const white = Color(0xffffffff); + static const black = Color(0xff000000); + // endregion + + // region Gradients + /// Light theme background gradient + static const lightGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xfff7f7f7), Color(0xfffcfcfc)], + stops: [0, 1], + ); + + /// Dark theme background gradient + static const darkGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xff101214), Color(0xff070a0d)], + stops: [0, 1], + ); + // endregion + + // region Alpha Colors + /// Overlay colors with alpha + static const blackAlpha20 = Color.fromRGBO(0, 0, 0, 0.2); + static const blackAlpha40 = Color.fromRGBO(0, 0, 0, 0.4); + static const blackAlpha60 = Color.fromRGBO(0, 0, 0, 0.6); + static const blackAlpha80 = Color.fromRGBO(0, 0, 0, 0.08); + static const blackAlpha100 = Color.fromRGBO(0, 0, 0, 1); + + static const whiteAlpha50 = Color.fromRGBO(255, 255, 255, 0.5); + static const whiteAlpha60 = Color.fromRGBO(255, 255, 255, 0.6); + // endregion +} diff --git a/sample_app/lib/utils/date_time_extensions.dart b/sample_app/lib/utils/date_time_extensions.dart new file mode 100644 index 00000000..c76e7d5f --- /dev/null +++ b/sample_app/lib/utils/date_time_extensions.dart @@ -0,0 +1,5 @@ +import 'package:jiffy/jiffy.dart'; + +extension DateTimeExtensions on DateTime { + String get displayRelativeTime => Jiffy.parseFromDateTime(this).fromNow(); +} diff --git a/sample_app/lib/widgets/app_failure.dart b/sample_app/lib/widgets/app_failure.dart new file mode 100644 index 00000000..26b33385 --- /dev/null +++ b/sample_app/lib/widgets/app_failure.dart @@ -0,0 +1,302 @@ +import 'package:flutter/material.dart'; + +import '../theme/theme.dart'; + +/// App failure screen widget. +/// +/// Displays detailed error information with stack trace in a beautiful, +/// developer-friendly format while maintaining clean design principles. +class AppFailure extends StatefulWidget { + const AppFailure({ + super.key, + required this.error, + this.stackTrace, + }); + + /// The error object that caused the failure. + final Object error; + + /// Optional stack trace for debugging. + final StackTrace? stackTrace; + + @override + State createState() => _AppFailureState(); +} + +class _AppFailureState extends State { + bool _showDetails = false; + + String get _errorMessage { + return widget.error.toString().replaceFirst('Exception: ', ''); + } + + String get _errorType { + return widget.error.runtimeType.toString(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: context.appColors.appBg, + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 60), + + // Error icon with enhanced styling + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: + context.appColors.accentError.withValues(alpha: 0.08), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: context.appColors.accentError + .withValues(alpha: 0.2), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Icon( + Icons.warning_rounded, + size: 50, + color: context.appColors.accentError, + ), + ), + + const SizedBox(height: 32), + + // Error title with better typography + Text( + 'Application Error', + textAlign: TextAlign.center, + style: context.appTextStyles.title.copyWith( + color: context.appColors.textHighEmphasis, + fontSize: 28, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + + const SizedBox(height: 8), + + // Subtitle for context + Text( + 'The app failed to initialize properly', + textAlign: TextAlign.center, + style: context.appTextStyles.body.copyWith( + color: context.appColors.textLowEmphasis, + fontSize: 16, + ), + ), + + const SizedBox(height: 24), + + // Error type chip with improved styling + Container( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: + context.appColors.accentError.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: context.appColors.accentError + .withValues(alpha: 0.2), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.code, + size: 16, + color: context.appColors.accentError, + ), + const SizedBox(width: 8), + Text( + _errorType, + style: context.appTextStyles.footnote.copyWith( + color: context.appColors.accentError, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Error message with enhanced card + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: context.appColors.barsBg, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: context.appColors.borders, + ), + boxShadow: [ + context.appEffects.shadowIconButton, + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.error_outline, + size: 18, + color: context.appColors.textLowEmphasis, + ), + const SizedBox(width: 8), + Text( + 'Error Details', + style: context.appTextStyles.bodyBold.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + _errorMessage, + style: context.appTextStyles.body.copyWith( + color: context.appColors.textHighEmphasis, + fontSize: 15, + height: 1.4, + ), + ), + ], + ), + ), + + const SizedBox(height: 20), + + // Enhanced toggle details button + if (widget.stackTrace != null) + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => + setState(() => _showDetails = !_showDetails), + style: OutlinedButton.styleFrom( + foregroundColor: context.appColors.accentPrimary, + side: BorderSide( + color: context.appColors.accentPrimary + .withValues(alpha: 0.3), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + icon: Icon( + _showDetails ? Icons.expand_less : Icons.expand_more, + size: 20, + ), + label: Text( + _showDetails + ? 'Hide Technical Details' + : 'Show Technical Details', + style: context.appTextStyles.bodyBold.copyWith( + color: context.appColors.accentPrimary, + ), + ), + ), + ), + + // Enhanced stack trace with better formatting + if (_showDetails && widget.stackTrace != null) ...[ + const SizedBox(height: 20), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: context.appColors.inputBg, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: context.appColors.borders, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.appColors.borders + .withValues(alpha: 0.3), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(15), + topRight: Radius.circular(15), + ), + ), + child: Row( + children: [ + Icon( + Icons.terminal, + size: 18, + color: context.appColors.textLowEmphasis, + ), + const SizedBox(width: 8), + Text( + 'Stack Trace', + style: + context.appTextStyles.bodyBold.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + const Spacer(), + Icon( + Icons.code, + size: 16, + color: context.appColors.textLowEmphasis, + ), + ], + ), + ), + // Content + Container( + width: double.infinity, + constraints: const BoxConstraints(maxHeight: 250), + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: SelectableText( + widget.stackTrace.toString(), + style: context.appTextStyles.footnote.copyWith( + color: context.appColors.textHighEmphasis, + fontFamily: 'monospace', + fontSize: 11, + height: 1.3, + ), + ), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 60), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/sample_app/lib/widgets/app_splash.dart b/sample_app/lib/widgets/app_splash.dart new file mode 100644 index 00000000..afb4b53e --- /dev/null +++ b/sample_app/lib/widgets/app_splash.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../theme/theme.dart'; + +/// App splash screen widget. +/// +/// Displays only the app logo in a clean, minimal style while the app initializes. +/// Follows true minimalistic principles with perfect simplicity. +class AppSplash extends StatelessWidget { + const AppSplash({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: context.appColors.appBg, + body: Center( + child: SvgPicture.asset( + 'assets/images/app_logo.svg', + width: 64, + height: 64, + colorFilter: ColorFilter.mode( + context.appColors.accentPrimary, + BlendMode.srcIn, + ), + ), + ), + ); + } +} diff --git a/sample_app/lib/widgets/theme.dart b/sample_app/lib/widgets/theme.dart deleted file mode 100644 index 00b444c6..00000000 --- a/sample_app/lib/widgets/theme.dart +++ /dev/null @@ -1,15 +0,0 @@ -// ignore_for_file: avoid_classes_with_only_static_members - -import 'package:flutter/material.dart'; - -class FeedsSampleThemeData { - static final light = ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.lightBlue), - dividerTheme: DividerThemeData( - color: Colors.grey.shade300, - ), - listTileTheme: ListTileThemeData( - subtitleTextStyle: TextStyle(color: Colors.grey.shade600), - ), - ); -} diff --git a/sample_app/lib/widgets/user_avatar.dart b/sample_app/lib/widgets/user_avatar.dart new file mode 100644 index 00000000..39c21540 --- /dev/null +++ b/sample_app/lib/widgets/user_avatar.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../theme/extensions/theme_extensions.dart'; + +class UserAvatar extends StatelessWidget { + const UserAvatar({ + super.key, + required this.user, + this.radius = 20, + this.backgroundColor, + }); + + /// Creates a small user avatar (24×24 pixels). + const UserAvatar.small({ + super.key, + required this.user, + this.backgroundColor, + }) : radius = 12; + + /// Creates a medium user avatar for AppBar (36×36 pixels). + const UserAvatar.appBar({ + super.key, + required this.user, + this.backgroundColor, + }) : radius = 18; + + /// Creates a standard user avatar for ListTile (40×40 pixels). + const UserAvatar.listTile({ + super.key, + required this.user, + this.backgroundColor, + }) : radius = 20; + + /// Creates a large user avatar (56×56 pixels). + const UserAvatar.large({ + super.key, + required this.user, + this.backgroundColor, + }) : radius = 28; + + /// Creates an extra large user avatar (80×80 pixels). + const UserAvatar.extraLarge({ + super.key, + required this.user, + this.backgroundColor, + }) : radius = 40; + + final User user; + final double radius; + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + return CircleAvatar( + radius: radius, + backgroundColor: backgroundColor ?? context.appColors.textLowEmphasis, + backgroundImage: switch (user.image) { + final image? => NetworkImage(image), + _ => null, + }, + child: switch (user.image) { + null => BackupGradientAvatar(username: user.name), + _ => null, + }, + ); + } +} + +class BackupGradientAvatar extends StatelessWidget { + const BackupGradientAvatar({ + super.key, + required this.username, + }); + + final String username; + + @override + Widget build(BuildContext context) { + final initials = switch (username.trim().split(' ')) { + [final part] => part[0], + [final first, final second, ...] => '${first[0]} ${second[0]}', + _ => '', + }; + + return Text(initials); + } +} diff --git a/sample_app/pubspec.yaml b/sample_app/pubspec.yaml index bde81518..31a2e568 100644 --- a/sample_app/pubspec.yaml +++ b/sample_app/pubspec.yaml @@ -10,10 +10,16 @@ environment: dependencies: auto_route: ^10.0.0 cached_network_image: ^3.4.1 + collection: ^1.18.0 + flex_color_scheme: ^8.1.1 flutter: sdk: flutter flutter_state_notifier: ^1.0.0 + flutter_svg: ^2.2.0 get_it: ^8.0.3 + google_fonts: ^6.3.0 + injectable: ^2.5.1 + jiffy: ^6.4.3 shared_preferences: ^2.5.3 stream_feeds: path: ../packages/stream_feeds @@ -23,6 +29,10 @@ dev_dependencies: build_runner: ^2.4.15 flutter_test: sdk: flutter + injectable_generator: ^2.7.0 flutter: uses-material-design: true + + assets: + - assets/images/ \ No newline at end of file