├── .gitignore ├── .metadata ├── README.md ├── analysis_options.yaml ├── lib ├── app │ ├── app.dart │ ├── navigation │ │ ├── custom_rect_tween.dart │ │ ├── hero_dialog_route.dart │ │ └── navigation.dart │ ├── state │ │ ├── app_state.dart │ │ ├── demo_users.dart │ │ ├── models │ │ │ ├── models.dart │ │ │ └── user.dart │ │ └── state.dart │ ├── stream_agram.dart │ ├── theme.dart │ └── utils.dart ├── components │ ├── app_widgets │ │ ├── app_widgets.dart │ │ ├── avatars.dart │ │ ├── comment_box.dart │ │ ├── favorite_icon.dart │ │ └── tap_fade_icon.dart │ ├── comments │ │ ├── comments.dart │ │ ├── comments_screen.dart │ │ └── state │ │ │ ├── comment_state.dart │ │ │ └── state.dart │ ├── home │ │ ├── home.dart │ │ └── home_screen.dart │ ├── login │ │ ├── login.dart │ │ └── login_screen.dart │ ├── new_post │ │ ├── new_post.dart │ │ └── new_post_screen.dart │ ├── profile │ │ ├── edit_profile_screen.dart │ │ ├── profile.dart │ │ └── profile_page.dart │ ├── search │ │ ├── search.dart │ │ └── search_page.dart │ └── timeline │ │ ├── timeline.dart │ │ ├── timeline_page.dart │ │ └── widgets │ │ ├── post_card.dart │ │ └── widgets.dart └── main.dart ├── previews └── preview.png ├── pubspec.lock └── pubspec.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | /android 2 | /ios 3 | /windows 4 | /linux 5 | /macos 6 | /web 7 | 8 | # Miscellaneous 9 | *.class 10 | *.log 11 | *.pyc 12 | *.swp 13 | .DS_Store 14 | .atom/ 15 | .buildlog/ 16 | .history 17 | .svn/ 18 | 19 | # IntelliJ related 20 | *.iml 21 | *.ipr 22 | *.iws 23 | .idea/ 24 | 25 | # The .vscode folder contains launch configuration and tasks you configure in 26 | # VS Code which you may wish to be included in version control, so this line 27 | # is commented out by default. 28 | #.vscode/ 29 | 30 | # Flutter/Dart/Pub related 31 | **/doc/api/ 32 | **/ios/Flutter/.last_build_id 33 | .dart_tool/ 34 | .flutter-plugins 35 | .flutter-plugins-dependencies 36 | .packages 37 | .pub-cache/ 38 | .pub/ 39 | /build/ 40 | 41 | # Web related 42 | lib/generated_plugin_registrant.dart 43 | 44 | # Symbolication related 45 | app.*.symbols 46 | 47 | # Obfuscation related 48 | app.*.map.json 49 | 50 | # Android Studio will place build artifacts here 51 | /android/app/debug 52 | /android/app/profile 53 | /android/app/release 54 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled. 5 | 6 | version: 7 | revision: ffccd96b62ee8cec7740dab303538c5fc26ac543 8 | channel: stable 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: ffccd96b62ee8cec7740dab303538c5fc26ac543 17 | base_revision: ffccd96b62ee8cec7740dab303538c5fc26ac543 18 | - platform: android 19 | create_revision: ffccd96b62ee8cec7740dab303538c5fc26ac543 20 | base_revision: ffccd96b62ee8cec7740dab303538c5fc26ac543 21 | - platform: ios 22 | create_revision: ffccd96b62ee8cec7740dab303538c5fc26ac543 23 | base_revision: ffccd96b62ee8cec7740dab303538c5fc26ac543 24 | - platform: linux 25 | create_revision: ffccd96b62ee8cec7740dab303538c5fc26ac543 26 | base_revision: ffccd96b62ee8cec7740dab303538c5fc26ac543 27 | - platform: macos 28 | create_revision: ffccd96b62ee8cec7740dab303538c5fc26ac543 29 | base_revision: ffccd96b62ee8cec7740dab303538c5fc26ac543 30 | - platform: web 31 | create_revision: ffccd96b62ee8cec7740dab303538c5fc26ac543 32 | base_revision: ffccd96b62ee8cec7740dab303538c5fc26ac543 33 | - platform: windows 34 | create_revision: ffccd96b62ee8cec7740dab303538c5fc26ac543 35 | base_revision: ffccd96b62ee8cec7740dab303538c5fc26ac543 36 | 37 | # User provided section 38 | 39 | # List of Local paths (relative to this file) that should be 40 | # ignored by the migrate tool. 41 | # 42 | # Files that are not part of the templates will be ignored by default. 43 | unmanaged_files: 44 | - 'lib/main.dart' 45 | - 'ios/Runner.xcodeproj/project.pbxproj' 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Flutter Instagram Clone (Stream-agram)

2 | 3 |

4 | Pub 5 | style: flutter lints 6 | Flutter Samples 7 | License 8 | Stream Feeds 9 |

10 | 11 |

12 | Stream-agram is a demo application showing how to recreate Instagram using Flutter and Stream Feeds. 13 |
14 | 15 |

16 | 17 |

18 | 19 | ## Tutorials 20 | If you'd like to learn more about this project and how the code is structured, take a look at the companion blog and video. Make sure you're on the [tutorial branch](https://github.com/GetStream/flutter-instagram-clone/tree/tutorial) - this branch is the historic branch that aligns with the tutorial. The `main` branch is the most up to date with the latest code changes and package versions. 21 | 22 | ```bash 23 | git checkout tutorial 24 | ``` 25 | 26 | ### Blog Post 27 | - [Article](https://getstream.io/blog/instagram-clone-flutter/) detailing a step-by-step guide for this project. 28 | 29 | ### YouTube Video 30 | - [Video](https://youtu.be/fHRB6KGoaV0) showing how to create this application from start to finish. The video follows the same steps as the blog post, and we recommend that you use both resources to make the tutorial easy to follow. 31 | 32 | ## Previews 33 |

34 | 35 | 36 | 37 |

