diff --git a/README.md b/README.md index 4208d3e..50bd9ca 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,31 @@ -# ![RealWorld Example App](logo.png) +# ![Flutter RealWorld Example App](flutter_realworld.png) -> ### [YOUR_FRAMEWORK] codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. +> ### [Flutter](https://flutter.io/) codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. ### [Demo](https://github.com/gothinkster/realworld)    [RealWorld](https://github.com/gothinkster/realworld) -This codebase was created to demonstrate a fully fledged fullstack application built with **[YOUR_FRAMEWORK]** including CRUD operations, authentication, routing, pagination, and more. +This codebase was created to demonstrate a fully fledged fullstack application built with [Flutter](https://flutter.io/) including CRUD operations, authentication, routing, pagination, and more. -We've gone to great lengths to adhere to the **[YOUR_FRAMEWORK]** community styleguides & best practices. +We've gone to great lengths to adhere to the [Flutter](https://flutter.io/) community styleguides & best practices. For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. # How it works -> Describe the general architecture of your app here +* Using [Flutter-ReduRx](https://github.com/leocavalcante/Flutter-ReduRx/) for managing State +* Using [flutter_i18n](https://github.com/long1eu/flutter_i18n) for i18n + * English and **Chinese** Locale are supported! +* Infinite ListView inspired by [MARCIN SZAŁEK](https://marcinszalek.pl/flutter/infinite-dynamic-listview/). Good job, dude! # Getting started -> npm install, npm start, etc. +To build this app yourself: + +* [Install Flutter](https://flutter.io/docs/get-started/install) +* Clone this repo and open it with Android Studio (Don't forget to install the Flutter Plugin) +* `flutter packages get` to download the Dart packages +* `flutter build apk` then `flutter install` to install the app in **release mode** to Android device +* Enjoy! diff --git a/flutter_realworld.png b/flutter_realworld.png new file mode 100644 index 0000000..84c7c74 Binary files /dev/null and b/flutter_realworld.png differ diff --git a/lib/api.dart b/lib/api.dart index 3b263d6..72e8b27 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -117,12 +117,14 @@ class Api { Future
articleGet(String slug) => _get('/articles/$slug').then(Article.fromRequest); - Future
articleCreate(Article article) => _post('/articles', data: { + Future
articleCreate(String title, String description, String body, + List tagList) => + _post('/articles', data: { "article": { - "title": article.title, - "description": article.description, - "body": article.body, - "tagList": article.tagList + "title": title, + "description": description, + "body": body, + "tagList": tagList } }).then(Article.fromRequest); diff --git a/lib/components/app_drawer.dart b/lib/components/app_drawer.dart index d9e2c08..2b2f7b5 100644 --- a/lib/components/app_drawer.dart +++ b/lib/components/app_drawer.dart @@ -6,6 +6,7 @@ import 'package:flutter_realworld_app/generated/i18n.dart'; import 'package:flutter_realworld_app/models/app_state.dart'; import 'package:flutter_realworld_app/models/profile.dart'; import 'package:flutter_realworld_app/models/user.dart'; +import 'package:flutter_realworld_app/pages/main_page.dart'; import 'package:flutter_realworld_app/pages/profile_page.dart'; import 'package:flutter_realworld_app/util.dart' as util; import 'package:flutter_redurx/flutter_redurx.dart'; @@ -47,13 +48,22 @@ class AppDrawer extends StatelessWidget { ), ListTile( leading: Icon(Icons.public), - onTap: () {}, - title: Text(S.of(context).bottomNavGlobal), + onTap: () { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (context) => + MainPage(MainPageType.GLOBAL_FEED)), + ModalRoute.withName('/')); + }, + title: Text(S.of(context).globalFeed), ), Divider(), ListTile( - title: Text(S.of(context).about), - onTap: () {}, + leading: Icon(Icons.info), + title: Text(S.of(context).aboutApp), + onTap: () { + util.showAbout(context); + }, ), ], ) @@ -78,18 +88,32 @@ class AppDrawer extends StatelessWidget { ), ListTile( leading: Icon(Icons.person), - onTap: () {}, - title: Text(S.of(context).bottomNavYours), + onTap: () { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (context) => + MainPage(MainPageType.YOUR_FEED)), + ModalRoute.withName('/')); + }, + title: Text(S.of(context).yourFeed), ), ListTile( - leading: Icon(Icons.group), - onTap: () {}, - title: Text(S.of(context).bottomNavGlobal), + leading: Icon(Icons.public), + onTap: () { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (context) => + MainPage(MainPageType.GLOBAL_FEED)), + ModalRoute.withName('/')); + }, + title: Text(S.of(context).globalFeed), ), Divider(), ListTile( leading: Icon(Icons.settings), - onTap: () {}, + onTap: () { + Navigator.of(context).pushNamed('/settings'); + }, title: Text(S.of(context).settings), ), ListTile( @@ -97,8 +121,11 @@ class AppDrawer extends StatelessWidget { onTap: () { Provider.dispatch(context, Logout(successCallback: () { - Navigator.of(context) - .popUntil(ModalRoute.withName("/main")); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (context) => + MainPage(MainPageType.GLOBAL_FEED)), + ModalRoute.withName('/')); Flushbar() ..title = S.of(context).logoutSuccessfulTitle ..message = S.of(context).logoutSuccessful @@ -111,8 +138,10 @@ class AppDrawer extends StatelessWidget { Divider(), ListTile( leading: Icon(Icons.info), - onTap: () {}, - title: Text(S.of(context).about), + onTap: () { + util.showAbout(context); + }, + title: Text(S.of(context).aboutApp), ), ], ), diff --git a/lib/components/article_item.dart b/lib/components/article_item.dart index 78ad41c..8dcc439 100644 --- a/lib/components/article_item.dart +++ b/lib/components/article_item.dart @@ -1,9 +1,13 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_realworld_app/api.dart'; +import 'package:flutter_realworld_app/components/chipper.dart'; import 'package:flutter_realworld_app/models/article.dart'; +import 'package:flutter_realworld_app/pages/article_page.dart'; +import 'package:flutter_realworld_app/pages/profile_page.dart'; import 'package:flutter_realworld_app/util.dart' as util; -import 'package:intl/intl.dart'; + +typedef void FollowButtonCallback(Article article); class ArticleItem extends StatefulWidget { final Article _article; @@ -16,95 +20,102 @@ class ArticleItem extends StatefulWidget { class _ArticleItemState extends State { Article _article; - var dateFormatter = DateFormat('yyyy-mm-dd HH:MM:ss'); _ArticleItemState(this._article); - _header(BuildContext context) => Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 4.0), - child: CircleAvatar( - backgroundImage: - util.isNullEmpty(_article.author.image, trim: true) - ? AssetImage('res/assets/smiley-cyrus.jpg') - : CachedNetworkImageProvider(_article.author.image), + _header(BuildContext context) => GestureDetector( + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => ProfilePage(_article.author))); + }, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 4.0), + child: CircleAvatar( + backgroundImage: + util.isNullEmpty(_article.author.image, trim: true) + ? AssetImage('res/assets/smiley-cyrus.jpg') + : CachedNetworkImageProvider(_article.author.image), + ), ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(_article.author.username, - style: Theme.of(context).textTheme.body2), - Text(dateFormatter.format(_article.createdAt), - style: Theme.of(context).textTheme.caption), - ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_article.author.username, + style: Theme.of(context).textTheme.body2), + Text(util.dateFormatter.format(_article.createdAt), + style: Theme.of(context).textTheme.caption), + ], + ), ), - ), - FlatButton( - textColor: Theme.of(context).primaryColor, - onPressed: () async { - try { - final api = await Api.getInstance(); - Article article; - if (_article.favorited) { - article = await api.articleUnfavorite(_article.slug); - } else { - article = await api.articleFavorite(_article.slug); + FlatButton( + textColor: Theme.of(context).primaryColor, + onPressed: () async { + try { + final api = await Api.getInstance(); + Article article; + if (_article.favorited) { + article = await api.articleUnfavorite(_article.slug); + } else { + article = await api.articleFavorite(_article.slug); + } + setState(() { + this._article = article; + }); + } catch (e) { + util.errorHandle(e, context); } - setState(() { - this._article = article; - }); - } catch (e) { - util.errorHandle(e, context); - } - }, - child: Row( - children: [ - _article.favorited - ? Icon(Icons.favorite) - : Icon(Icons.favorite_border), - Text(_article.favoritesCount.toString()) - ], + }, + child: Row( + children: [ + _article.favorited + ? Icon(Icons.favorite) + : Icon(Icons.favorite_border), + Text(_article.favoritesCount.toString()) + ], + ), ), - ), - ], + ], + ), ); @override - Widget build(BuildContext context) => GestureDetector( - onTap: () { - print("card, ${this._article.author.username}, ${this._article.title}"); - }, - child: Padding( + Widget build(BuildContext context) => Container( padding: EdgeInsets.only(left: 4.0), - child: Container( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _header(context), - Text(_article.title, style: Theme.of(context).textTheme.title), - Padding( - padding: EdgeInsets.only(bottom: 4.0), - child: Text(_article.body, maxLines: 3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _header(context), + GestureDetector( + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => ArticlePage(_article))); + }, + child: Column( + children: [ + Text(_article.title, + style: Theme.of(context).textTheme.title), + Padding( + padding: EdgeInsets.only(bottom: 4.0), + child: Text(_article.description), + ), + ], ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: _article.tagList - .map((t) => Padding( - padding: EdgeInsets.only(right: 4.0), - child: GestureDetector( - onTap: () { - print("chip tap: $t"); - }, - child: Chip(label: Text(t)), - ), - )) - .toList()), - Divider(), - ], - ), + ), + Wrap( + spacing: 4.0, + children: _article.tagList + .map((t) => GestureDetector( + onTap: () { + print("chip tap: $t"); + }, + child: Chipper(t), + )) + .toList()), + Divider(), + ], ), - )); + ); } diff --git a/lib/components/chipper.dart b/lib/components/chipper.dart new file mode 100644 index 0000000..13c80ea --- /dev/null +++ b/lib/components/chipper.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_realworld_app/pages/search_result_page.dart'; + +class Chipper extends StatelessWidget { + final String _label; + final bool _canReplaceLastPage; + + const Chipper(this._label, {Key key, bool canReplaceLastPage = false}) + : this._canReplaceLastPage = canReplaceLastPage, + super(key: key); + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: () { + final route = + MaterialPageRoute(builder: (context) => SearchResultPage(_label)); + if (_canReplaceLastPage) + Navigator.pushReplacement(context, route); + else + Navigator.push(context, route); + }, + child: Chip(label: Text(_label)), + ); +} diff --git a/lib/components/scrollable_loading_list.dart b/lib/components/scrollable_loading_list.dart new file mode 100644 index 0000000..fe7467a --- /dev/null +++ b/lib/components/scrollable_loading_list.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_realworld_app/generated/i18n.dart'; + +typedef Future> LoadingDataFunction({int offset}); +typedef Widget ItemConstructor(T item); + +class ScrollableLoadingList extends StatefulWidget { + final LoadingDataFunction _loadingDataFunction; + final ItemConstructor _itemConstructor; + + ScrollableLoadingList({ + Key key, + @required LoadingDataFunction loadingDataFunction, + @required ItemConstructor itemConstructor, + }) : this._loadingDataFunction = loadingDataFunction, + this._itemConstructor = itemConstructor, + super(key: key) { + assert(this._loadingDataFunction != null); + assert(this._itemConstructor != null); + } + + @override + State createState() => + _ScrollableLoadingListState(_loadingDataFunction, _itemConstructor); +} + +class _ScrollableLoadingListState extends State> { + final LoadingDataFunction _loadingDataFunction; + final ItemConstructor _itemConstructor; + + List _data = []; + bool _isPerformingRequest = false; + ScrollController _scrollController = new ScrollController(); + + _ScrollableLoadingListState(this._loadingDataFunction, this._itemConstructor); + + @override + void initState() { + super.initState(); + _scrollController.addListener(() { + if (_scrollController.position.pixels == + _scrollController.position.maxScrollExtent) { + _getMoreData(); + } + }); + _getMoreData(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + Widget _buildProgressIndicator() { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Center( + child: Opacity( + opacity: _isPerformingRequest ? 1.0 : 0.0, + child: CircularProgressIndicator(), + ), + ), + ); + } + + Future _getMoreData() async { + if (!_isPerformingRequest) { + setState(() => _isPerformingRequest = true); + List nextData = await _loadingDataFunction(offset: _data.length); + nextData = nextData.where((a) => !_data.contains(a)).toList(); + if (nextData.isEmpty && _data.isNotEmpty) { + double edge = 50.0; + double offsetFromBottom = _scrollController.position.maxScrollExtent - + _scrollController.position.pixels; + if (offsetFromBottom < edge) { + _scrollController.animateTo( + _scrollController.offset - (edge - offsetFromBottom), + duration: Duration(milliseconds: 500), + curve: Curves.easeOut); + } + } + setState(() { + _data.addAll(nextData); + _isPerformingRequest = false; + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isPerformingRequest) { + return Center(child: CircularProgressIndicator()); + } + return _data.isEmpty + ? Container( + padding: EdgeInsets.only(top: 50.0), + alignment: Alignment.center, + child: Wrap( + children: [ + Icon( + Icons.loop, + size: 50.0, + ), + Text( + S.of(context).emptyNow, + style: Theme.of(context).textTheme.display1, + ), + ], + ), + ) + : ListView.builder( + controller: _scrollController, + itemCount: _data.length + 1, + itemBuilder: (context, index) { + if (index == _data.length) return _buildProgressIndicator(); + return _itemConstructor(_data[index]); + }, + ); + } +} diff --git a/lib/generated/i18n.dart b/lib/generated/i18n.dart index 8e79b92..32ed916 100644 --- a/lib/generated/i18n.dart +++ b/lib/generated/i18n.dart @@ -18,22 +18,29 @@ class S implements WidgetsLocalizations { @override TextDirection get textDirection => TextDirection.ltr; - String get about => "About"; + String get aboutApp => "About APP"; + String get aboutInfo => "Author: CasterKKK\nGithub: https://github.com/CasterKKK\nThis project: https://github.com/CasterKKK/flutter_realworld_app\nFeel free to fork or make PR, and thanks for staring this project!"; String get appTitle => "Flutter Realworld App"; + String get article => "Article"; + String get articleBody => "Body"; String get avatarUrl => "Avatar URL"; String get biography => "Biography"; - String get bottomNavGlobal => "Global"; - String get bottomNavYours => "Yours"; String get conduitSlogan => "A place to share your knowledge."; String get createANewUser => "Create a new user"; + String get delete => "Delete"; + String get description => "Description"; + String get edit => "Edit"; String get email => "Email"; String get emailAndPasswordShouldNotBeEmpty => "Email and password should not be empty!"; + String get emptyNow => "Empty Now..."; String get error => "Error"; String get error401 => "Unauthorized requests: please login and try again"; String get error403 => "Forbidden requests: you don't have permissions to perform the action"; String get error404 => "Not Found requests: the resource cannot be found, please wait and try again"; String get favoritedArticles => "Favorited Articles"; String get follow => "Follow"; + String get generalBizarreError => "Something weird happened, please try again"; + String get globalFeed => "Global Feed"; String get login => "Login"; String get loginSuccessful => "Login successful, you will be navigated to Main Page now"; String get loginSuccessfulTitle => "Login Successful!"; @@ -42,19 +49,27 @@ class S implements WidgetsLocalizations { String get logoutSuccessfulTitle => "Logout Successful!"; String get mainPageTitle => "Main"; String get myArticles => "My Articles"; + String get newArticlePageTitle => "New Article"; + String get newArticleSuccessful => "Creation successful, you will ne navigated to this new article"; + String get newArticleSuccessfulTitle => "New Article Successful!"; String get newPassword => "New Password"; String get notEmpty => "Not Empty"; - String get okSlang => "Alright..."; + String get ok => "OK"; String get password => "Password"; String get profilePageTitle => "Profile"; - String get registerSuccessful => "Register Successful, you will be navigated to Login Page"; + String get registerSuccessful => "Register successful, you will be navigated to Login Page"; String get registerSuccessfulTitle => "Register Successful!"; String get repassword => "Re-Password"; + String get search => "Search"; + String get searchByTagInfo => "Please choose a Tag you are interested in:"; String get seeProfile => "See Profile"; - String get settingChangeSuccessful => "Setting is changed successfully, you will be navigated to Main Page now"; - String get settingChangeSuccessfulTitle => "Wry! Changing Successful!"; String get settings => "Settings"; + String get settingsChangeSuccessful => "Settings are changed successfully, you will be navigated to Main Page now"; + String get settingsChangeSuccessfulTitle => "Wry! Changing Successful!"; String get submit => "Submit!"; + String get tagList => "Tag List"; + String get tagListTooltip => "Using English-style's comma to separate the tags. the blanks at the head and tail of each tag will be removed"; + String get title => "Title"; String get unfollow => "Unfollow"; String get username => "Username"; String get validatorNotEmail => "Please input a correct E-Mail"; @@ -62,7 +77,9 @@ class S implements WidgetsLocalizations { String get validatorNotSamePassword => "The re-entered password should be the same as the original one"; String get validatorNotUrl => "Please input a correct URL"; String get validatorTooShortPassword => "Password should be at least 6 characters"; + String get yourFeed => "Your Feed"; String errorUnknown(String err) => "Unknown Error: $err"; + String searchResultPageTitle(String tag) => "Search Result -- $tag"; } class en extends S { @@ -75,47 +92,23 @@ class zh_CN extends S { @override TextDirection get textDirection => TextDirection.ltr; - @override - String get favoritedArticles => "我喜爱的文章"; @override String get createANewUser => "创建新用户"; @override - String get submit => "提交!"; + String get articleBody => "正文"; @override String get error404 => "未找到资源的请求:请等待一段时间后重试"; @override - String get validatorNotEmpty => "此栏不可为空"; - @override - String get about => "关于"; - @override - String get okSlang => "好吧"; - @override - String get appTitle => "仿真 Flutter"; - @override - String get login => "登录"; - @override - String get error => "错误"; - @override - String get seeProfile => "我的主页"; - @override - String get loginSuccessful => "登陆成功,将跳转到主页面"; + String get newArticleSuccessful => "创建成功,即将跳转至那篇文章"; @override String get validatorNotEmail => "请输入有效的邮箱地址"; @override - String get settingChangeSuccessful => "你的信息已经更新成功,将跳转到主页面"; - @override String get logout => "退出登录"; @override String get password => "密码"; @override - String get settingChangeSuccessfulTitle => "耶!更新成功!"; - @override String get validatorNotSamePassword => "重新输入的密码应该与上面的密码保持一致"; @override - String get validatorTooShortPassword => "密码应大于等于6个字符"; - @override - String get profilePageTitle => "我的"; - @override String get conduitSlogan => "属于你的知识宝库。"; @override String get error403 => "被禁止的请求:你没有足够的权限进行操作"; @@ -124,46 +117,100 @@ class zh_CN extends S { @override String get validatorNotUrl => "请输入有效的 URL"; @override - String get email => "邮箱地址"; - @override String get loginSuccessfulTitle => "哈!登录成功!"; @override - String get settings => "设置"; + String get yourFeed => "你所关注的文章"; @override - String get emailAndPasswordShouldNotBeEmpty => "邮箱地址和密码均不能为空!"; + String get settings => "设置"; @override String get avatarUrl => "头像 URL"; @override - String get logoutSuccessful => "登出成功,将跳转到主界面"; + String get edit => "编辑"; @override String get mainPageTitle => "主页"; @override - String get bottomNavYours => "你的"; + String get tagListTooltip => "使用英文逗号分隔标签,每个标签的首尾空格将被去除"; + @override + String get globalFeed => "全部文章"; @override String get newPassword => "新密码"; @override + String get newArticlePageTitle => "新文章"; + @override + String get registerSuccessful => "注册成功,将跳转到登录界面"; + @override + String get article => "文章"; + @override + String get myArticles => "我的文章"; + @override + String get notEmpty => "不要为空"; + @override + String get unfollow => "取消关注"; + @override + String get favoritedArticles => "我喜爱的文章"; + @override + String get generalBizarreError => "奇怪的事情发生了,请重试"; + @override + String get submit => "提交!"; + @override + String get searchByTagInfo => "请选择一个你感兴趣的标签:"; + @override + String get validatorNotEmpty => "此栏不可为空"; + @override + String get description => "简介"; + @override + String get appTitle => "仿真 Flutter"; + @override + String get login => "登录"; + @override + String get error => "错误"; + @override + String get title => "标题"; + @override + String get seeProfile => "我的主页"; + @override + String get delete => "删除"; + @override + String get loginSuccessful => "登陆成功,将跳转到主页面"; + @override + String get aboutApp => "关于 APP"; + @override + String get search => "搜索"; + @override + String get validatorTooShortPassword => "密码应大于等于6个字符"; + @override + String get profilePageTitle => "我的"; + @override + String get ok => "好"; + @override + String get email => "邮箱地址"; + @override + String get emptyNow => "当前数据为空"; + @override + String get emailAndPasswordShouldNotBeEmpty => "邮箱地址和密码均不能为空!"; + @override + String get logoutSuccessful => "登出成功,将跳转到主界面"; + @override String get biography => "自我介绍"; @override String get logoutSuccessfulTitle => "登出成功!"; @override String get follow => "关注"; @override - String get registerSuccessful => "注册成功,将跳转到登录界面"; + String get tagList => "标签"; @override - String get myArticles => "我的文章"; + String get newArticleSuccessfulTitle => "创建新文章"; @override String get repassword => "请再次输入密码"; @override - String get bottomNavGlobal => "全部"; - @override - String get notEmpty => "不要为空"; - @override - String get unfollow => "取消关注"; + String get aboutInfo => "作者:CasterKKK\nGithub:https://github.com/CasterKKK\n该项目地址:https://github.com/CasterKKK/flutter_realworld_app\n请随意 fork 或提 PR,如果能点个星星是极好的!"; @override String get registerSuccessfulTitle => "吼!注册成功!"; @override String get username => "用户名"; @override + String searchResultPageTitle(String tag) => "搜索结果 -- $tag"; + @override String errorUnknown(String err) => "未知错误:$err"; } diff --git a/lib/main.dart b/lib/main.dart index f5500d7..d1726f4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,19 +1,33 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_realworld_app/api.dart'; import 'package:flutter_realworld_app/generated/i18n.dart'; import 'package:flutter_realworld_app/models/app_state.dart'; import 'package:flutter_realworld_app/pages/login_page.dart'; import 'package:flutter_realworld_app/pages/main_page.dart'; +import 'package:flutter_realworld_app/pages/new_article_page.dart'; import 'package:flutter_realworld_app/pages/register_page.dart'; -import 'package:flutter_realworld_app/pages/setting_page.dart'; +import 'package:flutter_realworld_app/pages/search_page.dart'; +import 'package:flutter_realworld_app/pages/settings_page.dart'; import 'package:flutter_redurx/flutter_redurx.dart'; +import 'package:shared_preferences/shared_preferences.dart'; -void main() { - final appState = AppState((b) => b +void main() async { + var appState = AppState((b) => b ..currentUser = null ..currentProfile = null); - runApp(Provider(store: Store(appState), child: RealworldApp())); + try { + final api = await Api.getInstance(); + final currentUser = await api.authCurrent(); + appState = + appState.rebuild((b) => b..currentUser = currentUser.toBuilder()); + } catch (e) { + final prefs = await SharedPreferences.getInstance(); + prefs.remove('jwt'); + } finally { + runApp(Provider(store: Store(appState), child: RealworldApp())); + } } class RealworldApp extends StatelessWidget { @@ -21,15 +35,16 @@ class RealworldApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( routes: { - '/main': (context) => MainPage(), '/login': (context) => LoginPage(), '/register': (context) => RegisterPage(), - '/setting': (context) => SettingPage() + '/settings': (context) => SettingsPage(), + '/newArticle': (context) => NewArticlePage(), + '/search': (context) => SearchPage() }, - title: "Realworld Flutter App", + onGenerateTitle: (context) => S.of(context).appTitle, theme: ThemeData( primaryColor: Colors.lightGreen, accentColor: Colors.orange), - home: MainPage(), + home: MainPage(MainPageType.GLOBAL_FEED), localizationsDelegates: [ S.delegate, GlobalMaterialLocalizations.delegate, diff --git a/lib/pages/article_page.dart b/lib/pages/article_page.dart new file mode 100644 index 0000000..8c1c993 --- /dev/null +++ b/lib/pages/article_page.dart @@ -0,0 +1,218 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_realworld_app/api.dart'; +import 'package:flutter_realworld_app/components/chipper.dart'; +import 'package:flutter_realworld_app/generated/i18n.dart'; +import 'package:flutter_realworld_app/models/app_state.dart'; +import 'package:flutter_realworld_app/models/article.dart'; +import 'package:flutter_realworld_app/models/profile.dart'; +import 'package:flutter_realworld_app/models/user.dart'; +import 'package:flutter_realworld_app/util.dart' as util; +import 'package:flutter_redurx/flutter_redurx.dart'; + +enum MoreOption { EDIT, DELETE } + +class ArticlePage extends StatefulWidget { + final Article _article; + + const ArticlePage(this._article, {Key key}) : super(key: key); + + @override + State createState() => _ArticlePageState(this._article); +} + +class _ArticlePageState extends State { + Article _article; + + _ArticlePageState(this._article); + + bool _isMe(AuthUser currentUser) => currentUser == null + ? false + : (currentUser.username == _article.author.username); + + MaterialButton _followButton(AuthUser currentUser, Profile profile) { + if (currentUser == null) return null; + if (_isMe(currentUser)) return null; + + if (profile.following) + return FlatButton( + color: Theme.of(context).accentColor, + child: Text(S.of(context).unfollow), + onPressed: () async { + try { + final api = await Api.getInstance(); + final newProfile = await api.profileUnfollow(profile.username); + setState(() { + _article = + _article.rebuild((b) => b..author = newProfile.toBuilder()); + }); + } catch (e) { + util.errorHandle(e, context); + } + }, + ); + return FlatButton( + color: Theme.of(context).accentColor, + child: Text(S.of(context).follow), + onPressed: () async { + try { + final api = await Api.getInstance(); + final newProfile = await api.profileFollow(profile.username); + setState(() { + _article = + _article.rebuild((b) => b..author = newProfile.toBuilder()); + }); + } catch (e) { + util.errorHandle(e, context); + } + }, + ); + } + + List _moreButton(AuthUser currentUser) => _isMe(currentUser) + ? [ + PopupMenuButton( + itemBuilder: (context) => >[ + PopupMenuItem( + child: Text(S.of(context).edit), + value: MoreOption.EDIT, + ), + PopupMenuItem( + child: Text(S.of(context).delete), + value: MoreOption.DELETE, + ), + ], + onSelected: (MoreOption op) { + switch (op) { + case MoreOption.EDIT: + // todo navigate to edit page + break; + case MoreOption.DELETE: + // todo delete this page + break; + default: + util.errorHandle( + ArgumentError(S.of(context).generalBizarreError), + context); + } + }, + ) + ] + : null; + + @override + Widget build(BuildContext context) { + return Connect( + where: (AppState oldState, AppState newState) => oldState != newState, + convert: (AppState state) => state, + builder: (AppState state) { + return FutureBuilder( + future: Api.getInstance() + .then((api) => api.profileGet(_article.author.username)) + .catchError((err) => util.errorHandle(err, context)), + builder: (context, snapshot) { + if (snapshot.hasData) { + final profile = snapshot.data; + return Scaffold( + body: NestedScrollView( + headerSliverBuilder: (BuildContext context, + bool innerBoxIsScrolled) => + [ + SliverAppBar( + expandedHeight: 200, + pinned: true, + primary: true, + leading: IconButton( + icon: Icon(Icons.arrow_back), + onPressed: () { + Navigator.of(context).pop(); + }), + actions: _moreButton(state.currentUser), + flexibleSpace: FlexibleSpaceBar( + title: Text(S.of(context).article), + background: Padding( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + + 50.0, + left: 8.0, + right: 8.0), + child: Column( + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.only( + right: 4.0), + child: CircleAvatar( + backgroundImage: util.isNullEmpty( + _article.author.image, + trim: true) == + null + ? AssetImage( + 'res/assets/smiley-cyrus.jpg') + : CachedNetworkImageProvider( + _article.author.image), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text(_article.author.username), + Text(util.dateFormatter.format( + _article.createdAt)), + ], + ), + ), + _followButton( + state.currentUser, profile), + ].where((w) => w != null).toList(), + ), + ], + ), + ), + ), + ), + ], + body: Container( + padding: + EdgeInsets.only(left: 8.0, right: 8.0, top: 8.0), + child: ListView( + padding: EdgeInsets.only(bottom: 8.0), + children: [ + Text( + _article.title, + style: Theme.of(context).textTheme.display1, + ), + Wrap( + spacing: 8.0, + children: _article.tagList + .map((s) => GestureDetector( + onTap: () {}, + child: Chipper(s), + )) + .toList(), + ), + Divider(), + MarkdownBody(data: _article.body), + ], + ), + ), + ), + ); + } else { + return Scaffold( + appBar: AppBar( + title: Text(S.of(context).article), + ), + body: Center( + child: CircularProgressIndicator(), + ), + ); + } + }); + }); + } +} diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart index 8d9d1a1..519b137 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_realworld_app/actions.dart'; import 'package:flutter_realworld_app/generated/i18n.dart'; import 'package:flutter_realworld_app/models/app_state.dart'; -import 'package:flutter_realworld_app/pages/register_page.dart'; +import 'package:flutter_realworld_app/pages/main_page.dart'; import 'package:flutter_realworld_app/util.dart' as util; import 'package:flutter_redurx/flutter_redurx.dart'; @@ -75,7 +75,7 @@ class _LoginPageState extends State { Text(S.of(context).emailAndPasswordShouldNotBeEmpty), actions: [ FlatButton( - child: Text(S.of(context).okSlang), + child: Text(S.of(context).ok), onPressed: () { Navigator.of(context).pop(); }, @@ -88,8 +88,11 @@ class _LoginPageState extends State { context, AuthLogin(_email, _password, successCallback: () { util.finishLoading(context); - Navigator.pushNamedAndRemoveUntil( - context, '/main', (route) => route == null); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (context) => + MainPage(MainPageType.GLOBAL_FEED)), + ModalRoute.withName('/')); Flushbar() ..title = S.of(context).loginSuccessfulTitle ..message = S.of(context).loginSuccessful @@ -108,8 +111,7 @@ class _LoginPageState extends State { S.of(context).createANewUser, ), onPressed: () { - Navigator.of(context) - .push(MaterialPageRoute(builder: (context) => RegisterPage())); + Navigator.of(context).pushNamed("/register"); }, ); diff --git a/lib/pages/main_page.dart b/lib/pages/main_page.dart index 3db7384..f7e5adf 100644 --- a/lib/pages/main_page.dart +++ b/lib/pages/main_page.dart @@ -2,16 +2,68 @@ import 'package:flutter/material.dart'; import 'package:flutter_realworld_app/api.dart'; import 'package:flutter_realworld_app/components/app_drawer.dart'; import 'package:flutter_realworld_app/components/article_item.dart'; +import 'package:flutter_realworld_app/components/scrollable_loading_list.dart'; import 'package:flutter_realworld_app/generated/i18n.dart'; import 'package:flutter_realworld_app/models/app_state.dart'; import 'package:flutter_realworld_app/models/article.dart'; import 'package:flutter_realworld_app/models/user.dart'; -import 'package:flutter_realworld_app/pages/login_page.dart'; import 'package:flutter_redurx/flutter_redurx.dart'; -class MainPage extends StatelessWidget { +enum MainPageType { YOUR_FEED, GLOBAL_FEED } + +class MainPage extends StatefulWidget { + final MainPageType _type; + + MainPage(this._type, {Key key}) : super(key: key); + + @override + State createState() => _MainPageState(this._type); +} + +class _MainPageState extends State { + final MainPageType _type; final GlobalKey _scaffoldKey = GlobalKey(); + _MainPageState(this._type); + + String _getSubtitle(BuildContext context) { + switch (_type) { + case MainPageType.GLOBAL_FEED: + return S.of(context).globalFeed; + case MainPageType.YOUR_FEED: + return S.of(context).yourFeed; + default: + return null; + } + } + + FloatingActionButton _fab(AuthUser currentUser, BuildContext context) => + currentUser == null + ? FloatingActionButton( + shape: StadiumBorder(), + onPressed: () { + Navigator.of(context).pushNamed('/login'); + }, + child: Text(S.of(context).login), + ) + : FloatingActionButton( + onPressed: () { + Navigator.of(context).pushNamed('/newArticle'); + }, + child: Icon(Icons.add), + ); + + Future> _loadingDataFunction({int offset}) async { + final api = await Api.getInstance(); + List
articles = []; + if (_type == MainPageType.YOUR_FEED) { + articles = (await api.articleListFeed(offset: offset)).articles.toList(); + } else if (_type == MainPageType.GLOBAL_FEED) { + articles = (await api.articleListGet(offset: offset)).articles.toList(); + } + return articles; + } + @override Widget build(BuildContext context) => Connect( convert: (AppState state) => state, @@ -24,54 +76,25 @@ class MainPage extends StatelessWidget { onPressed: () { _scaffoldKey.currentState.openDrawer(); }), - title: Text(S.of(context).mainPageTitle), + title: Text( + "${S.of(context).mainPageTitle} -- ${_getSubtitle(context)}"), actions: [ IconButton( icon: Icon(Icons.search), onPressed: () { - print("search"); + Navigator.pushNamed(context, '/search'); }, ) ], ), drawer: AppDrawer(state.currentUser, state.currentProfile), - body: FutureBuilder( - future: Api.getInstance().then((api) => api.articleListGet()), - builder: (BuildContext context, - AsyncSnapshot snapshot) { - switch (snapshot.connectionState) { - case ConnectionState.waiting: - return Center( - child: CircularProgressIndicator(), - ); - case ConnectionState.none: - return Center( - child: Text("None found"), - ); - default: - return ListView( - children: snapshot.data.articles - .map((a) => ArticleItem(a)) - .toList()); - } - }), + body: ScrollableLoadingList
( + loadingDataFunction: _loadingDataFunction, + itemConstructor: (Article a) => ArticleItem(a), + ), floatingActionButton: _fab(state.currentUser, context), floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, ), ); - - FloatingActionButton _fab(AuthUser user, BuildContext context) => user == null - ? FloatingActionButton( - shape: StadiumBorder(), - onPressed: () { - Navigator.of(context) - .push(MaterialPageRoute(builder: (context) => LoginPage())); - }, - child: Text(S.of(context).login), - ) - : FloatingActionButton( - onPressed: () {}, - child: Icon(Icons.add), - ); } diff --git a/lib/pages/new_article_page.dart b/lib/pages/new_article_page.dart new file mode 100644 index 0000000..9e7e759 --- /dev/null +++ b/lib/pages/new_article_page.dart @@ -0,0 +1,124 @@ +import 'package:flushbar/flushbar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_realworld_app/api.dart'; +import 'package:flutter_realworld_app/generated/i18n.dart'; +import 'package:flutter_realworld_app/pages/article_page.dart'; +import 'package:flutter_realworld_app/util.dart' as util; + +class NewArticlePage extends StatefulWidget { + @override + State createState() => _NewArticlePageState(); +} + +class _NewArticlePageState extends State { + final _formKey = GlobalKey(); + + final _titleController = TextEditingController(); + final _descrptionController = TextEditingController(); + final _bodyController = TextEditingController(); + final _tagListController = TextEditingController(); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: Text(S.of(context).newArticlePageTitle), + leading: IconButton( + icon: Icon(Icons.arrow_back), + onPressed: () { + Navigator.of(context).pop(); + }), + actions: [ + IconButton( + icon: Icon(Icons.send), + onPressed: () async { + if (_formKey.currentState.validate()) { + final tagList = _tagListController.text + .split(",") + .map((s) => s.trim()) + .where((s) => !util.isNullEmpty(s)) + .toList(); + util.startLoading(context); + try { + final api = await Api.getInstance(); + final newArticle = await api.articleCreate( + _titleController.text, + _descrptionController.text, + _bodyController.text, + tagList); + util.finishLoading(context); + Flushbar() + ..title = S.of(context).newArticleSuccessfulTitle + ..message = S.of(context).newArticleSuccessful + ..duration = Duration(seconds: 5); + Navigator.of(context).pushReplacement(MaterialPageRoute( + builder: (context) => ArticlePage(newArticle))); + } catch (e) { + util.finishLoading(context); + util.errorHandle(e, context); + } + } + }, + ) + ], + ), + body: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Form( + autovalidate: true, + key: _formKey, + child: ListView( + children: [ + TextFormField( + decoration: InputDecoration(labelText: S.of(context).title), + validator: (value) { + if (util.isNullEmpty(value, trim: true)) { + return S.of(context).validatorNotEmpty; + } + }, + controller: _titleController, + ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: TextFormField( + decoration: + InputDecoration(labelText: S.of(context).description), + validator: (value) { + if (util.isNullEmpty(value, trim: true)) { + return S.of(context).validatorNotEmpty; + } + }, + controller: _descrptionController, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: TextFormField( + keyboardType: TextInputType.multiline, + maxLines: null, + decoration: + InputDecoration(labelText: S.of(context).articleBody), + validator: (value) { + if (util.isNullEmpty(value)) { + return S.of(context).validatorNotEmpty; + } + }, + controller: _bodyController, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Tooltip( + child: TextFormField( + decoration: + InputDecoration(labelText: S.of(context).tagList), + controller: _tagListController, + ), + message: S.of(context).tagListTooltip, + ), + ), + ], + ), + ), + ), + ); +} diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index a8e1cb4..a3a6667 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -1,14 +1,13 @@ -import 'package:built_collection/built_collection.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_realworld_app/api.dart'; import 'package:flutter_realworld_app/components/article_item.dart'; +import 'package:flutter_realworld_app/components/scrollable_loading_list.dart'; import 'package:flutter_realworld_app/generated/i18n.dart'; import 'package:flutter_realworld_app/models/app_state.dart'; import 'package:flutter_realworld_app/models/article.dart'; import 'package:flutter_realworld_app/models/profile.dart'; import 'package:flutter_realworld_app/models/user.dart'; -import 'package:flutter_realworld_app/pages/setting_page.dart'; import 'package:flutter_realworld_app/util.dart' as util; import 'package:flutter_redurx/flutter_redurx.dart'; import 'package:tuple/tuple.dart'; @@ -28,11 +27,8 @@ class _ProfilePageState extends State with TickerProviderStateMixin { // This is the person you are looking at!!! final Profile _profile; - - bool _following; - + bool _following = false; TabController _tabController; - Map<_TabState, Tuple2>> _cached; List> get _tabs => [ Tuple2( @@ -49,46 +45,20 @@ class _ProfilePageState extends State )) ]; - _ProfilePageState(this._profile) { - this._following = _profile.following; - this._tabController = TabController(length: 2, vsync: this); - _tabController.addListener(() { - if (_tabController.indexIsChanging) { - _getArticles(_TabState.values[_tabController.index]); - } - }); - _cached = Map.fromEntries( - _TabState.values.map((i) => MapEntry(i, Tuple2(DateTime.now(), [])))); + @override + void initState() { + super.initState(); + Api.getInstance() + .then((api) => api.profileGet(_profile.username)) + .then((p) => setState(() => _following = p.following)); } - void _getArticles(_TabState code) { - print("get article $code"); - if (DateTime.now().difference(_cached[code].item1).inMinutes <= 1) { - return; - } - Api.getInstance().then((api) { - switch (code) { - case _TabState.MY: - return api.articleListGet(author: _profile.username); - case _TabState.FAVORITED: - return api.articleListGet(favorited: _profile.username); - default: - return Future.value(ArticleList((b) => b - ..articles = SetBuilder() - ..articlesCount = 0)); - } - }).then((ArticleList list) { - setState(() { - _cached[code] = Tuple2(DateTime.now(), list.articles.toList()); - print("ArticleList: ${list.articles}"); - }); - }).catchError((err) { - util.errorHandle(err, context); - }); + _ProfilePageState(this._profile) { + this._tabController = TabController(length: 2, vsync: this); } MaterialButton _followButton(bool isMe, AuthUser currentUser) { - if (isMe) return null; + if (isMe || currentUser == null) return null; if (_following) return FlatButton( color: Theme.of(context).accentColor, @@ -97,9 +67,9 @@ class _ProfilePageState extends State ? null : () async { final api = await Api.getInstance(); - await api.profileUnfollow(_profile.username); + final newProfile = await api.profileUnfollow(_profile.username); setState(() { - _following = false; + _following = newProfile.following; }); }, ); @@ -110,146 +80,112 @@ class _ProfilePageState extends State ? null : () async { final api = await Api.getInstance(); - await api.profileFollow(_profile.username); + final newProfile = await api.profileFollow(_profile.username); setState(() { - _following = true; + _following = newProfile.following; }); }, ); } - @override - Widget build(BuildContext context) => Connect( - where: (AuthUser oldState, AuthUser newState) => oldState != newState, - convert: (AppState state) => state.currentUser, - builder: (AuthUser currentUser) { - final isMe = currentUser.username == _profile.username; - return Scaffold( - body: NestedScrollView( - headerSliverBuilder: (BuildContext context, - bool innerBoxIsScrolled) => - [ - SliverAppBar( - elevation: 0.0, - expandedHeight: 400.0, - pinned: true, - leading: IconButton( - icon: Icon(Icons.arrow_back), - onPressed: () { - Navigator.of(context).pop(); - }), - actions: isMe - ? [ - IconButton( - icon: Icon(Icons.settings), - onPressed: () { - Navigator.of(context).push(MaterialPageRoute( - builder: (BuildContext context) => - SettingPage())); - }, - ) - ] - : null, - flexibleSpace: FlexibleSpaceBar( - title: Text(S.of(context).profilePageTitle), - background: Padding( - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + 50.0), - child: Column( - children: [ - CircleAvatar( - radius: 50.0, - backgroundImage: util.isNullEmpty( - _profile.image, - trim: true) == - null - ? AssetImage('res/assets/smiley-cyrus.jpg') - : CachedNetworkImageProvider( - _profile.image), - ), - Stack( - children: [ - Container( - alignment: Alignment.center, - child: Text( - _profile.username, - style: Theme.of(context) - .textTheme - .display1 - .merge( - TextStyle(color: Colors.black)), - ), - ), - Container( - padding: EdgeInsets.only(right: 8.0), - alignment: Alignment.centerRight, - child: _followButton(isMe, currentUser)) - ].where((Object o) => o != null).toList(), - ), - ], - ), - )), - ), - SliverPersistentHeader( - pinned: true, - delegate: _SliverAppBarDelegate( - TabBar( - controller: _tabController, - labelColor: Theme.of(context).accentColor, - unselectedLabelColor: Colors.grey, - tabs: _tabs.map((t) => t.item2).toList(), - ), - ), - ) - ], - body: Center( - child: TabBarView( - controller: _tabController, - children: _TabState.values.map((_TabState s) { - if (_cached[s].item2.isEmpty) { - return Center( - child: Text("Empty now"), - ); - } else { - return ListView( - children: _cached[s] - .item2 - .map((Article a) => ArticleItem(a)) - .toList(), - ); - } - }).toList(), - ), - ), - ), - ); - }); -} - -class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { - _SliverAppBarDelegate(this._tabBar); - - final TabBar _tabBar; - - @override - double get minExtent => _tabBar.preferredSize.height; - - @override - double get maxExtent => _tabBar.preferredSize.height; - - @override - Widget build( - BuildContext context, double shrinkOffset, bool overlapsContent) => - Container( + Widget _tabbar() => Container( color: Colors.white, child: Material( elevation: 4.0, - child: _tabBar, + child: TabBar( + controller: _tabController, + labelColor: Theme.of(context).accentColor, + unselectedLabelColor: Colors.grey, + tabs: _tabs.map((t) => t.item2).toList(), + ), + ), + ); + + Widget _header(bool isMe, AppState state, String imageUrl) => Container( + padding: EdgeInsets.only(bottom: 4.0), + alignment: Alignment.center, + color: Theme.of(context).primaryColor, + child: Column( + children: [ + CircleAvatar( + radius: 50.0, + backgroundImage: util.isNullEmpty(imageUrl, trim: true) == null + ? AssetImage('res/assets/smiley-cyrus.jpg') + : CachedNetworkImageProvider(imageUrl), + ), + Text( + _profile.username, + style: Theme.of(context) + .textTheme + .display1 + .merge(TextStyle(color: Colors.black)), + ), + _profile.bio == null + ? null + : Text( + _profile.bio, + style: Theme.of(context).textTheme.caption, + ), + _followButton(isMe, state.currentUser), + ].where((o) => o != null).toList(), + ), + ); + + Widget _tabbarBody() => Expanded( + child: TabBarView( + physics: NeverScrollableScrollPhysics(), + controller: _tabController, + children: _TabState.values + .map( + (_TabState s) => ScrollableLoadingList
( + itemConstructor: (Article a) => ArticleItem(a), + loadingDataFunction: ({int offset}) async { + final api = await Api.getInstance(); + List
articles = []; + if (s == _TabState.FAVORITED) { + articles = (await api.articleListGet( + favorited: _profile.username)) + .articles + .toList(); + } else if (s == _TabState.MY) { + articles = (await api.articleListGet( + author: _profile.username)) + .articles + .toList(); + } + return articles; + }, + ), + ) + .toList(), ), ); @override - bool shouldRebuild(_SliverAppBarDelegate oldDelegate) { - return false; - } + Widget build(BuildContext context) => Connect( + where: (AppState oldState, AppState newState) => oldState != newState, + convert: (AppState state) => state, + builder: (AppState state) { + final isMe = state.currentUser?.username == _profile.username; + return Scaffold( + appBar: AppBar( + elevation: 0.0, + leading: IconButton( + icon: Icon(Icons.arrow_back), + onPressed: () { + Navigator.pop(context); + }), + title: Text(S.of(context).profilePageTitle), + ), + body: Column( + children: [ + _header(isMe, state, _profile.image), + _tabbar(), + _tabbarBody(), + ], + ), + ); + ; + }, + ); } diff --git a/lib/pages/search_page.dart b/lib/pages/search_page.dart new file mode 100644 index 0000000..2676ea8 --- /dev/null +++ b/lib/pages/search_page.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_realworld_app/api.dart'; +import 'package:flutter_realworld_app/components/chipper.dart'; +import 'package:flutter_realworld_app/generated/i18n.dart'; + +class SearchPage extends StatelessWidget { + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + leading: IconButton( + icon: Icon(Icons.arrow_back), + onPressed: () { + Navigator.pop(context); + }), + title: Text(S.of(context).search), + ), + body: FutureBuilder( + future: Api.getInstance().then((api) => api.tagGet()), + builder: + (BuildContext context, AsyncSnapshot> snapshot) { + if (snapshot.hasData) { + final tags = snapshot.data; + return Center( +// padding: const EdgeInsets.all(8.0), +// alignment: Alignment.center, + child: Wrap( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Text( + S.of(context).searchByTagInfo, + style: Theme.of(context).textTheme.title, + ), + ), + Wrap( + spacing: 4.0, + children: tags + .map( + (s) => Chipper(s, canReplaceLastPage: true), + ) + .toList(), + ), + ], + ), + ); + } else + return Center( + child: CircularProgressIndicator(), + ); + }, + ), + ); +} diff --git a/lib/pages/search_result_page.dart b/lib/pages/search_result_page.dart new file mode 100644 index 0000000..fdc15e1 --- /dev/null +++ b/lib/pages/search_result_page.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_realworld_app/api.dart'; +import 'package:flutter_realworld_app/components/article_item.dart'; +import 'package:flutter_realworld_app/components/scrollable_loading_list.dart'; +import 'package:flutter_realworld_app/generated/i18n.dart'; +import 'package:flutter_realworld_app/models/article.dart'; + +class SearchResultPage extends StatefulWidget { + final String _tag; + + const SearchResultPage(this._tag, {Key key}) : super(key: key); + + @override + State createState() => _SearchResultPage(_tag); +} + +class _SearchResultPage extends State { + final String _tag; + + _SearchResultPage(this._tag); + + Future> _loadingDataFunction({int offset}) async { + final api = await Api.getInstance(); + return (await api.articleListGet(tag: _tag, offset: offset)) + .articles + .toList(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + leading: IconButton( + icon: Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context)), + title: Text(S.of(context).searchResultPageTitle(_tag)), + ), + body: ScrollableLoadingList
( + loadingDataFunction: _loadingDataFunction, + itemConstructor: (Article a) => ArticleItem(a), + ), + ); +} diff --git a/lib/pages/setting_page.dart b/lib/pages/settings_page.dart similarity index 89% rename from lib/pages/setting_page.dart rename to lib/pages/settings_page.dart index 2836cc4..ff1beb7 100644 --- a/lib/pages/setting_page.dart +++ b/lib/pages/settings_page.dart @@ -4,16 +4,17 @@ import 'package:flutter_realworld_app/actions.dart'; import 'package:flutter_realworld_app/generated/i18n.dart'; import 'package:flutter_realworld_app/models/app_state.dart'; import 'package:flutter_realworld_app/models/user.dart'; +import 'package:flutter_realworld_app/pages/main_page.dart'; import 'package:flutter_realworld_app/util.dart' as util; import 'package:flutter_redurx/flutter_redurx.dart'; import 'package:validators/validators.dart' as v; -class SettingPage extends StatefulWidget { +class SettingsPage extends StatefulWidget { @override - State createState() => _SettingPageState(); + State createState() => _SettingsPageState(); } -class _SettingPageState extends State { +class _SettingsPageState extends State { final _formKey = GlobalKey(); String _avatarUrl; @@ -46,13 +47,17 @@ class _SettingPageState extends State { AuthUpdate( successCallback: () { util.finishLoading(context); - Navigator.of(context).pop(); - Navigator.of(context).pop(); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (context) => + MainPage(MainPageType.GLOBAL_FEED)), + ModalRoute.withName('/')); Flushbar() - ..title = - S.of(context).settingChangeSuccessfulTitle + ..title = S + .of(context) + .settingsChangeSuccessfulTitle ..message = - S.of(context).settingChangeSuccessful + S.of(context).settingsChangeSuccessful ..duration = Duration(seconds: 5) ..show(context); }, diff --git a/lib/util.dart b/lib/util.dart index 70d0464..281c82d 100644 --- a/lib/util.dart +++ b/lib/util.dart @@ -2,6 +2,9 @@ import 'package:dio/dio.dart'; import 'package:flushbar/flushbar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_realworld_app/generated/i18n.dart'; +import 'package:intl/intl.dart'; + +final dateFormatter = DateFormat('yyyy-mm-dd HH:MM:ss'); void errorHandle(Error e, BuildContext context) { if (e is DioError) { @@ -51,9 +54,11 @@ void startLoading(BuildContext context) => transitionDuration: Duration(seconds: 1), pageBuilder: (BuildContext buildContext, Animation animation, Animation secondaryAnimation) => - Center( - child: CircularProgressIndicator(), - ), + WillPopScope( + child: Center( + child: CircularProgressIndicator(), + ), + onWillPop: () async => false), ); void finishLoading(BuildContext context) => Navigator.of(context).pop(); @@ -63,17 +68,42 @@ bool isNullEmpty(String s, {bool trim = false}) => void showInfoDialog(BuildContext context, {String title, String content}) => showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext context) => AlertDialog( - title: title == null ? null : Text(title), - content: content == null ? null : Text(content), - actions: [ - FlatButton( - child: Text(S.of(context).okSlang), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ], - )); + context: context, + barrierDismissible: false, + builder: (BuildContext context) => AlertDialog( + title: title == null ? null : Text(title), + content: content == null ? null : Text(content), + actions: [ + FlatButton( + child: Text(S.of(context).ok), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + ); + +void showAbout(BuildContext context) => showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) => AlertDialog( + title: Text(S.of(context).aboutApp), + actions: [ + FlatButton( + child: Text(S.of(context).ok), + onPressed: () => Navigator.pop(context), + ) + ], + content: SingleChildScrollView( + child: ListBody( + children: [ + Text( + S.of(context).aboutInfo, + style: Theme.of(context).textTheme.body2, + ), + ], + ), + ), + ), + ); diff --git a/pubspec.yaml b/pubspec.yaml index 4203aea..a1a121b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: sdk: flutter validators: ^1.0.0+1 flutter_redurx: ^0.4.3 + flutter_markdown: ^0.2.0 built_value: ^6.1.6 built_collection: ^4.0.0 cupertino_icons: ^0.1.2 diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index 5dd677d..ae97ac6 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -2,14 +2,16 @@ "mainPageTitle": "Main", "profilePageTitle": "Profile", "appTitle": "Flutter Realworld App", - "bottomNavYours": "Yours", - "bottomNavGlobal": "Global", + "newArticlePageTitle": "New Article", + "searchResultPageTitle": "Search Result -- $tag", + "yourFeed": "Your Feed", + "globalFeed": "Global Feed", "seeProfile": "See Profile", "settings": "Settings", "login": "Login", "logout": "Logout", "conduitSlogan": "A place to share your knowledge.", - "about": "About", + "aboutApp": "About APP", "error": "Error", "error401": "Unauthorized requests: please login and try again", "error403": "Forbidden requests: you don't have permissions to perform the action", @@ -34,15 +36,29 @@ "createANewUser": "Create a new user", "notEmpty": "Not Empty", "emailAndPasswordShouldNotBeEmpty": "Email and password should not be empty!", - "okSlang": "Alright...", - "settingChangeSuccessfulTitle": "Wry! Changing Successful!", - "settingChangeSuccessful" : "Setting is changed successfully, you will be navigated to Main Page now", + "ok": "OK", + "settingsChangeSuccessfulTitle": "Wry! Changing Successful!", + "settingsChangeSuccessful" : "Settings are changed successfully, you will be navigated to Main Page now", "loginSuccessfulTitle": "Login Successful!", "loginSuccessful" : "Login successful, you will be navigated to Main Page now", "logoutSuccessfulTitle": "Logout Successful!", "logoutSuccessful" : "Logout successful, you will be navigated to Main Page now", "registerSuccessfulTitle": "Register Successful!", - "registerSuccessful": "Register Successful, you will be navigated to Login Page", - "submit": "Submit!" - + "registerSuccessful": "Register successful, you will be navigated to Login Page", + "newArticleSuccessfulTitle": "New Article Successful!", + "newArticleSuccessful": "Creation successful, you will ne navigated to this new article", + "submit": "Submit!", + "title": "Title", + "description": "Description", + "articleBody": "Body", + "tagList": "Tag List", + "tagListTooltip": "Using English-style's comma to separate the tags. the blanks at the head and tail of each tag will be removed", + "edit": "Edit", + "delete": "Delete", + "generalBizarreError" : "Something weird happened, please try again", + "article" : "Article", + "emptyNow": "Empty Now...", + "search": "Search", + "searchByTagInfo": "Please choose a Tag you are interested in:", + "aboutInfo": "Author: CasterKKK\nGithub: https://github.com/CasterKKK\nThis project: https://github.com/CasterKKK/flutter_realworld_app\nFeel free to fork or make PR, and thanks for staring this project!" } diff --git a/res/values/strings_zh_CN.arb b/res/values/strings_zh_CN.arb index 5f4cf38..18badfd 100644 --- a/res/values/strings_zh_CN.arb +++ b/res/values/strings_zh_CN.arb @@ -2,14 +2,16 @@ "mainPageTitle": "主页", "profilePageTitle": "我的", "appTitle": "仿真 Flutter", - "bottomNavYours": "你的", - "bottomNavGlobal": "全部", + "newArticlePageTitle": "新文章", + "searchResultPageTitle": "搜索结果 -- $tag", + "yourFeed": "你所关注的文章", + "globalFeed": "全部文章", "seeProfile": "我的主页", "settings": "设置", "login": "登录", "logout": "退出登录", "conduitSlogan": "属于你的知识宝库。", - "about": "关于", + "aboutApp": "关于 APP", "error": "错误", "error401": "未授权的请求:请登录后重试", "error403": "被禁止的请求:你没有足够的权限进行操作", @@ -34,7 +36,7 @@ "createANewUser": "创建新用户", "notEmpty": "不要为空", "emailAndPasswordShouldNotBeEmpty": "邮箱地址和密码均不能为空!", - "okSlang": "好吧", + "ok": "好", "settingChangeSuccessfulTitle": "耶!更新成功!", "settingChangeSuccessful" : "你的信息已经更新成功,将跳转到主页面", "loginSuccessfulTitle": "哈!登录成功!", @@ -43,5 +45,20 @@ "logoutSuccessful" : "登出成功,将跳转到主界面", "registerSuccessfulTitle": "吼!注册成功!", "registerSuccessful": "注册成功,将跳转到登录界面", - "submit": "提交!" + "newArticleSuccessfulTitle": "创建新文章", + "newArticleSuccessful": "创建成功,即将跳转至那篇文章", + "submit": "提交!", + "title": "标题", + "description": "简介", + "articleBody": "正文", + "tagList": "标签", + "tagListTooltip": "使用英文逗号分隔标签,每个标签的首尾空格将被去除", + "edit": "编辑", + "delete": "删除", + "generalBizarreError" : "奇怪的事情发生了,请重试", + "article" : "文章", + "emptyNow": "当前数据为空", + "search": "搜索", + "searchByTagInfo": "请选择一个你感兴趣的标签:", + "aboutInfo": "作者:CasterKKK\nGithub:https://github.com/CasterKKK\n该项目地址:https://github.com/CasterKKK/flutter_realworld_app\n请随意 fork 或提 PR,如果能点个星星是极好的!" }