38 | 39 | ## Supported features 40 | - Light and Dark themes. 41 | - Instagram-like animations and transitions 42 | - Sign in using different user accounts 43 | - Add and change profile pictures 44 | - Add photo posts to your own user feed (activities) 45 | - Subscribe/unsubscribe to other users’ feeds 46 | - Add comments and likes (reactions) 47 | 48 | 49 | ## Getting Started 50 | These are the steps to run this project locally, with your own Stream Feeds configuration. 51 | 52 | ### Clone This Repository 53 | ``` 54 | git clone https://github.com/GetStream/flutter-instagram-clone 55 | ``` 56 | 57 | The project folder needs to be renamed in order for `flutter create` to work (dashes "-" in the folder name are not allowed). You can do this however you want. For example, from a bash terminal you can do: 58 | ```bash 59 | mv flutter-instagram-clone flutter_instagram_clone 60 | ``` 61 | 62 | ### Create Flutter Platform Folders 63 | Run this inside the main folder to generate platform folders. 64 | ``` 65 | flutter create . 66 | ``` 67 | 68 | ### Setup Image Picker Package 69 | Depending on the platform that you're targetting you will need to do some [setup](https://pub.dev/packages/image_picker#installation) for the [image_picker](https://pub.dev/packages/image_picker) Flutter package. At the time of writing this package only supports **Android**, **iOS** and **Web**. 70 | 71 | ### Add Keys and Tokens 72 | You will also need to add your **Stream API-Key** and **User Frontend Tokens**. These are marked with TODOs in the codebase. For additional information, see the [blog](https://getstream.io/blog/instagram-clone-flutter/#creating-demo-instagram-users) or [video](https://www.youtube.com/watch?v=fHRB6KGoaV0&t=627s) section on creating user tokens. 73 | 74 | 75 | 76 | 77 | ## Stream Feeds Flutter SDK 78 | Stream-agram is built with [Stream Feeds](https://getstream.io/activity-feeds/) for implementing activity feeds. 79 | - [Activity Feeds Tutorial](https://getstream.io/activity-feeds/sdk/flutter/tutorial/) - Basic tutorials for getting started with activity feeds. 80 | - [Stream Feeds Flutter Repository](https://github.com/GetStream/stream-feed-flutter) - Official Flutter SDK for Stream Feeds. 81 | - [Feed Client Documentation](https://getstream.io/activity-feeds/docs/flutter-dart/?language=dart) - Detailed documentation of the Activity Feeds client. 82 | 83 | 84 | 85 | ## Stream Chat Flutter SDK 86 | If you're interested in adding chat functionality to your Instagram clone, check out [Stream Chat](https://getstream.io/chat/). 87 | - [Chat Messaging Tutorial](https://getstream.io/chat/flutter/tutorial/) - Basic tutorials for getting started by building a simple messaging app. 88 | - [Stream Chat Flutter repository](https://github.com/GetStream/stream-chat-flutter) - Official Flutter SDK for Stream Chat. 89 | - [Chat Client Documentation](https://getstream.io/chat/docs/flutter-dart/?language=dart) - Full documentation of the Chat client for requesting API calls. 90 | - [Chat UI Components Documentation and Guides](https://getstream.io/chat/docs/sdk/flutter/) - Full documentation of the Stream UI Components. 91 | - [Sample Application](https://github.com/GetStream/flutter-samples/tree/main/packages/stream_chat_v1) - Official Flutter sample chat application. 92 | 93 | ## Find this repository useful? 💙 94 | Support it by joining __[stargazers](https://github.com/GetStream/flutter-instagram-clone/stargazers)__ :star:
95 | 96 | # License 97 | ```xml 98 | Copyright 2021 Stream.IO, Inc. All Rights Reserved. 99 | 100 | Licensed under the Apache License, Version 2.0 (the "License"); 101 | you may not use this file except in compliance with the License. 102 | You may obtain a copy of the License at 103 | 104 | http://www.apache.org/licenses/LICENSE-2.0 105 | 106 | Unless required by applicable law or agreed to in writing, software 107 | distributed under the License is distributed on an "AS IS" BASIS, 108 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 109 | See the License for the specific language governing permissions and 110 | limitations under the License. 111 | ``` 112 | 113 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at 17 | # https://dart-lang.github.io/linter/lints/index.html. 18 | # 19 | # Instead of disabling a lint rule for the entire project in the 20 | # section below, it can also be suppressed for a single line of code 21 | # or a specific dart file by using the `// ignore: name_of_lint` and 22 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 23 | # producing the lint. 24 | rules: 25 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 26 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 27 | 28 | # Additional information about this file can be found at 29 | # https://dart.dev/guides/language/analysis-options 30 | -------------------------------------------------------------------------------- /lib/app/app.dart: -------------------------------------------------------------------------------- 1 | export 'state/state.dart'; 2 | export 'theme.dart'; 3 | export 'stream_agram.dart'; 4 | export 'utils.dart'; 5 | export 'navigation/navigation.dart'; 6 | -------------------------------------------------------------------------------- /lib/app/navigation/custom_rect_tween.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:flutter/widgets.dart'; 4 | 5 | /// {@template custom_rect_tween} 6 | /// Linear RectTween with a [Curves.easeOut] curve. 7 | /// 8 | /// Less dramatic than the regular [RectTween] used in [Hero] animations. 9 | /// {@endtemplate} 10 | class CustomRectTween extends RectTween { 11 | /// {@macro custom_rect_tween} 12 | CustomRectTween({ 13 | required Rect? begin, 14 | required Rect? end, 15 | }) : super(begin: begin, end: end); 16 | 17 | @override 18 | Rect? lerp(double t) { 19 | final elasticCurveValue = Curves.easeOut.transform(t); 20 | if (begin == null || end == null) return null; 21 | return Rect.fromLTRB( 22 | lerpDouble(begin!.left, end!.left, elasticCurveValue)!, 23 | lerpDouble(begin!.top, end!.top, elasticCurveValue)!, 24 | lerpDouble(begin!.right, end!.right, elasticCurveValue)!, 25 | lerpDouble(begin!.bottom, end!.bottom, elasticCurveValue)!, 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/app/navigation/hero_dialog_route.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// {@template hero_dialog_route} 4 | /// Custom [PageRoute] that creates an overlay dialog (popup effect). 5 | /// 6 | /// Best used with a [Hero] animation. 7 | /// {@endtemplate} 8 | class HeroDialogRoute extends PageRoute { 9 | /// {@macro hero_dialog_route} 10 | HeroDialogRoute({ 11 | required WidgetBuilder builder, 12 | RouteSettings? settings, 13 | bool fullscreenDialog = false, 14 | }) : _builder = builder, 15 | super(settings: settings, fullscreenDialog: fullscreenDialog); 16 | 17 | final WidgetBuilder _builder; 18 | 19 | @override 20 | bool get opaque => false; 21 | 22 | @override 23 | bool get barrierDismissible => true; 24 | 25 | @override 26 | Duration get transitionDuration => const Duration(milliseconds: 300); 27 | 28 | @override 29 | bool get maintainState => true; 30 | 31 | @override 32 | Color get barrierColor => Colors.black54; 33 | 34 | @override 35 | Widget buildTransitions(BuildContext context, Animation animation, 36 | Animation secondaryAnimation, Widget child) { 37 | return FadeTransition(opacity: animation, child: child); 38 | } 39 | 40 | @override 41 | Widget buildPage(BuildContext context, Animation animation, 42 | Animation secondaryAnimation) { 43 | return _builder(context); 44 | } 45 | 46 | @override 47 | String get barrierLabel => 'Hero Dialog Open'; 48 | } 49 | -------------------------------------------------------------------------------- /lib/app/navigation/navigation.dart: -------------------------------------------------------------------------------- 1 | export 'custom_rect_tween.dart'; 2 | export 'hero_dialog_route.dart'; 3 | -------------------------------------------------------------------------------- /lib/app/state/app_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; 3 | import 'models/models.dart'; 4 | 5 | import 'demo_users.dart'; 6 | 7 | /// State related to Stream-agram app. 8 | /// 9 | /// Manages the connection and stores a references to the [StreamFeedClient] 10 | /// and [StreamagramUser]. 11 | /// 12 | /// Provides various convenience methods. 13 | class AppState extends ChangeNotifier { 14 | /// Create new [AppState]. 15 | AppState({ 16 | required StreamFeedClient client, 17 | }) : _client = client; 18 | 19 | late final StreamFeedClient _client; 20 | 21 | /// Stream Feed client. 22 | StreamFeedClient get client => _client; 23 | 24 | /// Stream Feed user - [StreamUser]. 25 | StreamUser get user => _client.currentUser!; 26 | 27 | StreamagramUser? _streamagramUser; 28 | var isUploadingProfilePicture = false; 29 | 30 | /// The extraData from [user], mapped to an [StreamagramUser] object. 31 | StreamagramUser? get streamagramUser => _streamagramUser; 32 | 33 | /// Current user's [FlatFeed] with name 'user'. 34 | /// 35 | /// This feed contains all of a user's personal posts. 36 | FlatFeed get currentUserFeed => _client.flatFeed('user', user.id); 37 | 38 | /// Current user's [FlatFeed] with name 'timeline'. 39 | /// 40 | /// This contains all posts that a user has subscribed (followed) to. 41 | FlatFeed get currentTimelineFeed => _client.flatFeed('timeline', user.id); 42 | 43 | /// Connect to Stream Feed with one of the demo users, using a predefined, 44 | /// hardcoded token. 45 | /// 46 | /// THIS IS ONLY FOR DEMONSTRATIONS PURPOSES. USER TOKENS SHOULD NOT BE 47 | /// HARDCODED LIKE THIS. 48 | Future connect(DemoAppUser demoUser) async { 49 | final currentUser = await _client.setUser( 50 | User(id: demoUser.id), 51 | demoUser.token!, 52 | extraData: demoUser.data, 53 | ); 54 | 55 | if (currentUser.data != null) { 56 | _streamagramUser = StreamagramUser.fromMap(currentUser.data!); 57 | await currentTimelineFeed.follow(currentUserFeed); 58 | notifyListeners(); 59 | return true; 60 | } else { 61 | return false; 62 | } 63 | } 64 | 65 | /// Uploads a new profile picture from the given [filePath]. 66 | /// 67 | /// This will call [notifyListeners] and update the local [_streamagramUser] state. 68 | Future updateProfilePhoto(String filePath) async { 69 | // Upload the original image 70 | isUploadingProfilePicture = true; 71 | notifyListeners(); 72 | 73 | final imageUrl = await client.images.upload(AttachmentFile(path: filePath)); 74 | if (imageUrl == null) { 75 | debugPrint('Could not upload the image. Not setting profile picture'); 76 | isUploadingProfilePicture = false; 77 | notifyListeners(); 78 | return; 79 | } 80 | // Get resized images using the Stream Feed client. 81 | final results = await Future.wait([ 82 | client.images.getResized( 83 | imageUrl, 84 | const Resize(500, 500), 85 | ), 86 | client.images.getResized( 87 | imageUrl, 88 | const Resize(50, 50), 89 | ) 90 | ]); 91 | 92 | // Update the current user data state. 93 | _streamagramUser = _streamagramUser?.copyWith( 94 | profilePhoto: imageUrl, 95 | profilePhotoResized: results[0], 96 | profilePhotoThumbnail: results[1], 97 | ); 98 | 99 | isUploadingProfilePicture = false; 100 | 101 | // Upload the new user data for the current user. 102 | if (_streamagramUser != null) { 103 | await client.currentUser!.update(_streamagramUser!.toMap()); 104 | } 105 | 106 | notifyListeners(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /lib/app/state/demo_users.dart: -------------------------------------------------------------------------------- 1 | import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; 2 | 3 | /// Demo application users. 4 | enum DemoAppUser { 5 | sahil, 6 | sacha, 7 | salvatore, 8 | gordon, 9 | } 10 | 11 | /// Convenient class Extension on [DemoAppUser] enum 12 | extension DemoAppUserX on DemoAppUser { 13 | /// Convenient method Extension to generate an [id] from [DemoAppUser] enum 14 | String get id => { 15 | DemoAppUser.sahil: 'sahil-kumar', 16 | DemoAppUser.sacha: 'sacha-arbonel', 17 | DemoAppUser.salvatore: 'salvatore-giordano', 18 | DemoAppUser.gordon: 'gordon-hayes', 19 | }[this]!; 20 | 21 | /// Convenient method Extension to generate a [name] from [DemoAppUser] enum 22 | String? get name => { 23 | DemoAppUser.sahil: 'Sahil Kumar', 24 | DemoAppUser.sacha: 'Sacha Arbonel', 25 | DemoAppUser.salvatore: 'Salvatore Giordano', 26 | DemoAppUser.gordon: 'Gordon Hayes', 27 | }[this]; 28 | 29 | /// Convenient method Extension to generate [data] from [DemoAppUser] enum 30 | Map? get data => { 31 | DemoAppUser.sahil: { 32 | 'first_name': 'Sahil', 33 | 'last_name': 'Kumar', 34 | 'full_name': 'Sahil Kumar', 35 | }, 36 | DemoAppUser.sacha: { 37 | 'first_name': 'Sacha', 38 | 'last_name': 'Arbonel', 39 | 'full_name': 'Sacha Arbonel', 40 | }, 41 | DemoAppUser.salvatore: { 42 | 'first_name': 'Salvatore', 43 | 'last_name': 'Giordano', 44 | 'full_name': 'Salvatore Giordano', 45 | }, 46 | DemoAppUser.gordon: { 47 | 'first_name': 'Gordon', 48 | 'last_name': 'Hayes', 49 | 'full_name': 'Gordon Hayes', 50 | }, 51 | }[this]; 52 | 53 | /// Convenient method Extension to generate a [token] from [DemoAppUser] enum 54 | Token? get token => { 55 | // TODO: Generate your own tokens if you're using your own API key. 56 | DemoAppUser.sahil: const Token( 57 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2FoaWwta3VtYXIifQ.Ts_4yhx6P4syDdO5g0QKJXqcET-0UO3mZHY_tKbseoA'), 58 | DemoAppUser.sacha: const Token( 59 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2FjaGEtYXJib25lbCJ9.atw_x8yl5bnhXbDKntlNtIVfLfQm9fe2xpaUuzIHHsM'), 60 | DemoAppUser.salvatore: const Token( 61 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2FsdmF0b3JlLWdpb3JkYW5vIn0.C3sS6UM6LhZbM2evWaIDlp8N_V3g11fvah9Llk3Gs4w'), 62 | DemoAppUser.gordon: const Token( 63 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiZ29yZG9uLWhheWVzIn0.q0C65xjNtMdZ62pHcVodSjP6SqVh_BL9GNYavnp0l-4'), 64 | }[this]; 65 | } 66 | -------------------------------------------------------------------------------- /lib/app/state/models/models.dart: -------------------------------------------------------------------------------- 1 | export 'user.dart'; 2 | -------------------------------------------------------------------------------- /lib/app/state/models/user.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | /// Data model for a feed user's extra data. 6 | @immutable 7 | class StreamagramUser { 8 | /// Data model for a feed user's extra data. 9 | const StreamagramUser({ 10 | required this.firstName, 11 | required this.lastName, 12 | required this.fullName, 13 | required this.profilePhoto, 14 | required this.profilePhotoResized, 15 | required this.profilePhotoThumbnail, 16 | }); 17 | 18 | /// Converts a Map to this. 19 | factory StreamagramUser.fromMap(Map map) { 20 | return StreamagramUser( 21 | firstName: map['first_name'] as String? ?? 'No name', 22 | lastName: map['last_name'] as String? ?? 'No last name', 23 | fullName: map['full_name'] as String? ?? 'No full name', 24 | profilePhoto: map['profile_photo'] as String?, 25 | profilePhotoResized: map['profile_photo_resized'] as String?, 26 | profilePhotoThumbnail: map['profile_photo_thumbnail'] as String?, 27 | ); 28 | } 29 | 30 | /// Converts json to this. 31 | factory StreamagramUser.fromJson(String source) => 32 | StreamagramUser.fromMap(json.decode(source) as Map); 33 | 34 | /// User's first name 35 | final String firstName; 36 | 37 | /// User's last name 38 | final String lastName; 39 | 40 | /// User's full name 41 | final String fullName; 42 | 43 | /// URL to user's profile photo. 44 | final String? profilePhoto; 45 | 46 | /// A 500x500 version of the [profilePhoto]. 47 | final String? profilePhotoResized; 48 | 49 | /// A small thumbnail version of the [profilePhoto]. 50 | final String? profilePhotoThumbnail; 51 | 52 | /// Convenient method to replace certain fields. 53 | StreamagramUser copyWith({ 54 | String? firstName, 55 | String? lastName, 56 | String? fullName, 57 | String? profilePhoto, 58 | String? profilePhotoResized, 59 | String? profilePhotoThumbnail, 60 | }) { 61 | return StreamagramUser( 62 | firstName: firstName ?? this.firstName, 63 | lastName: lastName ?? this.lastName, 64 | fullName: fullName ?? this.fullName, 65 | profilePhoto: profilePhoto ?? this.profilePhoto, 66 | profilePhotoResized: profilePhotoResized ?? this.profilePhotoResized, 67 | profilePhotoThumbnail: 68 | profilePhotoThumbnail ?? this.profilePhotoThumbnail, 69 | ); 70 | } 71 | 72 | /// Converts this model to a Map. 73 | Map toMap() { 74 | return { 75 | 'first_name': firstName, 76 | 'last_name': lastName, 77 | 'full_name': fullName, 78 | 'profile_photo': profilePhoto, 79 | 'profile_photo_resized': profilePhotoResized, 80 | 'profile_photo_thumbnail': profilePhotoThumbnail, 81 | }; 82 | } 83 | 84 | /// Converts this class to json. 85 | String toJson() => json.encode(toMap()); 86 | 87 | @override 88 | String toString() { 89 | return '''UserData(firstName: $firstName, lastName: $lastName, fullName: $fullName, profilePhoto: $profilePhoto, profilePhotoResized: $profilePhotoResized, profilePhotoThumbnail: $profilePhotoThumbnail)'''; 90 | } 91 | 92 | @override 93 | bool operator ==(Object other) { 94 | if (identical(this, other)) return true; 95 | 96 | return other is StreamagramUser && 97 | other.firstName == firstName && 98 | other.lastName == lastName && 99 | other.fullName == fullName && 100 | other.profilePhoto == profilePhoto && 101 | other.profilePhotoResized == profilePhotoResized && 102 | other.profilePhotoThumbnail == profilePhotoThumbnail; 103 | } 104 | 105 | @override 106 | int get hashCode { 107 | return firstName.hashCode ^ 108 | lastName.hashCode ^ 109 | fullName.hashCode ^ 110 | profilePhoto.hashCode ^ 111 | profilePhotoResized.hashCode ^ 112 | profilePhotoThumbnail.hashCode; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /lib/app/state/state.dart: -------------------------------------------------------------------------------- 1 | export 'app_state.dart'; 2 | export 'demo_users.dart'; 3 | export 'models/models.dart'; 4 | -------------------------------------------------------------------------------- /lib/app/stream_agram.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:stream_agram/app/app.dart'; 4 | import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; 5 | 6 | import '../components/login/login.dart'; 7 | 8 | /// {@template app} 9 | /// Main entry point to the Stream-agram application. 10 | /// {@endtemplate} 11 | class StreamagramApp extends StatefulWidget { 12 | /// {@macro app} 13 | const StreamagramApp({ 14 | Key? key, 15 | required this.appTheme, 16 | }) : super(key: key); 17 | 18 | /// App's theme data. 19 | final AppTheme appTheme; 20 | 21 | @override 22 | State createState() => _StreamagramAppState(); 23 | } 24 | 25 | class _StreamagramAppState extends State { 26 | final _client = 27 | StreamFeedClient('eyssk29az2kj'); // TODO: Add Stream API Token 28 | late final appState = AppState(client: _client); 29 | 30 | // Important to only initialize this once. 31 | // Unless you want to update the bloc state 32 | late final feedBloc = FeedBloc(client: _client); 33 | 34 | @override 35 | Widget build(BuildContext context) { 36 | return ChangeNotifierProvider.value( 37 | value: appState, 38 | child: MaterialApp( 39 | title: 'Stream-agram', 40 | theme: widget.appTheme.lightTheme, 41 | darkTheme: widget.appTheme.darkTheme, 42 | themeMode: ThemeMode.dark, 43 | builder: (context, child) { 44 | // Stream Feeds provider to give access to [FeedBloc] 45 | // This class comes from Stream Feeds. 46 | return FeedProvider( 47 | bloc: feedBloc, 48 | child: child!, 49 | ); 50 | }, 51 | home: const LoginScreen(), 52 | ), 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/app/theme.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// Global reference to application colors. 4 | abstract class AppColors { 5 | /// Dark color. 6 | static const dark = Colors.black; 7 | 8 | static const light = Color(0xFFFAFAFA); 9 | 10 | /// Grey background accent. 11 | static const grey = Color(0xFF262626); 12 | 13 | /// Primary text color 14 | static const primaryText = Colors.white; 15 | 16 | /// Secondary color. 17 | static const secondary = Color(0xFF0095F6); 18 | 19 | /// Color to use for favorite icons (indicating a like). 20 | static const like = Colors.red; 21 | 22 | /// Grey faded color. 23 | static const faded = Colors.grey; 24 | 25 | /// Light grey color 26 | static const ligthGrey = Color(0xFFEEEEEE); 27 | 28 | /// Top gradient color used in various UI components. 29 | static const topGradient = Color(0xFFE60064); 30 | 31 | /// Bottom gradient color used in various UI components. 32 | static const bottomGradient = Color(0xFFFFB344); 33 | } 34 | 35 | /// Global reference to application [TextStyle]s. 36 | abstract class AppTextStyle { 37 | /// A medium bold text style. 38 | static const textStyleBoldMedium = TextStyle( 39 | fontWeight: FontWeight.w600, 40 | ); 41 | 42 | /// A bold text style. 43 | static const textStyleBold = TextStyle( 44 | fontWeight: FontWeight.bold, 45 | ); 46 | 47 | static const textStyleSmallBold = TextStyle( 48 | fontWeight: FontWeight.bold, 49 | fontSize: 13, 50 | ); 51 | 52 | /// A faded text style. Uses [AppColors.faded]. 53 | static const textStyleFaded = 54 | TextStyle(color: AppColors.faded, fontWeight: FontWeight.w400); 55 | 56 | /// A faded text style. Uses [AppColors.faded]. 57 | static const textStyleFadedSmall = TextStyle( 58 | color: AppColors.faded, fontWeight: FontWeight.w400, fontSize: 11); 59 | 60 | /// A faded text style. Uses [AppColors.faded]. 61 | static const textStyleFadedSmallBold = TextStyle( 62 | color: AppColors.faded, fontWeight: FontWeight.w500, fontSize: 11); 63 | 64 | /// Light text style. 65 | static const textStyleLight = TextStyle(fontWeight: FontWeight.w300); 66 | 67 | /// Action text 68 | static const textStyleAction = TextStyle( 69 | fontWeight: FontWeight.w700, 70 | color: AppColors.secondary, 71 | ); 72 | } 73 | 74 | /// Global reference to the application theme. 75 | class AppTheme { 76 | final _darkBase = ThemeData.dark(); 77 | final _lightBase = ThemeData.light(); 78 | 79 | /// Dark theme and its settings. 80 | ThemeData get darkTheme => _darkBase.copyWith( 81 | visualDensity: VisualDensity.adaptivePlatformDensity, 82 | backgroundColor: AppColors.dark, 83 | scaffoldBackgroundColor: AppColors.dark, 84 | appBarTheme: _darkBase.appBarTheme.copyWith( 85 | backgroundColor: AppColors.dark, 86 | foregroundColor: AppColors.light, 87 | iconTheme: const IconThemeData(color: AppColors.light), 88 | elevation: 0, 89 | ), 90 | bottomNavigationBarTheme: _darkBase.bottomNavigationBarTheme.copyWith( 91 | backgroundColor: AppColors.dark, 92 | selectedItemColor: AppColors.light, 93 | ), 94 | outlinedButtonTheme: OutlinedButtonThemeData( 95 | style: ButtonStyle( 96 | side: MaterialStateProperty.all( 97 | const BorderSide( 98 | color: AppColors.grey, 99 | ), 100 | ), 101 | foregroundColor: MaterialStateProperty.all( 102 | AppColors.light, 103 | ), 104 | backgroundColor: MaterialStateProperty.all( 105 | AppColors.dark, 106 | ), 107 | overlayColor: MaterialStateProperty.all( 108 | AppColors.grey, 109 | ), 110 | ), 111 | ), 112 | elevatedButtonTheme: ElevatedButtonThemeData( 113 | style: ButtonStyle( 114 | backgroundColor: MaterialStateProperty.all( 115 | AppColors.secondary, 116 | ), 117 | foregroundColor: MaterialStateProperty.all( 118 | AppColors.primaryText, 119 | ), 120 | overlayColor: MaterialStateProperty.all( 121 | AppColors.grey, 122 | ), 123 | ), 124 | ), 125 | textButtonTheme: TextButtonThemeData( 126 | style: ButtonStyle( 127 | foregroundColor: MaterialStateProperty.all( 128 | AppColors.secondary, 129 | ), 130 | overlayColor: MaterialStateProperty.all( 131 | AppColors.grey, 132 | ), 133 | textStyle: MaterialStateProperty.all( 134 | const TextStyle( 135 | color: AppColors.secondary, 136 | fontSize: 16, 137 | fontWeight: FontWeight.w600, 138 | ), 139 | ), 140 | ), 141 | ), 142 | brightness: Brightness.dark, 143 | colorScheme: 144 | _darkBase.colorScheme.copyWith(secondary: AppColors.secondary), 145 | ); 146 | 147 | ThemeData get lightTheme => _lightBase.copyWith( 148 | visualDensity: VisualDensity.adaptivePlatformDensity, 149 | backgroundColor: AppColors.light, 150 | scaffoldBackgroundColor: AppColors.light, 151 | appBarTheme: _lightBase.appBarTheme.copyWith( 152 | backgroundColor: AppColors.light, 153 | foregroundColor: AppColors.dark, 154 | iconTheme: const IconThemeData(color: AppColors.dark), 155 | elevation: 0, 156 | ), 157 | bottomNavigationBarTheme: _lightBase.bottomNavigationBarTheme.copyWith( 158 | backgroundColor: AppColors.light, 159 | selectedItemColor: AppColors.dark, 160 | ), 161 | snackBarTheme: 162 | _lightBase.snackBarTheme.copyWith(backgroundColor: AppColors.dark), 163 | outlinedButtonTheme: OutlinedButtonThemeData( 164 | style: ButtonStyle( 165 | side: MaterialStateProperty.all( 166 | const BorderSide( 167 | color: AppColors.ligthGrey, 168 | ), 169 | ), 170 | foregroundColor: MaterialStateProperty.all( 171 | AppColors.dark, 172 | ), 173 | backgroundColor: MaterialStateProperty.all( 174 | AppColors.light, 175 | ), 176 | overlayColor: MaterialStateProperty.all( 177 | AppColors.ligthGrey, 178 | ), 179 | ), 180 | ), 181 | elevatedButtonTheme: ElevatedButtonThemeData( 182 | style: ButtonStyle( 183 | backgroundColor: MaterialStateProperty.all( 184 | AppColors.secondary, 185 | ), 186 | foregroundColor: MaterialStateProperty.all( 187 | AppColors.primaryText, 188 | ), 189 | overlayColor: MaterialStateProperty.all( 190 | AppColors.ligthGrey, 191 | ), 192 | ), 193 | ), 194 | textButtonTheme: TextButtonThemeData( 195 | style: ButtonStyle( 196 | foregroundColor: MaterialStateProperty.all( 197 | AppColors.secondary, 198 | ), 199 | textStyle: MaterialStateProperty.all( 200 | const TextStyle( 201 | color: AppColors.secondary, 202 | fontSize: 16, 203 | fontWeight: FontWeight.w600, 204 | ), 205 | ), 206 | overlayColor: MaterialStateProperty.all( 207 | AppColors.ligthGrey, 208 | ), 209 | ), 210 | ), 211 | brightness: Brightness.light, 212 | colorScheme: 213 | _lightBase.colorScheme.copyWith(secondary: AppColors.secondary), 214 | ); 215 | } 216 | -------------------------------------------------------------------------------- /lib/app/utils.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'package:provider/provider.dart'; 4 | 5 | import 'state/app_state.dart'; 6 | 7 | /// Extension method on [BuildContext] to easily perform snackbar operations. 8 | extension Snackbar on BuildContext { 9 | /// Removes the current active [SnackBar], and replaces it with a new snackbar 10 | /// with content of [message]. 11 | void removeAndShowSnackbar(final String message) { 12 | ScaffoldMessenger.of(this).removeCurrentSnackBar(); 13 | ScaffoldMessenger.of(this).showSnackBar( 14 | SnackBar(content: Text(message)), 15 | ); 16 | } 17 | } 18 | 19 | /// Extension method on [BuildContext] to easily retrieve providers. 20 | extension ProviderX on BuildContext { 21 | /// Returns the application [AppState]. 22 | AppState get appState => read(); 23 | } 24 | -------------------------------------------------------------------------------- /lib/components/app_widgets/app_widgets.dart: -------------------------------------------------------------------------------- 1 | export 'avatars.dart'; 2 | export 'comment_box.dart'; 3 | export 'tap_fade_icon.dart'; 4 | export 'favorite_icon.dart'; 5 | -------------------------------------------------------------------------------- /lib/components/app_widgets/avatars.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | import '../../app/app.dart'; 5 | 6 | /// An avatar that displays a user's profile picture. 7 | /// 8 | /// Supports different sizes: 9 | /// - `Avatar.tiny` 10 | /// - `Avatar.small` 11 | /// - `Avatar.medium` 12 | /// - `Avatar.big` 13 | /// - `Avatar.huge` 14 | class Avatar extends StatelessWidget { 15 | /// Creates a tiny avatar. 16 | const Avatar.tiny({ 17 | Key? key, 18 | required this.streamagramUser, 19 | }) : _avatarSize = _tinyAvatarSize, 20 | _coloredCircle = _tinyColoredCircle, 21 | hasNewStory = false, 22 | fontSize = 12, 23 | isThumbnail = true, 24 | super(key: key); 25 | 26 | /// Creates a small avatar. 27 | const Avatar.small({ 28 | Key? key, 29 | required this.streamagramUser, 30 | }) : _avatarSize = _smallAvatarSize, 31 | _coloredCircle = _smallColoredCircle, 32 | hasNewStory = false, 33 | fontSize = 14, 34 | isThumbnail = true, 35 | super(key: key); 36 | 37 | /// Creates a medium avatar. 38 | const Avatar.medium({ 39 | Key? key, 40 | this.hasNewStory = false, 41 | required this.streamagramUser, 42 | }) : _avatarSize = _mediumAvatarSize, 43 | _coloredCircle = _mediumColoredCircle, 44 | fontSize = 20, 45 | isThumbnail = true, 46 | super(key: key); 47 | 48 | /// Creates a big avatar. 49 | const Avatar.big({ 50 | Key? key, 51 | this.hasNewStory = false, 52 | required this.streamagramUser, 53 | }) : _avatarSize = _largeAvatarSize, 54 | _coloredCircle = _largeColoredCircle, 55 | fontSize = 26, 56 | isThumbnail = false, 57 | super(key: key); 58 | 59 | /// Creates a huge avatar. 60 | const Avatar.huge({ 61 | Key? key, 62 | this.hasNewStory = false, 63 | required this.streamagramUser, 64 | }) : _avatarSize = _hugeAvatarSize, 65 | _coloredCircle = _hugeColoredCircle, 66 | fontSize = 30, 67 | isThumbnail = false, 68 | super(key: key); 69 | 70 | /// Indicates if the user has a new story. If yes, their avatar is surrounded 71 | /// with an indicator. 72 | final bool hasNewStory; 73 | 74 | /// The user data to show for the avatar. 75 | final StreamagramUser streamagramUser; 76 | 77 | /// Text size of the user's initials when there is no profile photo. 78 | final double fontSize; 79 | 80 | final double _avatarSize; 81 | final double _coloredCircle; 82 | 83 | // Small avatar configuration 84 | static const _tinyAvatarSize = 22.0; 85 | static const _tinyPaddedCircle = _tinyAvatarSize + 2; 86 | static const _tinyColoredCircle = _tinyPaddedCircle * 2 + 4; 87 | 88 | // Small avatar configuration 89 | static const _smallAvatarSize = 30.0; 90 | static const _smallPaddedCircle = _smallAvatarSize + 2; 91 | static const _smallColoredCircle = _smallPaddedCircle * 2 + 4; 92 | 93 | // Medium avatar configuration 94 | static const _mediumAvatarSize = 40.0; 95 | static const _mediumPaddedCircle = _mediumAvatarSize + 2; 96 | static const _mediumColoredCircle = _mediumPaddedCircle * 2 + 4; 97 | 98 | // Large avatar configuration 99 | static const _largeAvatarSize = 90.0; 100 | static const _largPaddedCircle = _largeAvatarSize + 2; 101 | static const _largeColoredCircle = _largPaddedCircle * 2 + 4; 102 | 103 | // Huge avatar configuration 104 | static const _hugeAvatarSize = 120.0; 105 | static const _hugePaddedCircle = _hugeAvatarSize + 2; 106 | static const _hugeColoredCircle = _hugePaddedCircle * 2 + 4; 107 | 108 | /// Whether this avatar uses a thumbnail as an image (low quality). 109 | final bool isThumbnail; 110 | 111 | @override 112 | Widget build(BuildContext context) { 113 | final picture = _CircularProfilePicture( 114 | size: _avatarSize, 115 | userData: streamagramUser, 116 | fontSize: fontSize, 117 | isThumbnail: isThumbnail, 118 | ); 119 | 120 | if (!hasNewStory) { 121 | return picture; 122 | } 123 | return Container( 124 | width: _coloredCircle, 125 | height: _coloredCircle, 126 | decoration: const BoxDecoration( 127 | color: Colors.red, 128 | shape: BoxShape.circle, 129 | ), 130 | child: Center(child: picture), 131 | ); 132 | } 133 | } 134 | 135 | class _CircularProfilePicture extends StatelessWidget { 136 | const _CircularProfilePicture({ 137 | Key? key, 138 | required this.size, 139 | required this.userData, 140 | required this.fontSize, 141 | this.isThumbnail = false, 142 | }) : super(key: key); 143 | 144 | final StreamagramUser userData; 145 | 146 | final double size; 147 | final double fontSize; 148 | 149 | final bool isThumbnail; 150 | 151 | @override 152 | Widget build(BuildContext context) { 153 | final profilePhoto = isThumbnail 154 | ? userData.profilePhotoThumbnail 155 | : userData.profilePhotoResized; 156 | 157 | return (profilePhoto == null) 158 | ? Container( 159 | width: size, 160 | height: size, 161 | decoration: const BoxDecoration( 162 | color: AppColors.secondary, 163 | shape: BoxShape.circle, 164 | ), 165 | child: Center( 166 | child: Text( 167 | '${userData.firstName[0]}${userData.lastName[0]}', 168 | style: TextStyle(fontSize: fontSize), 169 | ), 170 | ), 171 | ) 172 | : SizedBox( 173 | width: size, 174 | height: size, 175 | child: CachedNetworkImage( 176 | imageUrl: profilePhoto, 177 | fit: BoxFit.contain, 178 | imageBuilder: (context, imageProvider) => Container( 179 | width: size, 180 | height: size, 181 | decoration: BoxDecoration( 182 | shape: BoxShape.circle, 183 | image: 184 | DecorationImage(image: imageProvider, fit: BoxFit.cover), 185 | ), 186 | ), 187 | ), 188 | ); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /lib/components/app_widgets/comment_box.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../../app/app.dart'; 4 | import 'app_widgets.dart'; 5 | 6 | /// Displays a text field styled to easily add comments to posts. 7 | /// 8 | /// Quickly add emoji reactions. 9 | class CommentBox extends StatelessWidget { 10 | /// Creates a [CommentBox]. 11 | const CommentBox({ 12 | Key? key, 13 | required this.commenter, 14 | required this.textEditingController, 15 | required this.focusNode, 16 | required this.onSubmitted, 17 | }) : super(key: key); 18 | 19 | final StreamagramUser commenter; 20 | final TextEditingController textEditingController; 21 | final FocusNode focusNode; 22 | final Function(String?) onSubmitted; 23 | 24 | @override 25 | Widget build(BuildContext context) { 26 | final border = _border(context); 27 | return Container( 28 | decoration: BoxDecoration( 29 | color: (Theme.of(context).brightness == Brightness.light) 30 | ? AppColors.light 31 | : AppColors.dark, 32 | border: Border( 33 | top: BorderSide( 34 | color: (Theme.of(context).brightness == Brightness.light) 35 | ? AppColors.ligthGrey 36 | : AppColors.grey, 37 | )), 38 | ), 39 | child: Column( 40 | mainAxisSize: MainAxisSize.min, 41 | children: [ 42 | Padding( 43 | padding: const EdgeInsets.symmetric(vertical: 8.0), 44 | child: Row( 45 | mainAxisAlignment: MainAxisAlignment.spaceAround, 46 | children: [ 47 | _emojiText('❤️'), 48 | _emojiText('🙌'), 49 | _emojiText('🔥'), 50 | _emojiText('👏🏻'), 51 | _emojiText('😢'), 52 | _emojiText('😍'), 53 | _emojiText('😮'), 54 | _emojiText('😂'), 55 | ], 56 | ), 57 | ), 58 | Row( 59 | children: [ 60 | Padding( 61 | padding: const EdgeInsets.all(8.0), 62 | child: Avatar.medium(streamagramUser: commenter), 63 | ), 64 | Expanded( 65 | child: TextField( 66 | controller: textEditingController, 67 | focusNode: focusNode, 68 | onSubmitted: onSubmitted, 69 | minLines: 1, 70 | maxLines: 10, 71 | style: const TextStyle(fontSize: 14), 72 | decoration: InputDecoration( 73 | suffix: _DoneButton( 74 | textEditorFocusNode: focusNode, 75 | textEditingController: textEditingController, 76 | onSubmitted: onSubmitted, 77 | ), 78 | hintText: 'Add a comment...', 79 | isDense: true, 80 | contentPadding: const EdgeInsets.symmetric( 81 | horizontal: 16, vertical: 12), 82 | focusedBorder: border, 83 | border: border, 84 | enabledBorder: border), 85 | ), 86 | ), 87 | const SizedBox( 88 | width: 8, 89 | ), 90 | ], 91 | ), 92 | ], 93 | ), 94 | ); 95 | } 96 | 97 | OutlineInputBorder _border(BuildContext context) { 98 | return OutlineInputBorder( 99 | borderRadius: const BorderRadius.all(Radius.circular(24)), 100 | borderSide: BorderSide( 101 | color: (Theme.of(context).brightness == Brightness.light) 102 | ? AppColors.grey.withOpacity(0.3) 103 | : AppColors.light.withOpacity(0.5), 104 | width: 0.5, 105 | ), 106 | ); 107 | } 108 | 109 | Widget _emojiText(String emoji) { 110 | return GestureDetector( 111 | onTap: () { 112 | focusNode.requestFocus(); 113 | textEditingController.text = textEditingController.text + emoji; 114 | textEditingController.selection = TextSelection.fromPosition( 115 | TextPosition(offset: textEditingController.text.length)); 116 | }, 117 | child: Text( 118 | emoji, 119 | style: const TextStyle(fontSize: 24), 120 | ), 121 | ); 122 | } 123 | } 124 | 125 | class _DoneButton extends StatefulWidget { 126 | const _DoneButton({ 127 | Key? key, 128 | required this.onSubmitted, 129 | required this.textEditorFocusNode, 130 | required this.textEditingController, 131 | }) : super(key: key); 132 | 133 | final Function(String?) onSubmitted; 134 | final FocusNode textEditorFocusNode; 135 | final TextEditingController textEditingController; 136 | 137 | @override 138 | State<_DoneButton> createState() => _DoneButtonState(); 139 | } 140 | 141 | class _DoneButtonState extends State<_DoneButton> { 142 | final fadedTextStyle = 143 | AppTextStyle.textStyleAction.copyWith(color: Colors.grey); 144 | late TextStyle textStyle = fadedTextStyle; 145 | 146 | @override 147 | void initState() { 148 | super.initState(); 149 | widget.textEditingController.addListener(() { 150 | if (widget.textEditingController.text.isNotEmpty) { 151 | textStyle = AppTextStyle.textStyleAction; 152 | } else { 153 | textStyle = fadedTextStyle; 154 | } 155 | if (mounted) { 156 | setState(() {}); 157 | } 158 | }); 159 | } 160 | 161 | @override 162 | Widget build(BuildContext context) { 163 | return widget.textEditorFocusNode.hasFocus 164 | ? GestureDetector( 165 | onTap: () { 166 | widget.onSubmitted(widget.textEditingController.text); 167 | }, 168 | child: Text( 169 | 'Done', 170 | style: textStyle, 171 | ), 172 | ) 173 | : const SizedBox.shrink(); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /lib/components/app_widgets/favorite_icon.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:stream_agram/app/theme.dart'; 3 | 4 | /// {@template favorite_icon_button} 5 | /// Animated button to indicate if a post/comment is liked. 6 | /// 7 | /// Pass in onPressed to 8 | /// {@endtemplate} 9 | class FavoriteIconButton extends StatefulWidget { 10 | /// {@macro favorite_icon_button} 11 | const FavoriteIconButton({ 12 | Key? key, 13 | required this.isLiked, 14 | this.size = 22, 15 | required this.onTap, 16 | }) : super(key: key); 17 | 18 | /// Indicates if it is liked or not. 19 | final bool isLiked; 20 | 21 | /// Size of the icon. 22 | final double size; 23 | 24 | /// onTap callback. Returns a value to indicate if liked or not. 25 | final Function(bool val) onTap; 26 | 27 | @override 28 | State createState() => _FavoriteIconButtonState(); 29 | } 30 | 31 | class _FavoriteIconButtonState extends State { 32 | late bool isLiked = widget.isLiked; 33 | 34 | void _handleTap() { 35 | setState(() { 36 | isLiked = !isLiked; 37 | }); 38 | widget.onTap(isLiked); 39 | } 40 | 41 | @override 42 | Widget build(BuildContext context) { 43 | return GestureDetector( 44 | onTap: _handleTap, 45 | child: AnimatedCrossFade( 46 | firstCurve: Curves.easeIn, 47 | secondCurve: Curves.easeOut, 48 | firstChild: Icon( 49 | Icons.favorite, 50 | color: AppColors.like, 51 | size: widget.size, 52 | ), 53 | secondChild: Icon( 54 | Icons.favorite_outline, 55 | size: widget.size, 56 | ), 57 | crossFadeState: 58 | isLiked ? CrossFadeState.showFirst : CrossFadeState.showSecond, 59 | duration: const Duration(milliseconds: 200), 60 | ), 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/components/app_widgets/tap_fade_icon.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// {@template tap_fade_icon} 4 | /// A tappable icon that fades colors when tapped and held. 5 | /// {@endtemplate} 6 | class TapFadeIcon extends StatefulWidget { 7 | /// {@macro tap_fade_icon} 8 | const TapFadeIcon({ 9 | Key? key, 10 | required this.onTap, 11 | required this.icon, 12 | required this.iconColor, 13 | this.size = 22, 14 | }) : super(key: key); 15 | 16 | /// Callback to handle tap. 17 | final VoidCallback onTap; 18 | 19 | /// Color of the icon. 20 | final Color iconColor; 21 | 22 | /// Type of icon. 23 | final IconData icon; 24 | 25 | /// Icon size. 26 | final double size; 27 | 28 | @override 29 | State createState() => _TapFadeIconState(); 30 | } 31 | 32 | class _TapFadeIconState extends State { 33 | late Color color = widget.iconColor; 34 | 35 | void handleTapDown(TapDownDetails _) { 36 | setState(() { 37 | color = widget.iconColor.withOpacity(0.7); 38 | }); 39 | } 40 | 41 | void handleTapUp(TapUpDetails _) { 42 | setState(() { 43 | color = widget.iconColor; 44 | }); 45 | 46 | widget.onTap(); // Execute callback. 47 | } 48 | 49 | @override 50 | void didUpdateWidget(covariant TapFadeIcon oldWidget) { 51 | super.didUpdateWidget(oldWidget); 52 | if (oldWidget.iconColor != widget.iconColor) { 53 | color = widget.iconColor; 54 | } 55 | } 56 | 57 | @override 58 | Widget build(BuildContext context) { 59 | return GestureDetector( 60 | onTapDown: handleTapDown, 61 | onTapUp: handleTapUp, 62 | child: Icon( 63 | widget.icon, 64 | color: color, 65 | size: widget.size, 66 | ), 67 | ); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/components/comments/comments.dart: -------------------------------------------------------------------------------- 1 | export 'state/state.dart'; 2 | -------------------------------------------------------------------------------- /lib/components/comments/comments_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:jiffy/jiffy.dart'; 3 | import 'package:provider/provider.dart'; 4 | import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; 5 | 6 | import '../../app/app.dart'; 7 | import '../app_widgets/app_widgets.dart'; 8 | import 'state/state.dart'; 9 | 10 | /// Screen that shows all comments for a given post. 11 | class CommentsScreen extends StatefulWidget { 12 | /// Creates a new [CommentsScreen]. 13 | const CommentsScreen({ 14 | Key? key, 15 | required this.enrichedActivity, 16 | required this.activityOwnerData, 17 | }) : super(key: key); 18 | 19 | final EnrichedActivity enrichedActivity; 20 | 21 | /// Owner / [User] of the activity. 22 | final StreamagramUser activityOwnerData; 23 | 24 | /// MaterialPageRoute to this screen. 25 | static Route route({ 26 | required EnrichedActivity enrichedActivity, 27 | required StreamagramUser activityOwnerData, 28 | }) => 29 | MaterialPageRoute( 30 | builder: (context) => CommentsScreen( 31 | enrichedActivity: enrichedActivity, 32 | activityOwnerData: activityOwnerData, 33 | ), 34 | ); 35 | 36 | @override 37 | State createState() => _CommentsScreenState(); 38 | } 39 | 40 | class _CommentsScreenState extends State { 41 | late FocusNode commentFocusNode; 42 | late CommentState commentState; 43 | 44 | @override 45 | void initState() { 46 | super.initState(); 47 | commentFocusNode = FocusNode(); 48 | commentState = CommentState( 49 | activityId: widget.enrichedActivity.id!, 50 | activityOwnerData: widget.activityOwnerData, 51 | ); 52 | } 53 | 54 | @override 55 | void dispose() { 56 | commentFocusNode.dispose(); 57 | super.dispose(); 58 | } 59 | 60 | @override 61 | Widget build(BuildContext context) { 62 | return MultiProvider( 63 | providers: [ 64 | ChangeNotifierProvider.value(value: commentState), 65 | ChangeNotifierProvider.value(value: commentFocusNode), 66 | ], 67 | child: GestureDetector( 68 | onTap: () { 69 | commentState.resetCommentFocus(); 70 | FocusScope.of(context).unfocus(); 71 | }, 72 | child: Scaffold( 73 | appBar: AppBar( 74 | title: const Text('Comments', 75 | style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), 76 | elevation: 0.5, 77 | shadowColor: Colors.white, 78 | ), 79 | body: Stack( 80 | children: [ 81 | _CommentsList( 82 | activityId: widget.enrichedActivity.id!, 83 | ), 84 | _CommentBox( 85 | enrichedActivity: widget.enrichedActivity, 86 | ), 87 | ], 88 | ), 89 | ), 90 | ), 91 | ); 92 | } 93 | } 94 | 95 | class _CommentsList extends StatelessWidget { 96 | const _CommentsList({ 97 | Key? key, 98 | required this.activityId, 99 | }) : super(key: key); 100 | 101 | final String activityId; 102 | 103 | @override 104 | Widget build(BuildContext context) { 105 | return ReactionListCore( 106 | lookupValue: activityId, 107 | kind: 'comment', 108 | loadingBuilder: (context) => 109 | const Center(child: CircularProgressIndicator()), 110 | errorBuilder: (context, error) => 111 | const Center(child: Text('Could not load comments.')), 112 | emptyBuilder: (context) => 113 | const Center(child: Text('Be the first to add a comment.')), 114 | reactionsBuilder: (context, reactions) { 115 | return ListView.builder( 116 | itemCount: reactions.length + 1, 117 | itemBuilder: (context, index) { 118 | if (index == reactions.length) { 119 | // Bottom padding to ensure [CommentBox] does not obscure 120 | // visibility 121 | return const SizedBox( 122 | height: 120, 123 | ); 124 | } 125 | return Padding( 126 | padding: const EdgeInsets.symmetric(vertical: 8.0), 127 | child: _CommentTile( 128 | key: ValueKey('comment-${reactions[index].id}'), 129 | reaction: reactions[index], 130 | ), 131 | ); 132 | }, 133 | ); 134 | }, 135 | flags: EnrichmentFlags() 136 | ..withOwnChildren() 137 | ..withOwnReactions() 138 | ..withRecentReactions(), 139 | ); 140 | } 141 | } 142 | 143 | class _CommentBox extends StatefulWidget { 144 | const _CommentBox({ 145 | Key? key, 146 | required this.enrichedActivity, 147 | }) : super(key: key); 148 | 149 | final EnrichedActivity enrichedActivity; 150 | 151 | @override 152 | __CommentBoxState createState() => __CommentBoxState(); 153 | } 154 | 155 | class __CommentBoxState extends State<_CommentBox> { 156 | late final _commentTextController = TextEditingController(); 157 | 158 | Future handleSubmit(String? value) async { 159 | if (value != null && value.isNotEmpty) { 160 | _commentTextController.clear(); 161 | FocusScope.of(context).unfocus(); 162 | 163 | final commentState = context.read(); 164 | final commentFocus = commentState.commentFocus; 165 | 166 | if (commentFocus.typeOfComment == TypeOfComment.activityComment) { 167 | await FeedProvider.of(context).bloc.onAddReaction( 168 | kind: 'comment', 169 | activity: widget.enrichedActivity, 170 | feedGroup: 'timeline', 171 | data: {'message': value}, 172 | ); 173 | } else if (commentFocus.typeOfComment == TypeOfComment.reactionComment) { 174 | if (commentFocus.reaction != null) { 175 | await FeedProvider.of(context).bloc.onAddChildReaction( 176 | kind: 'comment', 177 | reaction: commentFocus.reaction!, 178 | lookupValue: widget.enrichedActivity.id!, 179 | data: {'message': value}, 180 | ); 181 | } 182 | } 183 | } 184 | } 185 | 186 | @override 187 | void dispose() { 188 | _commentTextController.dispose(); 189 | super.dispose(); 190 | } 191 | 192 | @override 193 | Widget build(BuildContext context) { 194 | final commentFocus = 195 | context.select((CommentState state) => state.commentFocus); 196 | 197 | final focusNode = context.watch(); 198 | 199 | return Align( 200 | alignment: Alignment.bottomCenter, 201 | child: Container( 202 | color: (Theme.of(context).brightness == Brightness.light) 203 | ? AppColors.light 204 | : AppColors.dark, 205 | child: Column( 206 | mainAxisSize: MainAxisSize.min, 207 | children: [ 208 | AnimatedSwitcher( 209 | duration: const Duration(milliseconds: 200), 210 | transitionBuilder: (child, animation) { 211 | final tween = 212 | Tween(begin: const Offset(0.0, 1.0), end: Offset.zero) 213 | .chain(CurveTween(curve: Curves.easeOutQuint)); 214 | final offsetAnimation = animation.drive(tween); 215 | return SlideTransition( 216 | position: offsetAnimation, 217 | child: child, 218 | ); 219 | }, 220 | child: 221 | (commentFocus.typeOfComment == TypeOfComment.reactionComment) 222 | ? _replyToBox(commentFocus, context) 223 | : const SizedBox.shrink(), 224 | ), 225 | CommentBox( 226 | commenter: context.appState.streamagramUser!, 227 | textEditingController: _commentTextController, 228 | onSubmitted: handleSubmit, 229 | focusNode: focusNode, 230 | ), 231 | SizedBox( 232 | height: MediaQuery.of(context).padding.bottom, 233 | ) 234 | ], 235 | ), 236 | ), 237 | ); 238 | } 239 | 240 | Container _replyToBox(CommentFocus commentFocus, BuildContext context) { 241 | return Container( 242 | color: (Theme.of(context).brightness == Brightness.dark) 243 | ? AppColors.grey 244 | : AppColors.ligthGrey, 245 | child: Padding( 246 | padding: const EdgeInsets.all(16.0), 247 | child: Row( 248 | children: [ 249 | Text( 250 | 'Replying to ${commentFocus.user.fullName}', 251 | style: AppTextStyle.textStyleFaded, 252 | ), 253 | const Spacer(), 254 | TapFadeIcon( 255 | onTap: () { 256 | context.read().resetCommentFocus(); 257 | }, 258 | icon: Icons.close, 259 | size: 16, 260 | iconColor: Theme.of(context).iconTheme.color!, 261 | ), 262 | ], 263 | ), 264 | ), 265 | ); 266 | } 267 | } 268 | 269 | class _CommentTile extends StatefulWidget { 270 | const _CommentTile({ 271 | Key? key, 272 | required this.reaction, 273 | this.canReply = true, 274 | this.isReplyToComment = false, 275 | }) : super(key: key); 276 | 277 | final Reaction reaction; 278 | final bool canReply; 279 | final bool isReplyToComment; 280 | @override 281 | __CommentTileState createState() => __CommentTileState(); 282 | } 283 | 284 | class __CommentTileState extends State<_CommentTile> { 285 | late final userData = StreamagramUser.fromMap(widget.reaction.user!.data!); 286 | late final message = extractMessage; 287 | 288 | late final timeSince = _timeSinceComment(); 289 | 290 | late int numberOfLikes = widget.reaction.childrenCounts?['like'] ?? 0; 291 | 292 | late bool isLiked = _isFavorited(); 293 | Reaction? likeReaction; 294 | 295 | String _timeSinceComment() { 296 | final jiffyTime = Jiffy(widget.reaction.createdAt).fromNow(); 297 | if (jiffyTime == 'a few seconds ago') { 298 | return 'just now'; 299 | } else { 300 | return jiffyTime; 301 | } 302 | } 303 | 304 | String numberOfLikesMessage(int count) { 305 | if (count == 0) { 306 | return ''; 307 | } 308 | if (count == 1) { 309 | return '1 like'; 310 | } else { 311 | return '$count likes'; 312 | } 313 | } 314 | 315 | String get extractMessage { 316 | final data = widget.reaction.data; 317 | if (data != null && data['message'] != null) { 318 | return data['message'] as String; 319 | } else { 320 | return ''; 321 | } 322 | } 323 | 324 | bool _isFavorited() { 325 | likeReaction = widget.reaction.ownChildren?['like']?.first; 326 | return likeReaction != null; 327 | } 328 | 329 | Future _handleFavorite(bool liked) async { 330 | if (isLiked && likeReaction != null) { 331 | await context.appState.client.reactions.delete(likeReaction!.id!); 332 | numberOfLikes--; 333 | } else { 334 | likeReaction = await context.appState.client.reactions.addChild( 335 | 'like', 336 | widget.reaction.id!, 337 | userId: context.appState.user.id, 338 | ); 339 | numberOfLikes++; 340 | } 341 | setState(() { 342 | isLiked = liked; 343 | }); 344 | } 345 | 346 | @override 347 | Widget build(BuildContext context) { 348 | return Column( 349 | mainAxisSize: MainAxisSize.min, 350 | crossAxisAlignment: CrossAxisAlignment.start, 351 | children: [ 352 | Row( 353 | crossAxisAlignment: CrossAxisAlignment.start, 354 | children: [ 355 | Padding( 356 | padding: const EdgeInsets.symmetric(horizontal: 8.0), 357 | child: (widget.isReplyToComment) 358 | ? Avatar.tiny(streamagramUser: userData) 359 | : Avatar.small(streamagramUser: userData), 360 | ), 361 | Expanded( 362 | child: Column( 363 | crossAxisAlignment: CrossAxisAlignment.start, 364 | mainAxisSize: MainAxisSize.min, 365 | children: [ 366 | Row( 367 | children: [ 368 | Expanded( 369 | child: Text.rich( 370 | TextSpan( 371 | children: [ 372 | TextSpan( 373 | text: userData.fullName, 374 | style: AppTextStyle.textStyleSmallBold), 375 | const TextSpan(text: ' '), 376 | TextSpan( 377 | text: message, 378 | style: const TextStyle(fontSize: 13), 379 | ), 380 | ], 381 | ), 382 | ), 383 | ), 384 | Padding( 385 | padding: const EdgeInsets.symmetric(horizontal: 8.0), 386 | child: Center( 387 | child: FavoriteIconButton( 388 | isLiked: isLiked, 389 | size: 14, 390 | onTap: _handleFavorite, 391 | ), 392 | ), 393 | ) 394 | ], 395 | ), 396 | Padding( 397 | padding: const EdgeInsets.only(top: 4.0), 398 | child: Row( 399 | mainAxisAlignment: MainAxisAlignment.start, 400 | children: [ 401 | SizedBox( 402 | width: 80, 403 | child: Text( 404 | timeSince, 405 | style: AppTextStyle.textStyleFadedSmall, 406 | ), 407 | ), 408 | Visibility( 409 | visible: numberOfLikes > 0, 410 | child: SizedBox( 411 | width: 60, 412 | child: Text( 413 | numberOfLikesMessage(numberOfLikes), 414 | style: AppTextStyle.textStyleFadedSmall, 415 | ), 416 | ), 417 | ), 418 | Visibility( 419 | visible: widget.canReply, 420 | child: GestureDetector( 421 | onTap: () { 422 | context.read().setCommentFocus( 423 | CommentFocus( 424 | typeOfComment: 425 | TypeOfComment.reactionComment, 426 | id: widget.reaction.id!, 427 | user: StreamagramUser.fromMap( 428 | widget.reaction.user!.data!), 429 | reaction: widget.reaction, 430 | ), 431 | ); 432 | 433 | FocusScope.of(context) 434 | .requestFocus(context.read()); 435 | }, 436 | child: const SizedBox( 437 | width: 50, 438 | child: Text( 439 | 'Reply', 440 | style: AppTextStyle.textStyleFadedSmallBold, 441 | ), 442 | ), 443 | ), 444 | ) 445 | ], 446 | ), 447 | ), 448 | ], 449 | ), 450 | ), 451 | ], 452 | ), 453 | Padding( 454 | padding: const EdgeInsets.only(left: 34.0), 455 | child: _ChildCommentList( 456 | comments: widget.reaction.latestChildren?['comment']), 457 | ), 458 | ], 459 | ); 460 | } 461 | } 462 | 463 | class _ChildCommentList extends StatelessWidget { 464 | const _ChildCommentList({ 465 | Key? key, 466 | required this.comments, 467 | }) : super(key: key); 468 | 469 | final List? comments; 470 | 471 | @override 472 | Widget build(BuildContext context) { 473 | return Column( 474 | mainAxisSize: MainAxisSize.min, 475 | children: comments 476 | ?.map( 477 | (reaction) => Padding( 478 | padding: const EdgeInsets.only(top: 8.0), 479 | child: _CommentTile( 480 | key: ValueKey('comment-tile-${reaction.id}'), 481 | reaction: reaction, 482 | canReply: false, 483 | isReplyToComment: true, 484 | ), 485 | ), 486 | ) 487 | .toList() ?? 488 | [], 489 | ); 490 | } 491 | } 492 | -------------------------------------------------------------------------------- /lib/components/comments/state/comment_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; 3 | 4 | import '../../../app/state/models/models.dart'; 5 | 6 | /// Indicates the type of comment that was made. 7 | /// Can be: 8 | /// - Activity comment 9 | /// - Reaction comment 10 | enum TypeOfComment { 11 | /// Comment on an activity 12 | activityComment, 13 | 14 | /// Comment on a reaction 15 | reactionComment, 16 | } 17 | 18 | /// {@template comment_focus} 19 | /// Information on the type of comment to make. This can be a comment on an 20 | /// activity, or a comment on a reaction. 21 | /// 22 | /// It also indicates the parent user on whom the comment is made. 23 | /// {@endtemplate} 24 | class CommentFocus { 25 | /// {@macro comment_focus} 26 | const CommentFocus({ 27 | required this.typeOfComment, 28 | required this.id, 29 | required this.user, 30 | this.reaction, 31 | }); 32 | 33 | final Reaction? reaction; 34 | 35 | /// Indicates the type of comment. See [TypeOfComment]. 36 | final TypeOfComment typeOfComment; 37 | 38 | /// Activity or reaction id on which the comment is made. 39 | final String id; 40 | 41 | /// The user data of the parent activity or reaction. 42 | final StreamagramUser user; 43 | } 44 | 45 | /// {@template comment_state} 46 | /// ChangeNotifier to facilitate posting comments to activities and reactions. 47 | /// {@endtemplate} 48 | class CommentState extends ChangeNotifier { 49 | /// {@macro comment_state} 50 | CommentState({ 51 | required this.activityId, 52 | required this.activityOwnerData, 53 | }); 54 | 55 | /// The id for this activity. 56 | final String activityId; 57 | 58 | /// UserData of whoever owns the activity. 59 | final StreamagramUser activityOwnerData; 60 | 61 | /// The type of commentFocus that is currently selected. 62 | 63 | late CommentFocus commentFocus = CommentFocus( 64 | typeOfComment: TypeOfComment.activityComment, 65 | id: activityId, 66 | user: activityOwnerData, 67 | ); 68 | 69 | /// Sets the focus to which a comment will be posted to. 70 | /// 71 | /// See [postComment]. 72 | void setCommentFocus(CommentFocus focus) { 73 | commentFocus = focus; 74 | notifyListeners(); 75 | } 76 | 77 | /// Resets the comment focus to the parent activity. 78 | void resetCommentFocus() { 79 | commentFocus = CommentFocus( 80 | typeOfComment: TypeOfComment.activityComment, 81 | id: activityId, 82 | user: activityOwnerData, 83 | ); 84 | notifyListeners(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /lib/components/comments/state/state.dart: -------------------------------------------------------------------------------- 1 | export 'comment_state.dart'; 2 | -------------------------------------------------------------------------------- /lib/components/home/home.dart: -------------------------------------------------------------------------------- 1 | export 'home_screen.dart'; 2 | -------------------------------------------------------------------------------- /lib/components/home/home_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:google_fonts/google_fonts.dart'; 4 | import 'package:stream_agram/components/profile/profile.dart'; 5 | import 'package:stream_agram/components/search/search_page.dart'; 6 | import 'package:stream_agram/components/timeline/timeline_page.dart'; 7 | 8 | import '../../app/app.dart'; 9 | import '../app_widgets/app_widgets.dart'; 10 | import '../new_post/new_post.dart'; 11 | 12 | /// HomeScreen of the application. 13 | /// 14 | /// Provides Navigation to various pages in the application and maintains their 15 | /// state. 16 | /// 17 | /// Default first page is [TimelinePage]. 18 | class HomeScreen extends StatefulWidget { 19 | /// Creates a new [HomeScreen] 20 | const HomeScreen({Key? key}) : super(key: key); 21 | 22 | /// List of pages available from the home screen. 23 | static const List _homePages = [ 24 | _KeepAlivePage(child: TimelinePage()), 25 | _KeepAlivePage(child: SearchPage()), 26 | _KeepAlivePage(child: ProfilePage()) 27 | ]; 28 | 29 | @override 30 | State createState() => _HomeScreenState(); 31 | } 32 | 33 | class _HomeScreenState extends State { 34 | final PageController pageController = PageController(); 35 | 36 | @override 37 | Widget build(BuildContext context) { 38 | final iconColor = Theme.of(context).appBarTheme.iconTheme!.color!; 39 | return Scaffold( 40 | appBar: AppBar( 41 | title: 42 | Text('Stream-agram', style: GoogleFonts.grandHotel(fontSize: 32)), 43 | elevation: 0, 44 | centerTitle: false, 45 | actions: [ 46 | Padding( 47 | padding: const EdgeInsets.all(8), 48 | child: TapFadeIcon( 49 | onTap: () => Navigator.of(context).push(NewPostScreen.route), 50 | icon: Icons.add_circle_outline, 51 | iconColor: iconColor, 52 | ), 53 | ), 54 | Padding( 55 | padding: const EdgeInsets.all(8), 56 | child: TapFadeIcon( 57 | onTap: () async { 58 | context.removeAndShowSnackbar('Not part of the demo'); 59 | }, 60 | icon: Icons.favorite_outline, 61 | iconColor: iconColor, 62 | ), 63 | ), 64 | Padding( 65 | padding: const EdgeInsets.all(8.0), 66 | child: TapFadeIcon( 67 | onTap: () => 68 | context.removeAndShowSnackbar('Not part of the demo'), 69 | icon: Icons.call_made, 70 | iconColor: iconColor, 71 | ), 72 | ), 73 | ], 74 | ), 75 | body: PageView( 76 | controller: pageController, 77 | physics: const NeverScrollableScrollPhysics(), 78 | children: HomeScreen._homePages, 79 | ), 80 | bottomNavigationBar: _StreamagramBottomNavBar( 81 | pageController: pageController, 82 | ), 83 | ); 84 | } 85 | } 86 | 87 | class _StreamagramBottomNavBar extends StatefulWidget { 88 | const _StreamagramBottomNavBar({ 89 | Key? key, 90 | required this.pageController, 91 | }) : super(key: key); 92 | 93 | final PageController pageController; 94 | 95 | @override 96 | State<_StreamagramBottomNavBar> createState() => 97 | _StreamagramBottomNavBarState(); 98 | } 99 | 100 | class _StreamagramBottomNavBarState extends State<_StreamagramBottomNavBar> { 101 | void _onNavigationItemTapped(int index) { 102 | widget.pageController.jumpToPage(index); 103 | } 104 | 105 | @override 106 | void initState() { 107 | super.initState(); 108 | widget.pageController.addListener(() { 109 | setState(() {}); 110 | }); 111 | } 112 | 113 | @override 114 | Widget build(BuildContext context) { 115 | return Container( 116 | decoration: BoxDecoration( 117 | boxShadow: [ 118 | BoxShadow( 119 | color: (Theme.of(context).brightness == Brightness.dark) 120 | ? AppColors.ligthGrey.withOpacity(0.5) 121 | : AppColors.faded.withOpacity(0.5), 122 | blurRadius: 1, 123 | ), 124 | ], 125 | ), 126 | child: BottomNavigationBar( 127 | onTap: _onNavigationItemTapped, 128 | showSelectedLabels: false, 129 | showUnselectedLabels: false, 130 | elevation: 0, 131 | iconSize: 28, 132 | currentIndex: widget.pageController.page?.toInt() ?? 0, 133 | items: const [ 134 | BottomNavigationBarItem( 135 | icon: Icon(Icons.home_outlined), 136 | activeIcon: Icon(Icons.home), 137 | label: 'Home', 138 | ), 139 | BottomNavigationBarItem( 140 | icon: Icon(CupertinoIcons.search), 141 | activeIcon: Icon( 142 | Icons.search, 143 | size: 22, 144 | ), 145 | label: 'Search', 146 | ), 147 | BottomNavigationBarItem( 148 | icon: Icon(Icons.person_outline), 149 | activeIcon: Icon(Icons.person), 150 | label: 'Person', 151 | ) 152 | ], 153 | ), 154 | ); 155 | } 156 | } 157 | 158 | class _KeepAlivePage extends StatefulWidget { 159 | const _KeepAlivePage({ 160 | Key? key, 161 | required this.child, 162 | }) : super(key: key); 163 | 164 | final Widget child; 165 | 166 | @override 167 | _KeepAlivePageState createState() => _KeepAlivePageState(); 168 | } 169 | 170 | class _KeepAlivePageState extends State<_KeepAlivePage> 171 | with AutomaticKeepAliveClientMixin { 172 | @override 173 | Widget build(BuildContext context) { 174 | super.build(context); 175 | 176 | return widget.child; 177 | } 178 | 179 | @override 180 | bool get wantKeepAlive => true; 181 | } 182 | -------------------------------------------------------------------------------- /lib/components/login/login.dart: -------------------------------------------------------------------------------- 1 | export 'login_screen.dart'; 2 | -------------------------------------------------------------------------------- /lib/components/login/login_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../../app/app.dart'; 4 | import '../home/home.dart'; 5 | 6 | /// {@template login_screen} 7 | /// Screen that presents an option of users to authenticate as. 8 | /// {@endtemplate} 9 | class LoginScreen extends StatefulWidget { 10 | /// {@macro login_screen} 11 | const LoginScreen({Key? key}) : super(key: key); 12 | 13 | @override 14 | State createState() => _LoginScreenState(); 15 | } 16 | 17 | class _LoginScreenState extends State { 18 | @override 19 | Widget build(BuildContext context) { 20 | final size = MediaQuery.of(context).size; 21 | return Scaffold( 22 | appBar: AppBar(title: const Text('Demo users')), 23 | body: Padding( 24 | padding: const EdgeInsets.symmetric(horizontal: 24.0), 25 | child: SizedBox( 26 | width: size.width, 27 | height: size.height, 28 | child: SingleChildScrollView( 29 | child: Column( 30 | mainAxisAlignment: MainAxisAlignment.center, 31 | children: [ 32 | const SizedBox(height: 42), 33 | for (final user in DemoAppUser.values) 34 | Padding( 35 | padding: const EdgeInsets.all(16.0), 36 | child: ElevatedButton( 37 | style: ButtonStyle( 38 | backgroundColor: MaterialStateColor.resolveWith( 39 | (states) => Colors.white), 40 | padding: MaterialStateProperty.all( 41 | const EdgeInsets.symmetric(horizontal: 4.0), 42 | ), 43 | shape: MaterialStateProperty.all( 44 | RoundedRectangleBorder( 45 | borderRadius: BorderRadius.circular(24.0), 46 | ), 47 | ), 48 | ), 49 | onPressed: () async { 50 | context.removeAndShowSnackbar('Connecting user'); 51 | 52 | final success = await context.appState.connect(user); 53 | 54 | if (success) { 55 | if (!mounted) return; 56 | context.removeAndShowSnackbar('User connected'); 57 | 58 | await Navigator.of(context).pushReplacement( 59 | MaterialPageRoute( 60 | builder: (_) => const HomeScreen(), 61 | ), 62 | ); 63 | } else { 64 | if (!mounted) return; 65 | context 66 | .removeAndShowSnackbar('Could not connect user'); 67 | } 68 | }, 69 | child: Padding( 70 | padding: const EdgeInsets.symmetric( 71 | vertical: 24.0, horizontal: 36.0), 72 | child: SizedBox( 73 | width: 200, 74 | child: Text( 75 | user.name!, 76 | style: const TextStyle( 77 | fontSize: 18, 78 | color: Colors.blueGrey, 79 | ), 80 | ), 81 | ), 82 | ), 83 | ), 84 | ) 85 | ], 86 | ), 87 | ), 88 | ), 89 | ), 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /lib/components/new_post/new_post.dart: -------------------------------------------------------------------------------- 1 | export 'new_post_screen.dart'; 2 | -------------------------------------------------------------------------------- /lib/components/new_post/new_post_screen.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:image_picker/image_picker.dart'; 4 | import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; 5 | import 'package:transparent_image/transparent_image.dart'; 6 | 7 | import '../../app/app.dart'; 8 | import '../app_widgets/app_widgets.dart'; 9 | 10 | /// Screen to choose photos and add a new feed post. 11 | class NewPostScreen extends StatefulWidget { 12 | /// Create a [NewPostScreen]. 13 | const NewPostScreen({Key? key}) : super(key: key); 14 | 15 | /// Material route to this screen. 16 | static Route get route => 17 | MaterialPageRoute(builder: (_) => const NewPostScreen()); 18 | 19 | @override 20 | State createState() => _NewPostScreenState(); 21 | } 22 | 23 | class _NewPostScreenState extends State { 24 | static const double maxImageHeight = 1000; 25 | static const double maxImageWidth = 800; 26 | 27 | final _formKey = GlobalKey(); 28 | final _text = TextEditingController(); 29 | 30 | XFile? _pickedFile; 31 | bool loading = false; 32 | 33 | final picker = ImagePicker(); 34 | 35 | Future _pickFile() async { 36 | _pickedFile = await picker.pickImage( 37 | source: ImageSource.gallery, 38 | maxHeight: maxImageHeight, 39 | maxWidth: maxImageWidth, 40 | imageQuality: 70, 41 | ); 42 | setState(() {}); 43 | } 44 | 45 | Future _postImage() async { 46 | if (_pickedFile == null) { 47 | context.removeAndShowSnackbar('Please select an image first'); 48 | return; 49 | } 50 | 51 | if (!_formKey.currentState!.validate()) { 52 | context.removeAndShowSnackbar('Please enter a caption'); 53 | return; 54 | } 55 | _setLoading(true); 56 | 57 | final client = context.appState.client; 58 | final bloc = FeedProvider.of(context).bloc; 59 | 60 | var decodedImage = 61 | await decodeImageFromList(await _pickedFile!.readAsBytes()); 62 | 63 | final imageUrl = 64 | await client.images.upload(AttachmentFile(path: _pickedFile!.path)); 65 | 66 | if (imageUrl != null) { 67 | final resizedUrl = await client.images.getResized( 68 | imageUrl, 69 | const Resize(300, 300), 70 | ); 71 | 72 | if (resizedUrl != null && client.currentUser != null) { 73 | await bloc.onAddActivity( 74 | feedGroup: 'user', 75 | verb: 'post', 76 | object: 'image', 77 | data: { 78 | 'description': _text.text, 79 | 'image_url': imageUrl, 80 | 'resized_image_url': resizedUrl, 81 | 'image_width': decodedImage.width, 82 | 'image_height': decodedImage.height, 83 | 'aspect_ratio': decodedImage.width / decodedImage.height 84 | }, 85 | ); 86 | } 87 | } 88 | 89 | _setLoading(false, shouldCallSetState: false); 90 | if (!mounted) return; 91 | context.removeAndShowSnackbar('Post created!'); 92 | 93 | Navigator.of(context).pop(); 94 | } 95 | 96 | void _setLoading(bool state, {bool shouldCallSetState = true}) { 97 | if (loading != state) { 98 | loading = state; 99 | if (shouldCallSetState) { 100 | setState(() {}); 101 | } 102 | } 103 | } 104 | 105 | @override 106 | void dispose() { 107 | _text.dispose(); 108 | super.dispose(); 109 | } 110 | 111 | @override 112 | Widget build(BuildContext context) { 113 | return Scaffold( 114 | appBar: AppBar( 115 | leading: TapFadeIcon( 116 | onTap: () => Navigator.pop(context), 117 | icon: Icons.close, 118 | iconColor: Theme.of(context).appBarTheme.iconTheme!.color!, 119 | ), 120 | actions: [ 121 | Padding( 122 | padding: const EdgeInsets.all(8.0), 123 | child: Center( 124 | child: GestureDetector( 125 | onTap: _postImage, 126 | child: const Text('Share', style: AppTextStyle.textStyleAction), 127 | ), 128 | ), 129 | ) 130 | ], 131 | ), 132 | body: loading 133 | ? Center( 134 | child: Column( 135 | mainAxisAlignment: MainAxisAlignment.center, 136 | children: const [ 137 | CircularProgressIndicator(), 138 | SizedBox(height: 12), 139 | Text('Uploading...') 140 | ], 141 | ), 142 | ) 143 | : ListView( 144 | children: [ 145 | InkWell( 146 | onTap: _pickFile, 147 | child: SizedBox( 148 | height: 400, 149 | child: (_pickedFile != null) 150 | ? FadeInImage( 151 | fit: BoxFit.contain, 152 | placeholder: MemoryImage(kTransparentImage), 153 | image: Image.file(File(_pickedFile!.path)).image, 154 | ) 155 | : Container( 156 | decoration: const BoxDecoration( 157 | gradient: LinearGradient( 158 | begin: Alignment.bottomLeft, 159 | end: Alignment.topRight, 160 | colors: [ 161 | AppColors.bottomGradient, 162 | AppColors.topGradient 163 | ]), 164 | ), 165 | height: 300, 166 | child: const Center( 167 | child: Text( 168 | 'Tap to select an image', 169 | style: TextStyle( 170 | color: AppColors.light, 171 | fontSize: 18, 172 | shadows: [ 173 | Shadow( 174 | offset: Offset(2.0, 1.0), 175 | blurRadius: 3.0, 176 | color: Colors.black54, 177 | ), 178 | Shadow( 179 | offset: Offset(1.0, 1.5), 180 | blurRadius: 5.0, 181 | color: Colors.black54, 182 | ), 183 | ], 184 | ), 185 | ), 186 | ), 187 | ), 188 | ), 189 | ), 190 | const SizedBox( 191 | height: 22, 192 | ), 193 | Form( 194 | key: _formKey, 195 | child: Padding( 196 | padding: const EdgeInsets.all(8.0), 197 | child: TextFormField( 198 | controller: _text, 199 | decoration: const InputDecoration( 200 | hintText: 'Write a caption', 201 | border: InputBorder.none, 202 | ), 203 | validator: (text) { 204 | if (text == null || text.isEmpty) { 205 | return 'Caption is empty'; 206 | } 207 | return null; 208 | }, 209 | ), 210 | ), 211 | ), 212 | ], 213 | ), 214 | ); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /lib/components/profile/edit_profile_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:image_picker/image_picker.dart'; 3 | import 'package:provider/provider.dart'; 4 | 5 | import '../../app/app.dart'; 6 | import '../app_widgets/app_widgets.dart'; 7 | 8 | /// {@template edit_profile_page} 9 | /// Screen to edit a user's profile info. 10 | /// {@endtemplate} 11 | class EditProfileScreen extends StatelessWidget { 12 | /// {@macro edit_profile_page} 13 | const EditProfileScreen({ 14 | Key? key, 15 | }) : super(key: key); 16 | 17 | /// Custom route to this screen. Animates from the bottom up. 18 | static Route get route => PageRouteBuilder( 19 | pageBuilder: (context, animation, secondaryAnimation) => 20 | const EditProfileScreen(), 21 | transitionsBuilder: (context, animation, secondaryAnimation, child) { 22 | final tween = Tween(begin: const Offset(0.0, 1.0), end: Offset.zero) 23 | .chain(CurveTween(curve: Curves.easeOutQuint)); 24 | final offsetAnimation = animation.drive(tween); 25 | return SlideTransition( 26 | position: offsetAnimation, 27 | child: child, 28 | ); 29 | }, 30 | ); 31 | 32 | @override 33 | Widget build(BuildContext context) { 34 | final streamagramUser = context 35 | .select((value) => value.streamagramUser); 36 | if (streamagramUser == null) { 37 | return const Scaffold( 38 | body: Center( 39 | child: Text('You should not see this.\nUser data is empty.'), 40 | ), 41 | ); 42 | } 43 | return Scaffold( 44 | appBar: AppBar( 45 | leading: TextButton( 46 | onPressed: () { 47 | Navigator.of(context).pop(); 48 | }, 49 | child: Text( 50 | 'Cancel', 51 | style: (Theme.of(context).brightness == Brightness.dark) 52 | ? const TextStyle(color: AppColors.light) 53 | : const TextStyle(color: AppColors.dark), 54 | ), 55 | ), 56 | leadingWidth: 80, 57 | title: const Text( 58 | ' Edit profile', 59 | style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600), 60 | ), 61 | actions: [ 62 | TextButton( 63 | onPressed: () { 64 | Navigator.of(context).pop(); 65 | }, 66 | child: const Text('Done'), 67 | ), 68 | ], 69 | ), 70 | body: ListView( 71 | children: [ 72 | const _ChangeProfilePictureButton(), 73 | const Divider( 74 | color: Colors.grey, 75 | ), 76 | Padding( 77 | padding: const EdgeInsets.all(8), 78 | child: Row( 79 | children: [ 80 | const SizedBox( 81 | width: 100, 82 | child: Text( 83 | 'Name', 84 | style: AppTextStyle.textStyleBoldMedium, 85 | ), 86 | ), 87 | Text( 88 | '${streamagramUser.fullName} ', 89 | style: AppTextStyle.textStyleBoldMedium, 90 | ), 91 | ], 92 | ), 93 | ), 94 | Padding( 95 | padding: const EdgeInsets.all(8), 96 | child: Row( 97 | children: [ 98 | const SizedBox( 99 | width: 100, 100 | child: Text( 101 | 'Username', 102 | style: AppTextStyle.textStyleBoldMedium, 103 | ), 104 | ), 105 | Text( 106 | '${context.appState.user.id} ', 107 | style: AppTextStyle.textStyleBoldMedium, 108 | ), 109 | ], 110 | ), 111 | ), 112 | const Divider(color: Colors.grey), 113 | ], 114 | ), 115 | ); 116 | } 117 | } 118 | 119 | class _ChangeProfilePictureButton extends StatefulWidget { 120 | const _ChangeProfilePictureButton({ 121 | Key? key, 122 | }) : super(key: key); 123 | 124 | @override 125 | __ChangeProfilePictureButtonState createState() => 126 | __ChangeProfilePictureButtonState(); 127 | } 128 | 129 | class __ChangeProfilePictureButtonState 130 | extends State<_ChangeProfilePictureButton> { 131 | final _picker = ImagePicker(); 132 | 133 | Future _changePicture() async { 134 | if (context.appState.isUploadingProfilePicture == true) { 135 | return; 136 | } 137 | 138 | final pickedFile = await _picker.pickImage( 139 | source: ImageSource.gallery, 140 | maxWidth: 800, 141 | maxHeight: 800, 142 | imageQuality: 70, 143 | ); 144 | if (pickedFile != null) { 145 | await context.appState.updateProfilePhoto(pickedFile.path); 146 | } else { 147 | if (!mounted) return; 148 | context.removeAndShowSnackbar('No picture selected'); 149 | } 150 | } 151 | 152 | @override 153 | Widget build(BuildContext context) { 154 | final streamagramUser = context 155 | .select((value) => value.streamagramUser!); 156 | final isUploadingProfilePicture = context 157 | .select((value) => value.isUploadingProfilePicture); 158 | return Padding( 159 | padding: const EdgeInsets.all(8.0), 160 | child: Column( 161 | mainAxisSize: MainAxisSize.min, 162 | children: [ 163 | SizedBox( 164 | height: 150, 165 | child: Center( 166 | child: isUploadingProfilePicture 167 | ? const CircularProgressIndicator() 168 | : GestureDetector( 169 | onTap: _changePicture, 170 | child: Avatar.huge(streamagramUser: streamagramUser), 171 | ), 172 | ), 173 | ), 174 | GestureDetector( 175 | onTap: _changePicture, 176 | child: const Text('Change Profile Photo', 177 | style: AppTextStyle.textStyleAction), 178 | ), 179 | ], 180 | ), 181 | ); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /lib/components/profile/profile.dart: -------------------------------------------------------------------------------- 1 | export 'edit_profile_screen.dart'; 2 | export 'profile_page.dart'; 3 | -------------------------------------------------------------------------------- /lib/components/profile/profile_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:provider/provider.dart'; 4 | import 'package:stream_agram/components/profile/edit_profile_screen.dart'; 5 | import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; 6 | 7 | import '../../app/app.dart'; 8 | import '../app_widgets/app_widgets.dart'; 9 | import '../new_post/new_post.dart'; 10 | 11 | /// {@template profile_page} 12 | /// User profile page. List of user created posts. 13 | /// {@endtemplate} 14 | class ProfilePage extends StatefulWidget { 15 | /// {@macro profile_page} 16 | const ProfilePage({Key? key}) : super(key: key); 17 | 18 | @override 19 | State createState() => _ProfilePageState(); 20 | } 21 | 22 | class _ProfilePageState extends State { 23 | bool _isPaginating = false; 24 | 25 | static const _feedGroup = 'user'; 26 | 27 | Future _loadMore() async { 28 | // Ensure we're not already loading more activities. 29 | if (!_isPaginating) { 30 | _isPaginating = true; 31 | context.feedBloc 32 | .loadMoreEnrichedActivities(feedGroup: _feedGroup) 33 | .whenComplete(() { 34 | _isPaginating = false; 35 | }); 36 | } 37 | } 38 | 39 | @override 40 | Widget build(BuildContext context) { 41 | return FlatFeedCore( 42 | feedGroup: _feedGroup, 43 | limit: 12, 44 | loadingBuilder: (context) => 45 | const Center(child: CircularProgressIndicator()), 46 | errorBuilder: (context, error) => const Center( 47 | child: Text('Error loading profile'), 48 | ), 49 | emptyBuilder: (context) => const CustomScrollView( 50 | slivers: [ 51 | SliverToBoxAdapter( 52 | child: _ProfileHeader( 53 | numberOfPosts: 0, 54 | ), 55 | ), 56 | SliverToBoxAdapter( 57 | child: _EditProfileButton(), 58 | ), 59 | SliverFillRemaining(child: _NoPostsMessage()) 60 | ], 61 | ), 62 | feedBuilder: (context, activities) { 63 | return RefreshIndicator( 64 | onRefresh: () async { 65 | // Refresh follow counts 66 | await FeedProvider.of(context) 67 | .bloc 68 | .currentUser! 69 | .get(withFollowCounts: true); 70 | // Refresh activities 71 | if (!mounted) return; 72 | return FeedProvider.of(context) 73 | .bloc 74 | .refreshPaginatedEnrichedActivities(feedGroup: _feedGroup); 75 | }, 76 | child: CustomScrollView( 77 | slivers: [ 78 | SliverToBoxAdapter( 79 | child: _ProfileHeader( 80 | numberOfPosts: activities.length, 81 | ), 82 | ), 83 | const SliverToBoxAdapter( 84 | child: _EditProfileButton(), 85 | ), 86 | const SliverToBoxAdapter( 87 | child: SizedBox(height: 24), 88 | ), 89 | SliverGrid( 90 | gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 91 | crossAxisCount: 3, 92 | crossAxisSpacing: 1, 93 | mainAxisSpacing: 1, 94 | ), 95 | delegate: SliverChildBuilderDelegate( 96 | (context, index) { 97 | // Pagination (Infinite scroll) 98 | bool shouldLoadMore = activities.length - 3 == index; 99 | if (shouldLoadMore) { 100 | _loadMore(); 101 | } 102 | final activity = activities[index]; 103 | final url = 104 | activity.extraData!['resized_image_url'] as String; 105 | return GestureDetector( 106 | onTap: () { 107 | Navigator.of(context).push( 108 | HeroDialogRoute( 109 | builder: (context) { 110 | return _PictureViewer(activity: activity); 111 | }, 112 | ), 113 | ); 114 | }, 115 | child: Hero( 116 | tag: 'hero-image-${activity.id}', 117 | child: CachedNetworkImage( 118 | key: ValueKey('image-${activity.id}'), 119 | width: 200, 120 | height: 200, 121 | fit: BoxFit.cover, 122 | imageUrl: url, 123 | ), 124 | ), 125 | ); 126 | }, 127 | childCount: activities.length, 128 | ), 129 | ) 130 | ], 131 | ), 132 | ); 133 | }, 134 | ); 135 | } 136 | } 137 | 138 | class _EditProfileButton extends StatelessWidget { 139 | const _EditProfileButton({Key? key}) : super(key: key); 140 | 141 | @override 142 | Widget build(BuildContext context) { 143 | return Padding( 144 | padding: const EdgeInsets.symmetric(horizontal: 8.0), 145 | child: OutlinedButton( 146 | onPressed: () { 147 | Navigator.of(context).push(EditProfileScreen.route); 148 | }, 149 | child: const Text('Edit Profile'), 150 | ), 151 | ); 152 | } 153 | } 154 | 155 | class _ProfileHeader extends StatelessWidget { 156 | const _ProfileHeader({ 157 | Key? key, 158 | required this.numberOfPosts, 159 | }) : super(key: key); 160 | 161 | final int numberOfPosts; 162 | 163 | static const _statitisticsPadding = 164 | EdgeInsets.symmetric(horizontal: 12, vertical: 8.0); 165 | 166 | @override 167 | Widget build(BuildContext context) { 168 | final feedState = context.watch(); 169 | final streamagramUser = feedState.streamagramUser; 170 | if (streamagramUser == null) return const SizedBox.shrink(); 171 | return Column( 172 | children: [ 173 | Row( 174 | children: [ 175 | Padding( 176 | padding: const EdgeInsets.all(8.0), 177 | child: Avatar.big( 178 | streamagramUser: streamagramUser, 179 | ), 180 | ), 181 | const Spacer(), 182 | Row( 183 | mainAxisSize: MainAxisSize.min, 184 | children: [ 185 | Padding( 186 | padding: _statitisticsPadding, 187 | child: Column( 188 | children: [ 189 | Text( 190 | '$numberOfPosts', 191 | style: AppTextStyle.textStyleBold, 192 | ), 193 | const Text( 194 | 'Posts', 195 | style: AppTextStyle.textStyleLight, 196 | ), 197 | ], 198 | ), 199 | ), 200 | Padding( 201 | padding: _statitisticsPadding, 202 | child: Column( 203 | children: [ 204 | Text( 205 | '${FeedProvider.of(context).bloc.currentUser?.followersCount ?? 0}', 206 | style: AppTextStyle.textStyleBold, 207 | ), 208 | const Text( 209 | 'Followers', 210 | style: AppTextStyle.textStyleLight, 211 | ), 212 | ], 213 | ), 214 | ), 215 | Padding( 216 | padding: _statitisticsPadding, 217 | child: Column( 218 | children: [ 219 | Text( 220 | '${FeedProvider.of(context).bloc.currentUser?.followingCount ?? 0}', 221 | style: AppTextStyle.textStyleBold, 222 | ), 223 | const Text( 224 | 'Following', 225 | style: AppTextStyle.textStyleLight, 226 | ), 227 | ], 228 | ), 229 | ), 230 | ], 231 | ), 232 | ], 233 | ), 234 | Align( 235 | alignment: Alignment.centerLeft, 236 | child: Padding( 237 | padding: const EdgeInsets.all(8.0), 238 | child: Text(streamagramUser.fullName, 239 | style: AppTextStyle.textStyleBoldMedium), 240 | ), 241 | ), 242 | ], 243 | ); 244 | } 245 | } 246 | 247 | class _NoPostsMessage extends StatelessWidget { 248 | const _NoPostsMessage({ 249 | Key? key, 250 | }) : super(key: key); 251 | 252 | @override 253 | Widget build(BuildContext context) { 254 | return Column( 255 | mainAxisAlignment: MainAxisAlignment.center, 256 | children: [ 257 | const Text('This is too empty'), 258 | const SizedBox(height: 12), 259 | ElevatedButton( 260 | onPressed: () { 261 | Navigator.of(context).push(NewPostScreen.route); // ADD THIS 262 | }, 263 | child: const Text('Add a post'), 264 | ) 265 | ], 266 | ); 267 | } 268 | } 269 | 270 | class _PictureViewer extends StatelessWidget { 271 | const _PictureViewer({ 272 | Key? key, 273 | required this.activity, 274 | }) : super(key: key); 275 | 276 | final EnrichedActivity activity; 277 | 278 | @override 279 | Widget build(BuildContext context) { 280 | final resizedUrl = activity.extraData!['resized_image_url'] as String?; 281 | final fullSizeUrl = activity.extraData!['image_url'] as String; 282 | final aspectRatio = activity.extraData!['aspect_ratio'] as double?; 283 | 284 | return Scaffold( 285 | appBar: AppBar( 286 | elevation: 0, 287 | backgroundColor: Colors.transparent, 288 | ), 289 | extendBodyBehindAppBar: true, 290 | body: InteractiveViewer( 291 | child: Center( 292 | child: Hero( 293 | tag: 'hero-image-${activity.id}', 294 | createRectTween: (begin, end) { 295 | return CustomRectTween(begin: begin, end: end); 296 | }, 297 | child: AspectRatio( 298 | aspectRatio: aspectRatio ?? 1, 299 | child: CachedNetworkImage( 300 | fadeInDuration: Duration.zero, 301 | placeholder: (resizedUrl != null) 302 | ? (context, url) => CachedNetworkImage( 303 | imageBuilder: (context, imageProvider) => 304 | DecoratedBox( 305 | decoration: BoxDecoration( 306 | image: DecorationImage( 307 | image: imageProvider, 308 | fit: BoxFit.contain, 309 | ), 310 | ), 311 | ), 312 | imageUrl: resizedUrl, 313 | ) 314 | : null, 315 | imageBuilder: (context, imageProvider) => DecoratedBox( 316 | decoration: BoxDecoration( 317 | image: DecorationImage( 318 | image: imageProvider, 319 | fit: BoxFit.contain, 320 | ), 321 | ), 322 | ), 323 | imageUrl: fullSizeUrl, 324 | ), 325 | ), 326 | ), 327 | ), 328 | ), 329 | ); 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /lib/components/search/search.dart: -------------------------------------------------------------------------------- 1 | export 'search_page.dart'; 2 | -------------------------------------------------------------------------------- /lib/components/search/search_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; 3 | 4 | import '../../app/app.dart'; 5 | import '../app_widgets/app_widgets.dart'; 6 | 7 | /// Page to find other users and follow/unfollow. 8 | class SearchPage extends StatelessWidget { 9 | /// Create a new [SearchPage]. 10 | const SearchPage({Key? key}) : super(key: key); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | final users = List.from(DemoAppUser.values) 15 | ..removeWhere((it) => it.id == context.appState.user.id); 16 | return ListView.builder( 17 | itemCount: users.length, 18 | itemBuilder: (context, index) { 19 | return _UserProfile(userId: users[index].id); 20 | }, 21 | ); 22 | } 23 | } 24 | 25 | class _UserProfile extends StatefulWidget { 26 | const _UserProfile({ 27 | Key? key, 28 | required this.userId, 29 | }) : super(key: key); 30 | 31 | final String userId; 32 | 33 | @override 34 | __UserProfileState createState() => __UserProfileState(); 35 | } 36 | 37 | class __UserProfileState extends State<_UserProfile> { 38 | late StreamUser streamUser; 39 | late bool isFollowing; 40 | late Future userDataFuture = getUser(); 41 | 42 | Future getUser() async { 43 | final userClient = context.appState.client.user(widget.userId); 44 | final futures = await Future.wait([ 45 | userClient.get(), 46 | _isFollowingUser(widget.userId), 47 | ]); 48 | streamUser = futures[0] as StreamUser; 49 | isFollowing = futures[1] as bool; 50 | 51 | return StreamagramUser.fromMap(streamUser.data!); 52 | } 53 | 54 | /// Determine if the current authenticated user is following [user]. 55 | Future _isFollowingUser(String userId) async { 56 | return FeedProvider.of(context).bloc.isFollowingFeed(followerId: userId); 57 | } 58 | 59 | @override 60 | Widget build(BuildContext context) { 61 | return FutureBuilder( 62 | future: userDataFuture, 63 | builder: (context, snapshot) { 64 | switch (snapshot.connectionState) { 65 | case ConnectionState.waiting: 66 | return const SizedBox.shrink(); 67 | default: 68 | if (snapshot.hasError) { 69 | return const Padding( 70 | padding: EdgeInsets.all(8.0), 71 | child: Text('Could not load profile'), 72 | ); 73 | } else { 74 | final userData = snapshot.data; 75 | if (userData != null) { 76 | return _ProfileTile( 77 | user: streamUser, 78 | userData: userData, 79 | isFollowing: isFollowing, 80 | ); 81 | } 82 | return const SizedBox.shrink(); 83 | } 84 | } 85 | }, 86 | ); 87 | } 88 | } 89 | 90 | class _ProfileTile extends StatefulWidget { 91 | const _ProfileTile({ 92 | Key? key, 93 | required this.user, 94 | required this.userData, 95 | required this.isFollowing, 96 | }) : super(key: key); 97 | 98 | final StreamUser user; 99 | final StreamagramUser userData; 100 | final bool isFollowing; 101 | 102 | @override 103 | __ProfileTileState createState() => __ProfileTileState(); 104 | } 105 | 106 | class __ProfileTileState extends State<_ProfileTile> { 107 | bool _isLoading = false; 108 | late bool _isFollowing = widget.isFollowing; 109 | 110 | Future followOrUnfollowUser(BuildContext context) async { 111 | setState(() { 112 | _isLoading = true; 113 | }); 114 | final bloc = FeedProvider.of(context).bloc; 115 | if (_isFollowing) { 116 | final bloc = FeedProvider.of(context).bloc; 117 | await bloc.unfollowFeed(unfolloweeId: widget.user.id); 118 | _isFollowing = false; 119 | } else { 120 | await FeedProvider.of(context) 121 | .bloc 122 | .followFeed(followeeId: widget.user.id); 123 | _isFollowing = true; 124 | } 125 | bloc.refreshPaginatedEnrichedActivities( 126 | feedGroup: 'timeline', 127 | flags: EnrichmentFlags() 128 | ..withOwnReactions() 129 | ..withRecentReactions() 130 | ..withReactionCounts(), 131 | ); 132 | 133 | setState(() { 134 | _isLoading = false; 135 | }); 136 | } 137 | 138 | @override 139 | Widget build(BuildContext context) { 140 | return Row( 141 | children: [ 142 | Padding( 143 | padding: const EdgeInsets.all(8.0), 144 | child: Avatar.medium(streamagramUser: widget.userData), 145 | ), 146 | Padding( 147 | padding: const EdgeInsets.all(8.0), 148 | child: Column( 149 | crossAxisAlignment: CrossAxisAlignment.start, 150 | children: [ 151 | Text(widget.user.id, style: AppTextStyle.textStyleBold), 152 | Text( 153 | widget.userData.fullName, 154 | style: AppTextStyle.textStyleFaded, 155 | ), 156 | ], 157 | ), 158 | ), 159 | const Spacer(), 160 | Padding( 161 | padding: const EdgeInsets.symmetric(horizontal: 8.0), 162 | child: _isLoading 163 | ? const CircularProgressIndicator(strokeWidth: 3) 164 | : OutlinedButton( 165 | onPressed: () { 166 | followOrUnfollowUser(context); 167 | }, 168 | child: _isFollowing 169 | ? const Text('Unfollow') 170 | : const Text('Follow'), 171 | ), 172 | ) 173 | ], 174 | ); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /lib/components/timeline/timeline.dart: -------------------------------------------------------------------------------- 1 | export 'widgets/widgets.dart'; 2 | export 'timeline_page.dart'; 3 | -------------------------------------------------------------------------------- /lib/components/timeline/timeline_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:stream_agram/app/app.dart'; 3 | import 'package:stream_agram/components/app_widgets/app_widgets.dart'; 4 | import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; 5 | 6 | import 'widgets/widgets.dart'; 7 | 8 | /// {@template timeline_page} 9 | /// Page to display a timeline of user created posts. Global 'timeline' 10 | /// {@endtemplate} 11 | class TimelinePage extends StatefulWidget { 12 | /// {@macro timeline_page} 13 | const TimelinePage({Key? key}) : super(key: key); 14 | 15 | @override 16 | State createState() => _TimelinePageState(); 17 | } 18 | 19 | class _TimelinePageState extends State { 20 | final ValueNotifier _showCommentBox = ValueNotifier(false); 21 | final TextEditingController _commentTextController = TextEditingController(); 22 | final FocusNode _commentFocusNode = FocusNode(); 23 | EnrichedActivity? activeActivity; 24 | bool _isPaginating = false; 25 | 26 | static const _feedGroup = 'timeline'; 27 | static final _flags = EnrichmentFlags() 28 | ..withOwnReactions() 29 | ..withRecentReactions() 30 | ..withReactionCounts(); 31 | 32 | void openCommentBox(EnrichedActivity activity, {String? message}) { 33 | _commentTextController.text = message ?? ''; 34 | _commentTextController.selection = TextSelection.fromPosition( 35 | TextPosition(offset: _commentTextController.text.length)); 36 | activeActivity = activity; 37 | _showCommentBox.value = true; 38 | _commentFocusNode.requestFocus(); 39 | } 40 | 41 | Future addComment(String? message) async { 42 | if (activeActivity != null && 43 | message != null && 44 | message.isNotEmpty && 45 | message != '') { 46 | await FeedProvider.of(context).bloc.onAddReaction( 47 | kind: 'comment', 48 | activity: activeActivity!, 49 | feedGroup: 'timeline', 50 | data: {'message': message}, 51 | ); 52 | _commentTextController.clear(); 53 | if (!mounted) return; 54 | FocusScope.of(context).unfocus(); 55 | _showCommentBox.value = false; 56 | } 57 | } 58 | 59 | Future _loadMore() async { 60 | // Ensure we're not already loading more activities. 61 | if (!_isPaginating) { 62 | _isPaginating = true; 63 | context.feedBloc 64 | .loadMoreEnrichedActivities(feedGroup: _feedGroup, flags: _flags) 65 | .whenComplete(() { 66 | _isPaginating = false; 67 | }); 68 | } 69 | } 70 | 71 | @override 72 | void dispose() { 73 | _commentTextController.dispose(); 74 | _commentFocusNode.dispose(); 75 | super.dispose(); 76 | } 77 | 78 | @override 79 | Widget build(BuildContext context) { 80 | return GestureDetector( 81 | onTap: () { 82 | FocusScope.of(context).unfocus(); 83 | _showCommentBox.value = false; 84 | }, 85 | child: Stack( 86 | children: [ 87 | FlatFeedCore( 88 | feedGroup: _feedGroup, 89 | errorBuilder: (context, error) => 90 | const Text('Could not load profile'), 91 | loadingBuilder: (context) => const SizedBox(), 92 | emptyBuilder: (context) => const Center( 93 | child: Text('No Posts\nGo and post something'), 94 | ), 95 | flags: _flags, 96 | feedBuilder: (context, activities) { 97 | return RefreshIndicator( 98 | onRefresh: () { 99 | return FeedProvider.of(context) 100 | .bloc 101 | .refreshPaginatedEnrichedActivities( 102 | feedGroup: 'timeline', 103 | flags: EnrichmentFlags() 104 | ..withOwnReactions() 105 | ..withRecentReactions() 106 | ..withReactionCounts(), 107 | ); 108 | }, 109 | child: ListView.builder( 110 | itemCount: activities.length, 111 | itemBuilder: (context, index) { 112 | // Pagination (Infinite scroll) 113 | bool shouldLoadMore = activities.length - 3 == index; 114 | if (shouldLoadMore) { 115 | _loadMore(); 116 | } 117 | 118 | return PostCard( 119 | key: ValueKey('post-${activities[index].id}'), 120 | enrichedActivity: activities[index], 121 | onAddComment: openCommentBox, 122 | ); 123 | }, 124 | ), 125 | ); 126 | }, 127 | ), 128 | _CommentBox( 129 | commenter: context.appState.streamagramUser!, 130 | textEditingController: _commentTextController, 131 | focusNode: _commentFocusNode, 132 | addComment: addComment, 133 | showCommentBox: _showCommentBox, 134 | ) 135 | ], 136 | ), 137 | ); 138 | } 139 | } 140 | 141 | class _CommentBox extends StatefulWidget { 142 | const _CommentBox({ 143 | Key? key, 144 | required this.commenter, 145 | required this.textEditingController, 146 | required this.focusNode, 147 | required this.addComment, 148 | required this.showCommentBox, 149 | }) : super(key: key); 150 | 151 | final StreamagramUser commenter; 152 | final TextEditingController textEditingController; 153 | final FocusNode focusNode; 154 | final Function(String?) addComment; 155 | final ValueNotifier showCommentBox; 156 | 157 | @override 158 | __CommentBoxState createState() => __CommentBoxState(); 159 | } 160 | 161 | class __CommentBoxState extends State<_CommentBox> 162 | with SingleTickerProviderStateMixin { 163 | late final AnimationController _controller; 164 | 165 | late final Animation _animation = CurvedAnimation( 166 | parent: _controller, 167 | curve: Curves.easeOut, 168 | reverseCurve: Curves.easeIn, 169 | ); 170 | 171 | bool visibility = false; 172 | 173 | @override 174 | void initState() { 175 | super.initState(); 176 | _controller = AnimationController( 177 | vsync: this, 178 | duration: const Duration(milliseconds: 300), 179 | ); 180 | _controller.addStatusListener((status) { 181 | if (status == AnimationStatus.dismissed) { 182 | setState(() { 183 | visibility = false; 184 | }); 185 | } else { 186 | setState(() { 187 | visibility = true; 188 | }); 189 | } 190 | }); 191 | widget.showCommentBox.addListener(_showHideCommentBox); 192 | } 193 | 194 | void _showHideCommentBox() { 195 | if (widget.showCommentBox.value) { 196 | _controller.forward(); 197 | } else { 198 | _controller.reverse(); 199 | } 200 | } 201 | 202 | @override 203 | void dispose() { 204 | super.dispose(); 205 | _controller.dispose(); 206 | } 207 | 208 | @override 209 | Widget build(BuildContext context) { 210 | return Visibility( 211 | visible: visibility, 212 | child: FadeTransition( 213 | opacity: _animation, 214 | child: Builder(builder: (context) { 215 | return Align( 216 | alignment: Alignment.bottomCenter, 217 | child: CommentBox( 218 | commenter: widget.commenter, 219 | textEditingController: widget.textEditingController, 220 | focusNode: widget.focusNode, 221 | onSubmitted: widget.addComment, 222 | ), 223 | ); 224 | }), 225 | ), 226 | ); 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /lib/components/timeline/widgets/post_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:jiffy/jiffy.dart'; 4 | import 'package:provider/provider.dart'; 5 | import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; 6 | 7 | import '../../../app/app.dart'; 8 | import '../../app_widgets/app_widgets.dart'; 9 | import '../../comments/comments_screen.dart'; 10 | 11 | typedef OnAddComment = void Function( 12 | EnrichedActivity activity, { 13 | String? message, 14 | }); 15 | 16 | /// {@template post_card} 17 | /// A card that displays a user post/activity. 18 | /// {@endtemplate} 19 | class PostCard extends StatelessWidget { 20 | /// {@macro post_card} 21 | const PostCard({ 22 | Key? key, 23 | required this.enrichedActivity, 24 | required this.onAddComment, 25 | }) : super(key: key); 26 | 27 | /// Enriched activity (post) to display. 28 | final EnrichedActivity enrichedActivity; 29 | final OnAddComment onAddComment; 30 | 31 | @override 32 | Widget build(BuildContext context) { 33 | final actorData = enrichedActivity.actor!.data; 34 | final userData = StreamagramUser.fromMap(actorData as Map); 35 | 36 | return Column( 37 | crossAxisAlignment: CrossAxisAlignment.start, 38 | children: [ 39 | _ProfileSlab( 40 | userData: userData, 41 | ), 42 | _PictureCarousal( 43 | enrichedActivity: enrichedActivity, 44 | ), 45 | _Description( 46 | enrichedActivity: enrichedActivity, 47 | ), 48 | _InteractiveCommentSlab( 49 | enrichedActivity: enrichedActivity, 50 | onAddComment: onAddComment, 51 | ), 52 | ], 53 | ); 54 | } 55 | } 56 | 57 | class _PictureCarousal extends StatefulWidget { 58 | const _PictureCarousal({ 59 | Key? key, 60 | required this.enrichedActivity, 61 | }) : super(key: key); 62 | 63 | final EnrichedActivity enrichedActivity; 64 | 65 | @override 66 | __PictureCarousalState createState() => __PictureCarousalState(); 67 | } 68 | 69 | class __PictureCarousalState extends State<_PictureCarousal> { 70 | late var likeReactions = getLikeReactions() ?? []; 71 | late var likeCount = getLikeCount() ?? 0; 72 | 73 | Reaction? latestLikeReaction; 74 | 75 | List? getLikeReactions() { 76 | return widget.enrichedActivity.latestReactions?['like'] ?? []; 77 | } 78 | 79 | int? getLikeCount() { 80 | return widget.enrichedActivity.reactionCounts?['like'] ?? 0; 81 | } 82 | 83 | Future _addLikeReaction() async { 84 | latestLikeReaction = await context.appState.client.reactions.add( 85 | 'like', 86 | widget.enrichedActivity.id!, 87 | userId: context.appState.user.id, 88 | ); 89 | 90 | setState(() { 91 | likeReactions.add(latestLikeReaction!); 92 | likeCount++; 93 | }); 94 | } 95 | 96 | Future _removeLikeReaction() async { 97 | late String? reactionId; 98 | // A new reaction was added to this state. 99 | if (latestLikeReaction != null) { 100 | reactionId = latestLikeReaction?.id; 101 | } else { 102 | // An old reaction has been retrieved from Stream. 103 | final prevReaction = widget.enrichedActivity.ownReactions?['like']; 104 | if (prevReaction != null && prevReaction.isNotEmpty) { 105 | reactionId = prevReaction[0].id; 106 | } 107 | } 108 | 109 | try { 110 | if (reactionId != null) { 111 | await context.appState.client.reactions.delete(reactionId); 112 | } 113 | } catch (e) { 114 | debugPrint(e.toString()); 115 | } 116 | setState(() { 117 | likeReactions.removeWhere((element) => element.id == reactionId); 118 | likeCount--; 119 | latestLikeReaction = null; 120 | }); 121 | } 122 | 123 | @override 124 | Widget build(BuildContext context) { 125 | return Column( 126 | crossAxisAlignment: CrossAxisAlignment.start, 127 | children: [ 128 | ..._pictureCarousel(context), 129 | _likes(), 130 | ], 131 | ); 132 | } 133 | 134 | /// Picture carousal and interaction buttons. 135 | List _pictureCarousel(BuildContext context) { 136 | const iconPadding = EdgeInsets.symmetric(horizontal: 8, vertical: 4); 137 | var imageUrl = widget.enrichedActivity.extraData!['image_url'] as String; 138 | double aspectRatio = 139 | widget.enrichedActivity.extraData!['aspect_ratio'] as double? ?? 1.0; 140 | final iconColor = Theme.of(context).iconTheme.color!; 141 | return [ 142 | Padding( 143 | padding: const EdgeInsets.symmetric(vertical: 8.0), 144 | child: Center( 145 | child: ConstrainedBox( 146 | constraints: const BoxConstraints(maxHeight: 500), 147 | child: AspectRatio( 148 | aspectRatio: aspectRatio, 149 | child: CachedNetworkImage( 150 | imageUrl: imageUrl, 151 | ), 152 | ), 153 | ), 154 | ), 155 | ), 156 | Row( 157 | children: [ 158 | const SizedBox( 159 | width: 4, 160 | ), 161 | Padding( 162 | padding: iconPadding, 163 | child: FavoriteIconButton( 164 | isLiked: widget.enrichedActivity.ownReactions?['like'] != null, 165 | onTap: (liked) { 166 | if (liked) { 167 | _addLikeReaction(); 168 | } else { 169 | _removeLikeReaction(); 170 | } 171 | }, 172 | ), 173 | ), 174 | Padding( 175 | padding: iconPadding, 176 | child: TapFadeIcon( 177 | onTap: () { 178 | // ADD THIS 179 | final map = widget.enrichedActivity.actor!.data!; 180 | 181 | // AND THIS 182 | Navigator.of(context).push( 183 | CommentsScreen.route( 184 | enrichedActivity: widget.enrichedActivity, 185 | activityOwnerData: StreamagramUser.fromMap(map), 186 | ), 187 | ); 188 | }, 189 | icon: Icons.chat_bubble_outline, 190 | iconColor: iconColor, 191 | ), 192 | ), 193 | Padding( 194 | padding: iconPadding, 195 | child: TapFadeIcon( 196 | onTap: () => 197 | context.removeAndShowSnackbar('Message: Not yet implemented'), 198 | icon: Icons.call_made, 199 | iconColor: iconColor, 200 | ), 201 | ), 202 | const Spacer(), 203 | Padding( 204 | padding: iconPadding, 205 | child: TapFadeIcon( 206 | onTap: () => context 207 | .removeAndShowSnackbar('Bookmark: Not yet implemented'), 208 | icon: Icons.bookmark_border, 209 | iconColor: iconColor, 210 | ), 211 | ), 212 | ], 213 | ) 214 | ]; 215 | } 216 | 217 | Widget _likes() { 218 | if (likeReactions.isNotEmpty) { 219 | return Padding( 220 | padding: const EdgeInsets.only(left: 16.0, top: 8), 221 | child: Text.rich( 222 | TextSpan( 223 | text: 'Liked by ', 224 | style: AppTextStyle.textStyleLight, 225 | children: [ 226 | TextSpan( 227 | text: StreamagramUser.fromMap( 228 | likeReactions[0].user?.data as Map) 229 | .fullName, 230 | style: AppTextStyle.textStyleBold), 231 | if (likeCount > 1 && likeCount < 3) ...[ 232 | const TextSpan(text: ' and '), 233 | TextSpan( 234 | text: StreamagramUser.fromMap( 235 | likeReactions[1].user?.data as Map) 236 | .fullName, 237 | style: AppTextStyle.textStyleBold), 238 | ], 239 | if (likeCount > 3) ...[ 240 | const TextSpan(text: ' and '), 241 | const TextSpan( 242 | text: 'others', style: AppTextStyle.textStyleBold), 243 | ], 244 | ], 245 | ), 246 | ), 247 | ); 248 | } else { 249 | return const SizedBox.shrink(); 250 | } 251 | } 252 | } 253 | 254 | class _Description extends StatelessWidget { 255 | const _Description({ 256 | Key? key, 257 | required this.enrichedActivity, 258 | }) : super(key: key); 259 | 260 | final EnrichedActivity enrichedActivity; 261 | 262 | @override 263 | Widget build(BuildContext context) { 264 | return Padding( 265 | padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6), 266 | child: Text.rich( 267 | TextSpan( 268 | children: [ 269 | TextSpan( 270 | text: enrichedActivity.actor!.id!, 271 | style: AppTextStyle.textStyleBold), 272 | const TextSpan(text: ' '), 273 | TextSpan( 274 | text: enrichedActivity.extraData?['description'] as String? ?? 275 | ''), 276 | ], 277 | ), 278 | ), 279 | ); 280 | } 281 | } 282 | 283 | class _InteractiveCommentSlab extends StatefulWidget { 284 | const _InteractiveCommentSlab({ 285 | Key? key, 286 | required this.enrichedActivity, 287 | required this.onAddComment, 288 | }) : super(key: key); 289 | 290 | final EnrichedActivity enrichedActivity; 291 | final OnAddComment onAddComment; 292 | 293 | @override 294 | _InteractiveCommentSlabState createState() => _InteractiveCommentSlabState(); 295 | } 296 | 297 | class _InteractiveCommentSlabState extends State<_InteractiveCommentSlab> { 298 | EnrichedActivity get enrichedActivity => widget.enrichedActivity; 299 | 300 | late final String _timeSinceMessage = 301 | Jiffy(widget.enrichedActivity.time).fromNow(); 302 | 303 | List get _commentReactions => 304 | enrichedActivity.latestReactions?['comment'] ?? []; 305 | 306 | int get _commentCount => enrichedActivity.reactionCounts?['comment'] ?? 0; 307 | 308 | @override 309 | Widget build(BuildContext context) { 310 | const textPadding = EdgeInsets.all(8); 311 | const spacePadding = EdgeInsets.only(left: 20.0, top: 8); 312 | final comments = _commentReactions; 313 | final commentCount = _commentCount; 314 | return Column( 315 | crossAxisAlignment: CrossAxisAlignment.start, 316 | children: [ 317 | if (commentCount > 0 && comments.isNotEmpty) 318 | Padding( 319 | padding: spacePadding, 320 | child: Text.rich( 321 | TextSpan( 322 | children: [ 323 | TextSpan( 324 | text: StreamagramUser.fromMap( 325 | comments[0].user?.data as Map) 326 | .fullName, 327 | style: AppTextStyle.textStyleBold), 328 | const TextSpan(text: ' '), 329 | TextSpan(text: comments[0].data?['message'] as String?), 330 | ], 331 | ), 332 | ), 333 | ), 334 | if (commentCount > 1 && comments.isNotEmpty) 335 | Padding( 336 | padding: spacePadding, 337 | child: Text.rich( 338 | TextSpan( 339 | children: [ 340 | TextSpan( 341 | text: StreamagramUser.fromMap( 342 | comments[1].user?.data as Map) 343 | .fullName, 344 | style: AppTextStyle.textStyleBold), 345 | const TextSpan(text: ' '), 346 | TextSpan(text: comments[1].data?['message'] as String?), 347 | ], 348 | ), 349 | ), 350 | ), 351 | if (commentCount > 2) 352 | Padding( 353 | padding: spacePadding, 354 | child: GestureDetector( 355 | onTap: () { 356 | final map = 357 | widget.enrichedActivity.actor!.data as Map; 358 | // AND THIS 359 | Navigator.of(context).push(CommentsScreen.route( 360 | enrichedActivity: widget.enrichedActivity, 361 | activityOwnerData: StreamagramUser.fromMap(map), 362 | )); 363 | }, 364 | child: Text( 365 | 'View all $commentCount comments', 366 | style: AppTextStyle.textStyleFaded, 367 | ), 368 | ), 369 | ), 370 | GestureDetector( 371 | behavior: HitTestBehavior.opaque, 372 | onTap: () { 373 | widget.onAddComment(enrichedActivity); 374 | }, 375 | child: Padding( 376 | padding: const EdgeInsets.only(left: 16.0, top: 3, right: 8), 377 | child: Row( 378 | children: [ 379 | const _ProfilePicture(), 380 | const Expanded( 381 | child: Padding( 382 | padding: EdgeInsets.only(left: 8.0), 383 | child: Text( 384 | 'Add a comment', 385 | style: TextStyle( 386 | color: AppColors.faded, 387 | fontSize: 14, 388 | ), 389 | ), 390 | ), 391 | ), 392 | GestureDetector( 393 | onTap: () { 394 | widget.onAddComment(enrichedActivity, message: '❤️'); 395 | }, 396 | child: const Padding( 397 | padding: textPadding, 398 | child: Text('❤️'), 399 | ), 400 | ), 401 | GestureDetector( 402 | onTap: () { 403 | widget.onAddComment(enrichedActivity, message: '🙌'); 404 | }, 405 | child: const Padding( 406 | padding: textPadding, 407 | child: Text('🙌'), 408 | ), 409 | ), 410 | ], 411 | ), 412 | ), 413 | ), 414 | Padding( 415 | padding: const EdgeInsets.only(left: 16.0, top: 4), 416 | child: Text( 417 | _timeSinceMessage, 418 | style: const TextStyle( 419 | color: AppColors.faded, 420 | fontWeight: FontWeight.w400, 421 | fontSize: 13, 422 | ), 423 | ), 424 | ), 425 | ], 426 | ); 427 | } 428 | } 429 | 430 | class _ProfilePicture extends StatelessWidget { 431 | const _ProfilePicture({Key? key}) : super(key: key); 432 | 433 | @override 434 | Widget build(BuildContext context) { 435 | final streamagramUser = context.watch().streamagramUser; 436 | if (streamagramUser == null) { 437 | return const Icon(Icons.error); 438 | } 439 | return Avatar.small( 440 | streamagramUser: streamagramUser, 441 | ); 442 | } 443 | } 444 | 445 | class _ProfileSlab extends StatelessWidget { 446 | const _ProfileSlab({ 447 | Key? key, 448 | required this.userData, 449 | }) : super(key: key); 450 | 451 | final StreamagramUser userData; 452 | 453 | @override 454 | Widget build(BuildContext context) { 455 | return Padding( 456 | padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: 16.0), 457 | child: Row( 458 | children: [ 459 | Avatar.medium(streamagramUser: userData), 460 | Padding( 461 | padding: const EdgeInsets.all(8.0), 462 | child: Text( 463 | userData.fullName, 464 | style: AppTextStyle.textStyleBold, 465 | ), 466 | ), 467 | const Spacer(), 468 | TapFadeIcon( 469 | onTap: () => context.removeAndShowSnackbar('Not part of the demo'), 470 | icon: Icons.more_horiz, 471 | iconColor: Theme.of(context).iconTheme.color!, 472 | ), 473 | ], 474 | ), 475 | ); 476 | } 477 | } 478 | -------------------------------------------------------------------------------- /lib/components/timeline/widgets/widgets.dart: -------------------------------------------------------------------------------- 1 | export 'post_card.dart'; 2 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'app/app.dart'; 4 | 5 | void main() { 6 | WidgetsFlutterBinding.ensureInitialized(); 7 | final theme = AppTheme(); 8 | runApp(StreamagramApp(appTheme: theme)); 9 | } 10 | -------------------------------------------------------------------------------- /previews/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GetStream/flutter-instagram-clone/fce74f7795ae3c18034023dfc87008f9926a3658/previews/preview.png -------------------------------------------------------------------------------- /pubspec.lock: -------------------------------------------------------------------------------- 1 | # Generated by pub 2 | # See https://dart.dev/tools/pub/glossary#lockfile 3 | packages: 4 | asn1lib: 5 | dependency: transitive 6 | description: 7 | name: asn1lib 8 | url: "https://pub.dartlang.org" 9 | source: hosted 10 | version: "1.1.0" 11 | async: 12 | dependency: transitive 13 | description: 14 | name: async 15 | url: "https://pub.dartlang.org" 16 | source: hosted 17 | version: "2.9.0" 18 | boolean_selector: 19 | dependency: transitive 20 | description: 21 | name: boolean_selector 22 | url: "https://pub.dartlang.org" 23 | source: hosted 24 | version: "2.1.0" 25 | cached_network_image: 26 | dependency: "direct main" 27 | description: 28 | name: cached_network_image 29 | url: "https://pub.dartlang.org" 30 | source: hosted 31 | version: "3.2.0" 32 | cached_network_image_platform_interface: 33 | dependency: transitive 34 | description: 35 | name: cached_network_image_platform_interface 36 | url: "https://pub.dartlang.org" 37 | source: hosted 38 | version: "1.0.0" 39 | cached_network_image_web: 40 | dependency: transitive 41 | description: 42 | name: cached_network_image_web 43 | url: "https://pub.dartlang.org" 44 | source: hosted 45 | version: "1.0.1" 46 | characters: 47 | dependency: transitive 48 | description: 49 | name: characters 50 | url: "https://pub.dartlang.org" 51 | source: hosted 52 | version: "1.2.1" 53 | charcode: 54 | dependency: transitive 55 | description: 56 | name: charcode 57 | url: "https://pub.dartlang.org" 58 | source: hosted 59 | version: "1.3.1" 60 | clock: 61 | dependency: transitive 62 | description: 63 | name: clock 64 | url: "https://pub.dartlang.org" 65 | source: hosted 66 | version: "1.1.1" 67 | collection: 68 | dependency: transitive 69 | description: 70 | name: collection 71 | url: "https://pub.dartlang.org" 72 | source: hosted 73 | version: "1.16.0" 74 | convert: 75 | dependency: transitive 76 | description: 77 | name: convert 78 | url: "https://pub.dartlang.org" 79 | source: hosted 80 | version: "3.0.1" 81 | cross_file: 82 | dependency: transitive 83 | description: 84 | name: cross_file 85 | url: "https://pub.dartlang.org" 86 | source: hosted 87 | version: "0.3.2" 88 | crypto: 89 | dependency: transitive 90 | description: 91 | name: crypto 92 | url: "https://pub.dartlang.org" 93 | source: hosted 94 | version: "3.0.1" 95 | crypto_keys: 96 | dependency: transitive 97 | description: 98 | name: crypto_keys 99 | url: "https://pub.dartlang.org" 100 | source: hosted 101 | version: "0.3.0" 102 | cupertino_icons: 103 | dependency: "direct main" 104 | description: 105 | name: cupertino_icons 106 | url: "https://pub.dartlang.org" 107 | source: hosted 108 | version: "1.0.4" 109 | dio: 110 | dependency: transitive 111 | description: 112 | name: dio 113 | url: "https://pub.dartlang.org" 114 | source: hosted 115 | version: "4.0.4" 116 | equatable: 117 | dependency: transitive 118 | description: 119 | name: equatable 120 | url: "https://pub.dartlang.org" 121 | source: hosted 122 | version: "2.0.3" 123 | fake_async: 124 | dependency: transitive 125 | description: 126 | name: fake_async 127 | url: "https://pub.dartlang.org" 128 | source: hosted 129 | version: "1.3.1" 130 | faye_dart: 131 | dependency: transitive 132 | description: 133 | name: faye_dart 134 | url: "https://pub.dartlang.org" 135 | source: hosted 136 | version: "0.1.1+2" 137 | ffi: 138 | dependency: transitive 139 | description: 140 | name: ffi 141 | url: "https://pub.dartlang.org" 142 | source: hosted 143 | version: "1.1.2" 144 | file: 145 | dependency: transitive 146 | description: 147 | name: file 148 | url: "https://pub.dartlang.org" 149 | source: hosted 150 | version: "6.1.2" 151 | flutter: 152 | dependency: "direct main" 153 | description: flutter 154 | source: sdk 155 | version: "0.0.0" 156 | flutter_blurhash: 157 | dependency: transitive 158 | description: 159 | name: flutter_blurhash 160 | url: "https://pub.dartlang.org" 161 | source: hosted 162 | version: "0.6.4" 163 | flutter_cache_manager: 164 | dependency: transitive 165 | description: 166 | name: flutter_cache_manager 167 | url: "https://pub.dartlang.org" 168 | source: hosted 169 | version: "3.3.0" 170 | flutter_lints: 171 | dependency: "direct dev" 172 | description: 173 | name: flutter_lints 174 | url: "https://pub.dartlang.org" 175 | source: hosted 176 | version: "2.0.1" 177 | flutter_plugin_android_lifecycle: 178 | dependency: transitive 179 | description: 180 | name: flutter_plugin_android_lifecycle 181 | url: "https://pub.dartlang.org" 182 | source: hosted 183 | version: "2.0.5" 184 | flutter_test: 185 | dependency: "direct dev" 186 | description: flutter 187 | source: sdk 188 | version: "0.0.0" 189 | flutter_web_plugins: 190 | dependency: transitive 191 | description: flutter 192 | source: sdk 193 | version: "0.0.0" 194 | google_fonts: 195 | dependency: "direct main" 196 | description: 197 | name: google_fonts 198 | url: "https://pub.dartlang.org" 199 | source: hosted 200 | version: "2.3.1" 201 | http: 202 | dependency: transitive 203 | description: 204 | name: http 205 | url: "https://pub.dartlang.org" 206 | source: hosted 207 | version: "0.13.4" 208 | http_parser: 209 | dependency: transitive 210 | description: 211 | name: http_parser 212 | url: "https://pub.dartlang.org" 213 | source: hosted 214 | version: "4.0.0" 215 | image_picker: 216 | dependency: "direct main" 217 | description: 218 | name: image_picker 219 | url: "https://pub.dartlang.org" 220 | source: hosted 221 | version: "0.8.4+10" 222 | image_picker_for_web: 223 | dependency: transitive 224 | description: 225 | name: image_picker_for_web 226 | url: "https://pub.dartlang.org" 227 | source: hosted 228 | version: "2.1.6" 229 | image_picker_platform_interface: 230 | dependency: transitive 231 | description: 232 | name: image_picker_platform_interface 233 | url: "https://pub.dartlang.org" 234 | source: hosted 235 | version: "2.4.4" 236 | intl: 237 | dependency: transitive 238 | description: 239 | name: intl 240 | url: "https://pub.dartlang.org" 241 | source: hosted 242 | version: "0.17.0" 243 | jiffy: 244 | dependency: "direct main" 245 | description: 246 | name: jiffy 247 | url: "https://pub.dartlang.org" 248 | source: hosted 249 | version: "5.0.0" 250 | jose: 251 | dependency: transitive 252 | description: 253 | name: jose 254 | url: "https://pub.dartlang.org" 255 | source: hosted 256 | version: "0.3.2" 257 | js: 258 | dependency: transitive 259 | description: 260 | name: js 261 | url: "https://pub.dartlang.org" 262 | source: hosted 263 | version: "0.6.4" 264 | json_annotation: 265 | dependency: transitive 266 | description: 267 | name: json_annotation 268 | url: "https://pub.dartlang.org" 269 | source: hosted 270 | version: "4.4.0" 271 | lints: 272 | dependency: transitive 273 | description: 274 | name: lints 275 | url: "https://pub.dartlang.org" 276 | source: hosted 277 | version: "2.0.0" 278 | logging: 279 | dependency: transitive 280 | description: 281 | name: logging 282 | url: "https://pub.dartlang.org" 283 | source: hosted 284 | version: "1.0.2" 285 | matcher: 286 | dependency: transitive 287 | description: 288 | name: matcher 289 | url: "https://pub.dartlang.org" 290 | source: hosted 291 | version: "0.12.12" 292 | material_color_utilities: 293 | dependency: transitive 294 | description: 295 | name: material_color_utilities 296 | url: "https://pub.dartlang.org" 297 | source: hosted 298 | version: "0.1.5" 299 | meta: 300 | dependency: transitive 301 | description: 302 | name: meta 303 | url: "https://pub.dartlang.org" 304 | source: hosted 305 | version: "1.8.0" 306 | mime: 307 | dependency: transitive 308 | description: 309 | name: mime 310 | url: "https://pub.dartlang.org" 311 | source: hosted 312 | version: "1.0.1" 313 | nested: 314 | dependency: transitive 315 | description: 316 | name: nested 317 | url: "https://pub.dartlang.org" 318 | source: hosted 319 | version: "1.0.0" 320 | octo_image: 321 | dependency: transitive 322 | description: 323 | name: octo_image 324 | url: "https://pub.dartlang.org" 325 | source: hosted 326 | version: "1.0.1" 327 | path: 328 | dependency: transitive 329 | description: 330 | name: path 331 | url: "https://pub.dartlang.org" 332 | source: hosted 333 | version: "1.8.2" 334 | path_provider: 335 | dependency: transitive 336 | description: 337 | name: path_provider 338 | url: "https://pub.dartlang.org" 339 | source: hosted 340 | version: "2.0.9" 341 | path_provider_android: 342 | dependency: transitive 343 | description: 344 | name: path_provider_android 345 | url: "https://pub.dartlang.org" 346 | source: hosted 347 | version: "2.0.12" 348 | path_provider_ios: 349 | dependency: transitive 350 | description: 351 | name: path_provider_ios 352 | url: "https://pub.dartlang.org" 353 | source: hosted 354 | version: "2.0.8" 355 | path_provider_linux: 356 | dependency: transitive 357 | description: 358 | name: path_provider_linux 359 | url: "https://pub.dartlang.org" 360 | source: hosted 361 | version: "2.1.5" 362 | path_provider_macos: 363 | dependency: transitive 364 | description: 365 | name: path_provider_macos 366 | url: "https://pub.dartlang.org" 367 | source: hosted 368 | version: "2.0.5" 369 | path_provider_platform_interface: 370 | dependency: transitive 371 | description: 372 | name: path_provider_platform_interface 373 | url: "https://pub.dartlang.org" 374 | source: hosted 375 | version: "2.0.3" 376 | path_provider_windows: 377 | dependency: transitive 378 | description: 379 | name: path_provider_windows 380 | url: "https://pub.dartlang.org" 381 | source: hosted 382 | version: "2.0.5" 383 | pedantic: 384 | dependency: transitive 385 | description: 386 | name: pedantic 387 | url: "https://pub.dartlang.org" 388 | source: hosted 389 | version: "1.11.1" 390 | platform: 391 | dependency: transitive 392 | description: 393 | name: platform 394 | url: "https://pub.dartlang.org" 395 | source: hosted 396 | version: "3.1.0" 397 | plugin_platform_interface: 398 | dependency: transitive 399 | description: 400 | name: plugin_platform_interface 401 | url: "https://pub.dartlang.org" 402 | source: hosted 403 | version: "2.1.2" 404 | pointycastle: 405 | dependency: transitive 406 | description: 407 | name: pointycastle 408 | url: "https://pub.dartlang.org" 409 | source: hosted 410 | version: "3.5.1" 411 | process: 412 | dependency: transitive 413 | description: 414 | name: process 415 | url: "https://pub.dartlang.org" 416 | source: hosted 417 | version: "4.2.4" 418 | provider: 419 | dependency: "direct main" 420 | description: 421 | name: provider 422 | url: "https://pub.dartlang.org" 423 | source: hosted 424 | version: "6.0.2" 425 | quiver: 426 | dependency: transitive 427 | description: 428 | name: quiver 429 | url: "https://pub.dartlang.org" 430 | source: hosted 431 | version: "3.0.1+1" 432 | rxdart: 433 | dependency: transitive 434 | description: 435 | name: rxdart 436 | url: "https://pub.dartlang.org" 437 | source: hosted 438 | version: "0.27.3" 439 | sky_engine: 440 | dependency: transitive 441 | description: flutter 442 | source: sdk 443 | version: "0.0.99" 444 | source_span: 445 | dependency: transitive 446 | description: 447 | name: source_span 448 | url: "https://pub.dartlang.org" 449 | source: hosted 450 | version: "1.9.0" 451 | sqflite: 452 | dependency: transitive 453 | description: 454 | name: sqflite 455 | url: "https://pub.dartlang.org" 456 | source: hosted 457 | version: "2.0.2" 458 | sqflite_common: 459 | dependency: transitive 460 | description: 461 | name: sqflite_common 462 | url: "https://pub.dartlang.org" 463 | source: hosted 464 | version: "2.2.0" 465 | stack_trace: 466 | dependency: transitive 467 | description: 468 | name: stack_trace 469 | url: "https://pub.dartlang.org" 470 | source: hosted 471 | version: "1.10.0" 472 | stream_channel: 473 | dependency: transitive 474 | description: 475 | name: stream_channel 476 | url: "https://pub.dartlang.org" 477 | source: hosted 478 | version: "2.1.0" 479 | stream_feed: 480 | dependency: transitive 481 | description: 482 | name: stream_feed 483 | url: "https://pub.dartlang.org" 484 | source: hosted 485 | version: "0.6.0+2" 486 | stream_feed_flutter_core: 487 | dependency: "direct main" 488 | description: 489 | name: stream_feed_flutter_core 490 | url: "https://pub.dartlang.org" 491 | source: hosted 492 | version: "0.8.0" 493 | string_scanner: 494 | dependency: transitive 495 | description: 496 | name: string_scanner 497 | url: "https://pub.dartlang.org" 498 | source: hosted 499 | version: "1.1.1" 500 | synchronized: 501 | dependency: transitive 502 | description: 503 | name: synchronized 504 | url: "https://pub.dartlang.org" 505 | source: hosted 506 | version: "3.0.0" 507 | term_glyph: 508 | dependency: transitive 509 | description: 510 | name: term_glyph 511 | url: "https://pub.dartlang.org" 512 | source: hosted 513 | version: "1.2.1" 514 | test_api: 515 | dependency: transitive 516 | description: 517 | name: test_api 518 | url: "https://pub.dartlang.org" 519 | source: hosted 520 | version: "0.4.12" 521 | transparent_image: 522 | dependency: "direct main" 523 | description: 524 | name: transparent_image 525 | url: "https://pub.dartlang.org" 526 | source: hosted 527 | version: "2.0.0" 528 | typed_data: 529 | dependency: transitive 530 | description: 531 | name: typed_data 532 | url: "https://pub.dartlang.org" 533 | source: hosted 534 | version: "1.3.0" 535 | uuid: 536 | dependency: transitive 537 | description: 538 | name: uuid 539 | url: "https://pub.dartlang.org" 540 | source: hosted 541 | version: "3.0.6" 542 | vector_math: 543 | dependency: transitive 544 | description: 545 | name: vector_math 546 | url: "https://pub.dartlang.org" 547 | source: hosted 548 | version: "2.1.2" 549 | web_socket_channel: 550 | dependency: transitive 551 | description: 552 | name: web_socket_channel 553 | url: "https://pub.dartlang.org" 554 | source: hosted 555 | version: "2.1.0" 556 | win32: 557 | dependency: transitive 558 | description: 559 | name: win32 560 | url: "https://pub.dartlang.org" 561 | source: hosted 562 | version: "2.4.1" 563 | x509: 564 | dependency: transitive 565 | description: 566 | name: x509 567 | url: "https://pub.dartlang.org" 568 | source: hosted 569 | version: "0.2.2" 570 | xdg_directories: 571 | dependency: transitive 572 | description: 573 | name: xdg_directories 574 | url: "https://pub.dartlang.org" 575 | source: hosted 576 | version: "0.2.0+1" 577 | sdks: 578 | dart: ">=2.17.0-206.0.dev <3.0.0" 579 | flutter: ">=2.10.0-0" 580 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: stream_agram 2 | description: A new Flutter project. 3 | 4 | # The following line prevents the package from being accidentally published to 5 | # pub.dev using `flutter pub publish`. This is preferred for private packages. 6 | publish_to: "none" # Remove this line if you wish to publish to pub.dev 7 | 8 | # The following defines the version and build number for your application. 9 | # A version number is three numbers separated by dots, like 1.2.43 10 | # followed by an optional build number separated by a +. 11 | # Both the version and the builder number may be overridden in flutter 12 | # build by specifying --build-name and --build-number, respectively. 13 | # In Android, build-name is used as versionName while build-number used as versionCode. 14 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 15 | # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. 16 | # Read more about iOS versioning at 17 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 18 | version: 1.0.0+1 19 | 20 | environment: 21 | sdk: ">=2.16.1 <3.0.0" 22 | 23 | # Dependencies specify other packages that your package needs in order to work. 24 | # To automatically upgrade your package dependencies to the latest versions 25 | # consider running `flutter pub upgrade --major-versions`. Alternatively, 26 | # dependencies can be manually updated by changing the version numbers below to 27 | # the latest version available on pub.dev. To see which dependencies have newer 28 | # versions available, run `flutter pub outdated`. 29 | dependencies: 30 | flutter: 31 | sdk: flutter 32 | 33 | # The following adds the Cupertino Icons font to your application. 34 | # Use with the CupertinoIcons class for iOS style icons. 35 | # The following adds the Cupertino Icons font to your application. 36 | # Use with the CupertinoIcons class for iOS style icons. 37 | cupertino_icons: ^1.0.2 38 | stream_feed_flutter_core: ^0.8.0 39 | provider: ^6.0.2 40 | google_fonts: ^2.3.1 41 | image_picker: ^0.8.4+9 42 | cached_network_image: ^3.2.0 43 | transparent_image: ^2.0.0 44 | jiffy: ^5.0.0 45 | 46 | dev_dependencies: 47 | flutter_test: 48 | sdk: flutter 49 | 50 | # The "flutter_lints" package below contains a set of recommended lints to 51 | # encourage good coding practices. The lint set provided by the package is 52 | # activated in the `analysis_options.yaml` file located at the root of your 53 | # package. See that file for information about deactivating specific lint 54 | # rules and activating additional ones. 55 | flutter_lints: ^2.0.0 56 | 57 | # For information on the generic Dart part of this file, see the 58 | # following page: https://dart.dev/tools/pub/pubspec 59 | 60 | # The following section is specific to Flutter. 61 | flutter: 62 | # The following line ensures that the Material Icons font is 63 | # included with your application, so that you can use the icons in 64 | # the material Icons class. 65 | uses-material-design: true 66 | 67 | # To add assets to your application, add an assets section, like this: 68 | # assets: 69 | # - images/a_dot_burr.jpeg 70 | # - images/a_dot_ham.jpeg 71 | 72 | # An image asset can refer to one or more resolution-specific "variants", see 73 | # https://flutter.dev/assets-and-images/#resolution-aware. 74 | 75 | # For details regarding adding assets from package dependencies, see 76 | # https://flutter.dev/assets-and-images/#from-packages 77 | 78 | # To add custom fonts to your application, add a fonts section here, 79 | # in this "flutter" section. Each entry in this list should have a 80 | # "family" key with the font family name, and a "fonts" key with a 81 | # list giving the asset and other descriptors for the font. For 82 | # example: 83 | # fonts: 84 | # - family: Schyler 85 | # fonts: 86 | # - asset: fonts/Schyler-Regular.ttf 87 | # - asset: fonts/Schyler-Italic.ttf 88 | # style: italic 89 | # - family: Trajan Pro 90 | # fonts: 91 | # - asset: fonts/TrajanPro.ttf 92 | # - asset: fonts/TrajanPro_Bold.ttf 93 | # weight: 700 94 | # 95 | # For details regarding fonts from package dependencies, 96 | # see https://flutter.dev/custom-fonts/#from-packages 97 | --------------------------------------------------------------------------------