Compare commits

..

16 Commits

33 changed files with 967 additions and 433 deletions

View File

@ -29,11 +29,14 @@ def get(user, id):
owner = user and (user.get('_id') == proj['user']) owner = user and (user.get('_id') == proj['user'])
if not owner and proj['visibility'] != 'public': if not owner and proj['visibility'] != 'public':
raise util.errors.BadRequest('Forbidden') raise util.errors.BadRequest('Forbidden')
if obj['type'] == 'file' and 'storedName' in obj:
obj['url'] = uploads.get_presigned_url('projects/{0}/{1}'.format(proj['_id'], obj['storedName']))
if obj['type'] == 'pattern' and 'preview' in obj and '.png' in obj['preview']: if obj['type'] == 'pattern' and 'preview' in obj and '.png' in obj['preview']:
obj['previewUrl'] = uploads.get_presigned_url('projects/{0}/{1}'.format(proj['_id'], obj['preview'])) obj['previewUrl'] = uploads.get_presigned_url('projects/{0}/{1}'.format(proj['_id'], obj['preview']))
del obj['preview'] del obj['preview']
if obj.get('fullPreview'): if obj.get('fullPreview'):
obj['fullPreviewUrl'] = uploads.get_presigned_url('projects/{0}/{1}'.format(proj['_id'], obj['fullPreview'])) obj['fullPreviewUrl'] = uploads.get_presigned_url('projects/{0}/{1}'.format(proj['_id'], obj['fullPreview']))
obj['projectObject'] = proj
return obj return obj
def copy_to_project(user, id, project_id): def copy_to_project(user, id, project_id):

View File

@ -61,8 +61,11 @@ def discover(user, count = 3):
random.shuffle(all_projects) random.shuffle(all_projects)
for p in all_projects: for p in all_projects:
if db.objects.find_one({'project': p['_id'], 'name': {'$ne': 'Untitled pattern'}}): if db.objects.find_one({'project': p['_id'], 'name': {'$ne': 'Untitled pattern'}}):
owner = db.users.find_one({'_id': p['user']}, {'username': 1}) owner = db.users.find_one({'_id': p['user']}, {'username': 1, 'avatar': 1})
p['fullName'] = owner['username'] + '/' + p['path'] p['fullName'] = owner['username'] + '/' + p['path']
p['owner'] = owner
if 'avatar' in p['owner']:
p['owner']['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(p['owner']['_id'], p['owner']['avatar']))
projects.append(p) projects.append(p)
if len(projects) >= count: break if len(projects) >= count: break
@ -95,7 +98,7 @@ def explore(page = 1):
all_public_project_ids = list(map(lambda p: p['_id'], all_public_projects)) all_public_project_ids = list(map(lambda p: p['_id'], all_public_projects))
for project in all_public_projects: for project in all_public_projects:
project_map[project['_id']] = project project_map[project['_id']] = project
objects = list(db.objects.find({'project': {'$in': all_public_project_ids}, 'name': {'$not': re.compile('untitled pattern', re.IGNORECASE)}, 'preview': {'$exists': True}}, {'project': 1, 'name': 1, 'createdAt': 1, 'preview': 1}).sort('createdAt', pymongo.DESCENDING).skip((page - 1) * per_page).limit(per_page)) objects = list(db.objects.find({'project': {'$in': all_public_project_ids}, 'name': {'$not': re.compile('untitled pattern', re.IGNORECASE)}, 'preview': {'$exists': True}}, {'project': 1, 'name': 1, 'createdAt': 1, 'type': 1, 'preview': 1}).sort('createdAt', pymongo.DESCENDING).skip((page - 1) * per_page).limit(per_page))
for object in objects: for object in objects:
object['projectObject'] = project_map.get(object['project']) object['projectObject'] = project_map.get(object['project'])
if 'preview' in object and '.png' in object['preview']: if 'preview' in object and '.png' in object['preview']:

View File

@ -30,13 +30,22 @@ def get(user, username):
if not user or not user['_id'] == fetch_user['_id']: if not user or not user['_id'] == fetch_user['_id']:
project_query['visibility'] = 'public' project_query['visibility'] = 'public'
fetch_user['projects'] = list(db.projects.find(project_query, {'name': 1, 'path': 1, 'description': 1, 'visibility': 1}).limit(15))
for project in fetch_user['projects']:
project['fullName'] = fetch_user['username'] + '/' + project['path']
if 'avatar' in fetch_user: if 'avatar' in fetch_user:
fetch_user['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(fetch_user['_id']), fetch_user['avatar'])) fetch_user['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(fetch_user['_id']), fetch_user['avatar']))
if user: if user:
fetch_user['following'] = fetch_user['_id'] in list(map(lambda f: f['user'], user.get('following', []))) fetch_user['following'] = fetch_user['_id'] in list(map(lambda f: f['user'], user.get('following', [])))
user_projects = list(db.projects.find(project_query, {'name': 1, 'path': 1, 'description': 1, 'visibility': 1}).limit(15))
for project in user_projects:
project['fullName'] = fetch_user['username'] + '/' + project['path']
project['owner'] = {
'_id': fetch_user['_id'],
'username': fetch_user['username'],
'avatar': fetch_user.get('avatar'),
'avatarUrl': fetch_user.get('avatarUrl'),
}
fetch_user['projects'] = user_projects
return fetch_user return fetch_user
def update(user, username, data): def update(user, username, data):

BIN
mobile/assets/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@ -48,6 +48,7 @@
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
9C430D344D81D00E4F8BC572 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9C430D344D81D00E4F8BC572 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
BE18F7F22B54707500363B2E /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = "<group>"; }; BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = "<group>"; };
BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerRelease.entitlements; sourceTree = "<group>"; }; BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerRelease.entitlements; sourceTree = "<group>"; };
BEA6727B24CCB04900BBF836 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; }; BEA6727B24CCB04900BBF836 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
@ -116,6 +117,7 @@
97C146F01CF9000F007C117D /* Runner */ = { 97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
BE18F7F22B54707500363B2E /* Runner.entitlements */,
BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */, BE6C8E7324CDE9B20018AD10 /* RunnerDebug.entitlements */,
BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */, BEA6727A24CCAF5600BBF836 /* RunnerRelease.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FA1CF9000F007C117D /* Main.storyboard */,
@ -373,6 +375,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 38T664W57F; DEVELOPMENT_TEAM = 38T664W57F;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1430" LastUpgradeVersion = "1520"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

View File

@ -12,6 +12,8 @@
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>Treadl</string> <string>Treadl</string>
<key>FlutterDeepLinkingEnabled</key>
<true/>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:treadl.com</string>
<string>applinks:www.treadl.com</string>
</array>
</dict>
</plist>

View File

@ -4,5 +4,10 @@
<dict> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:treadl.com</string>
<string>applinks:www.treadl.com</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@ -4,5 +4,10 @@
<dict> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:treadl.com</string>
<string>applinks:www.treadl.com</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@ -1,15 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'util.dart'; import 'util.dart';
import 'model.dart';
class Api { class Api {
String? _token; String? _token;
//final String apiBase = 'https://api.treadl.com'; final String apiBase = 'https://api.treadl.com';
final String apiBase = 'http://192.168.5.134:2001'; //final String apiBase = 'http://192.168.5.134:2001';
Api({token: null}) {
if (token != null) _token = token;
}
Future<String?> loadToken() async { Future<String?> loadToken() async {
if (_token != null) { if (_token != null) {

89
mobile/lib/explore.dart Normal file
View File

@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';
import 'routeArguments.dart';
import 'api.dart';
import 'util.dart';
import 'lib.dart';
class _ExploreTabState extends State<ExploreTab> {
List<dynamic> objects = [];
List<dynamic> projects = [];
bool loading = false;
final Api api = Api();
final Util util = Util();
@override
initState() {
super.initState();
getData();
}
void getData() async {
setState(() {
loading = true;
});
var data = await api.request('GET', '/search/explore');
if (data['success'] == true) {
setState(() {
loading = false;
objects = data['payload']['objects'];
});
}
var data2 = await api.request('GET', '/search/discover');
if (data2['success'] == true) {
setState(() {
projects = data2['payload']['highlightProjects'];
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Explore'),
),
body: loading ?
Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: CircularProgressIndicator()
)
: Container(
color: Color.fromRGBO(255, 251, 248, 1),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: 10),
CustomText('Discover projects', 'h1', margin: 5),
Container(
height: 130,
child: ListView(
scrollDirection: Axis.horizontal,
children: projects.map((p) => ProjectCard(p)).toList()
)
),
SizedBox(height: 10),
CustomText('Recent patterns', 'h1', margin: 5),
Expanded(child: GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 5,
crossAxisSpacing: 5,
childAspectRatio: 0.9,
children: objects.map((object) =>
PatternCard(object)
).toList(),
)),
]
)
),
);
}
}
class ExploreTab extends StatefulWidget {
@override
_ExploreTabState createState() => _ExploreTabState();
}

View File

@ -6,31 +6,41 @@ import 'group_noticeboard.dart';
import 'group_members.dart'; import 'group_members.dart';
class _GroupScreenState extends State<GroupScreen> { class _GroupScreenState extends State<GroupScreen> {
final String id;
Map<String, dynamic>? _group;
int _selectedIndex = 0; int _selectedIndex = 0;
List<Widget> _widgetOptions = <Widget> [];
final Map<String, dynamic> _group;
_GroupScreenState(this._group) { _GroupScreenState(this.id) { }
_widgetOptions = <Widget> [
GroupNoticeBoardTab(this._group), @override
GroupMembersTab(this._group) void initState() {
]; fetchGroup();
super.initState();
} }
void _onItemTapped(int index) { void fetchGroup() async {
setState(() { Api api = Api();
_selectedIndex = index; var data = await api.request('GET', '/groups/' + id);
}); if (data['success'] == true) {
setState(() {
_group = data['payload'];
});
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(_group['name']) title: Text(_group?['name'] ?? 'Group')
), ),
body: Center( body: Center(
child: _widgetOptions.elementAt(_selectedIndex), child: _group != null ?
[
GroupNoticeBoardTab(_group!),
GroupMembersTab(_group!)
].elementAt(_selectedIndex)
: CircularProgressIndicator(),
), ),
bottomNavigationBar: BottomNavigationBar( bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[ items: const <BottomNavigationBarItem>[
@ -45,15 +55,17 @@ class _GroupScreenState extends State<GroupScreen> {
], ],
currentIndex: _selectedIndex, currentIndex: _selectedIndex,
selectedItemColor: Colors.pink[600], selectedItemColor: Colors.pink[600],
onTap: _onItemTapped, onTap: (int index) => setState(() {
_selectedIndex = index;
}),
), ),
); );
} }
} }
class GroupScreen extends StatefulWidget { class GroupScreen extends StatefulWidget {
final Map<String,dynamic> group; final String id;
GroupScreen(this.group) { } GroupScreen(this.id) { }
@override @override
_GroupScreenState createState() => _GroupScreenState(group); _GroupScreenState createState() => _GroupScreenState(id);
} }

View File

@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'api.dart'; import 'api.dart';
import 'util.dart'; import 'util.dart';
import 'group_noticeboard.dart';
import 'user.dart'; import 'user.dart';
class _GroupMembersTabState extends State<GroupMembersTab> { class _GroupMembersTabState extends State<GroupMembersTab> {
@ -33,14 +33,7 @@ class _GroupMembersTabState extends State<GroupMembersTab> {
Widget getMemberCard(member) { Widget getMemberCard(member) {
return new ListTile( return new ListTile(
onTap: () { onTap: () => context.push('/' + member['username']),
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UserScreen(member),
),
);
},
leading: util.avatarImage(util.avatarUrl(member), size: 40), leading: util.avatarImage(util.avatarUrl(member), size: 40),
trailing: Icon(Icons.keyboard_arrow_right), trailing: Icon(Icons.keyboard_arrow_right),
title: Text(member['username']) title: Text(member['username'])

View File

@ -1,15 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'util.dart';
import 'api.dart'; import 'api.dart';
import 'user.dart';
import 'lib.dart'; import 'lib.dart';
class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> { class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> {
final TextEditingController _newEntryController = TextEditingController(); final TextEditingController _newEntryController = TextEditingController();
final Util utils = new Util();
final Api api = Api(); final Api api = Api();
Map<String,dynamic> _group; Map<String,dynamic> _group;
List<dynamic> _entries = []; List<dynamic> _entries = [];
@ -42,8 +38,10 @@ class _GroupNoticeBoardTabState extends State<GroupNoticeBoardTab> {
} }
void _sendPost(context) async { void _sendPost(context) async {
String text = _newEntryController.text;
if (text.length == 0) return;
setState(() => _posting = true); setState(() => _posting = true);
var data = await api.request('POST', '/groups/' + _group['_id'] + '/entries', {'content': _newEntryController.text}); var data = await api.request('POST', '/groups/' + _group['_id'] + '/entries', {'content': text});
if (data['success'] == true) { if (data['success'] == true) {
_newEntryController.value = TextEditingValue(text: ''); _newEntryController.value = TextEditingValue(text: '');
FocusScope.of(context).requestFocus(FocusNode()); FocusScope.of(context).requestFocus(FocusNode());

View File

@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:convert'; import 'package:provider/provider.dart';
import 'package:http/http.dart' as http; import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'group.dart';
import 'api.dart'; import 'api.dart';
import 'model.dart';
import 'lib.dart';
class _GroupsTabState extends State<GroupsTab> { class _GroupsTabState extends State<GroupsTab> {
List<dynamic> _groups = []; List<dynamic> _groups = [];
@ -16,6 +16,8 @@ class _GroupsTabState extends State<GroupsTab> {
} }
void getGroups() async { void getGroups() async {
AppModel model = Provider.of<AppModel>(context, listen: false);
if (model.user == null) return;
setState(() => _loading = true); setState(() => _loading = true);
Api api = Api(); Api api = Api();
var data = await api.request('GET', '/groups'); var data = await api.request('GET', '/groups');
@ -36,14 +38,7 @@ class _GroupsTabState extends State<GroupsTab> {
} }
return Card( return Card(
child: InkWell( child: InkWell(
onTap: () { onTap: () => context.push('/groups/' + group['_id']),
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => GroupScreen(group),
),
);
},
child: ListTile( child: ListTile(
leading: Icon(Icons.people), leading: Icon(Icons.people),
trailing: Icon(Icons.keyboard_arrow_right), trailing: Icon(Icons.keyboard_arrow_right),
@ -55,38 +50,42 @@ class _GroupsTabState extends State<GroupsTab> {
; ;
} }
Widget getBody() {
AppModel model = Provider.of<AppModel>(context);
if (model.user == null)
return LoginNeeded(text: 'Once logged in, you\'ll find your groups here.');
else if (_loading)
return CircularProgressIndicator();
else if (_groups != null && _groups.length > 0)
return ListView.builder(
itemCount: _groups.length,
itemBuilder: (BuildContext context, int index) {
return buildGroupCard(_groups[index]);
},
);
else
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('You aren\'t a member of any groups yet', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/group.png'), width: 300),
Text('Groups let you meet and keep in touch with others in the weaving community.', textAlign: TextAlign.center),
Text('Please use our website to join and leave groups.', textAlign: TextAlign.center),
]);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('Groups'), title: Text('My Groups'),
), ),
body: _loading ? body: Container(
Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: CircularProgressIndicator()
)
: Container(
margin: const EdgeInsets.all(10.0), margin: const EdgeInsets.all(10.0),
child: (_groups != null && _groups.length > 0) ? alignment: Alignment.center,
ListView.builder( child: getBody()
itemCount: _groups.length, )
itemBuilder: (BuildContext context, int index) {
return buildGroupCard(_groups[index]);
},
)
:
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('You aren\'t a member of any groups yet', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/group.png'), width: 300),
Text('Groups let you meet and keep in touch with others in the weaving community.', textAlign: TextAlign.center),
Text('Please use our website to join and leave groups.', textAlign: TextAlign.center),
])
),
); );
} }
} }

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'explore.dart';
import 'projects.dart'; import 'projects.dart';
import 'groups.dart'; import 'groups.dart';
@ -13,6 +14,7 @@ class HomeScreen extends StatefulWidget {
class _MyStatefulWidgetState extends State<HomeScreen> { class _MyStatefulWidgetState extends State<HomeScreen> {
int _selectedIndex = 0; int _selectedIndex = 0;
List<Widget> _widgetOptions = <Widget> [ List<Widget> _widgetOptions = <Widget> [
ExploreTab(),
ProjectsTab(), ProjectsTab(),
GroupsTab() GroupsTab()
]; ];
@ -31,13 +33,17 @@ class _MyStatefulWidgetState extends State<HomeScreen> {
), ),
bottomNavigationBar: BottomNavigationBar( bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[ items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.explore),
label: 'Explore',
),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.folder), icon: Icon(Icons.folder),
label: 'Projects', label: 'My Projects',
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.person), icon: Icon(Icons.person),
label: 'Groups', label: 'My Groups',
), ),
], ],
currentIndex: _selectedIndex, currentIndex: _selectedIndex,

View File

@ -4,9 +4,12 @@ import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:go_router/go_router.dart';
import 'api.dart'; import 'api.dart';
import 'util.dart'; import 'util.dart';
import 'user.dart'; import 'user.dart';
import 'object.dart';
import 'project.dart';
class Alert extends StatelessWidget { class Alert extends StatelessWidget {
final String type; final String type;
@ -100,7 +103,7 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
if (onDelete != null) { if (onDelete != null) {
onDelete!(_entry); onDelete!(_entry);
} }
Navigator.of(context).pop(); context.pop();
} }
} }
@ -146,7 +149,7 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(); context.pop();
}, },
child: Text('Cancel'), child: Text('Cancel'),
) )
@ -165,11 +168,7 @@ class _NoticeboardPostState extends State<NoticeboardPost> {
Row( Row(
children: <Widget>[ children: <Widget>[
GestureDetector( GestureDetector(
onTap: () { onTap: () => context.push('/' + _entry['authorUser']['username']),
Navigator.push(context, MaterialPageRoute(
builder: (context) => UserScreen(_entry['authorUser']),
));
},
child: utils.avatarImage(utils.avatarUrl(_entry['authorUser']), size: isReply ? 30 : 40) child: utils.avatarImage(utils.avatarUrl(_entry['authorUser']), size: isReply ? 30 : 40)
), ),
SizedBox(width: 5), SizedBox(width: 5),
@ -229,3 +228,172 @@ class NoticeboardInput extends StatelessWidget {
} }
} }
class UserChip extends StatelessWidget {
final Map<String,dynamic> user;
final Util utils = new Util();
UserChip(this.user) {}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => context.push('/' + user['username']),
child: Row(
children: [
utils.avatarImage(utils.avatarUrl(user), size: 20),
SizedBox(width: 5),
Text(user['username'], style: TextStyle(color: Colors.grey))
]
)
);
}
}
class PatternCard extends StatelessWidget {
final Map<String,dynamic> object;
PatternCard(this.object) {}
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
clipBehavior: Clip.hardEdge,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6.0),
),
child: InkWell(
onTap: () {
context.push('/' + object['userObject']['username'] + '/' + object['projectObject']['path'] + '/' + object['_id']);
},
child: Column(
children: [
Container(
height: 100,
decoration: BoxDecoration(
image: DecorationImage(
fit: BoxFit.cover,
image: NetworkImage(object['previewUrl']),
),
),
),
Container(
padding: EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UserChip(object['userObject']),
SizedBox(height: 5),
Text(Util.ellipsis(object['name'], 35), style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
]
)
)
]
)
)
);
}
}
class ProjectCard extends StatelessWidget {
final Map<String,dynamic> project;
ProjectCard(this.project) {}
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
clipBehavior: Clip.hardEdge,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6.0),
),
child: InkWell(
onTap: () {
context.push('/' + this.project['owner']['username'] + '/' + this.project['path']);
},
child: Column(
children: [
Container(
padding: EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.folder),
SizedBox(height: 10),
UserChip(project['owner']),
SizedBox(height: 5),
Text(Util.ellipsis(project['name'], 35), style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
]
)
)
]
)
)
);
}
}
class CustomText extends StatelessWidget {
final String text;
final String type;
final double margin;
TextStyle? style;
CustomText(this.text, this.type, {this.margin = 0}) {
if (this.type == 'h1') {
style = TextStyle(fontSize: 30, fontWeight: FontWeight.bold);
}
else {
style = TextStyle();
}
}
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.all(this.margin),
child: Text(text, style: style)
);
}
}
class LoginNeeded extends StatelessWidget {
final String? text;
LoginNeeded({this.text}) {}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('You need to login to see this', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/login.png'), width: 300),
text != null ? Text(text!, textAlign: TextAlign.center) : SizedBox(height: 10),
CupertinoButton(
onPressed: () {
context.push('/welcome');
},
child: new Text("Login or register",
textAlign: TextAlign.center,
)
)
]
);
}
}
class EmptyBox extends StatelessWidget {
final String title;
final String? description;
EmptyBox(this.title, {this.description}) {}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(title, style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/empty.png'), width: 300),
description != null ? Text('Add a pattern file, an image, or something else to this project using the + button below.', textAlign: TextAlign.center) : SizedBox(height: 0),
]);
}
}

View File

@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'api.dart'; import 'api.dart';
import 'model.dart';
class _LoginScreenState extends State<LoginScreen> { class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _emailController = TextEditingController(); final TextEditingController _emailController = TextEditingController();
@ -11,15 +13,14 @@ class _LoginScreenState extends State<LoginScreen> {
final Api api = Api(); final Api api = Api();
bool _loggingIn = false; bool _loggingIn = false;
void _submit(context) async { void _submit(BuildContext context) async {
setState(() => _loggingIn = true); setState(() => _loggingIn = true);
var data = await api.request('POST', '/accounts/login', {'email': _emailController.text, 'password': _passwordController.text}); var data = await api.request('POST', '/accounts/login', {'email': _emailController.text, 'password': _passwordController.text});
setState(() => _loggingIn = false); setState(() => _loggingIn = false);
if (data['success'] == true) { if (data['success'] == true) {
String token = data['payload']['token']; AppModel model = Provider.of<AppModel>(context, listen: false);
SharedPreferences prefs = await SharedPreferences.getInstance(); await model.setToken(data['payload']['token']);
prefs.setString('apiToken', token); context.go('/onboarding');
Navigator.of(context).pushNamedAndRemoveUntil('/onboarding', (Route<dynamic> route) => false);
} }
else { else {
showDialog( showDialog(
@ -31,7 +32,7 @@ class _LoginScreenState extends State<LoginScreen> {
CupertinoDialogAction( CupertinoDialogAction(
isDefaultAction: true, isDefaultAction: true,
child: Text('Try again'), child: Text('Try again'),
onPressed: () => Navigator.pop(context), onPressed: () => context.pop(),
), ),
], ],
) )
@ -46,19 +47,17 @@ class _LoginScreenState extends State<LoginScreen> {
title: Text('Login to Treadl'), title: Text('Login to Treadl'),
), ),
body: Container( body: Container(
margin: const EdgeInsets.all(10.0), margin: const EdgeInsets.only(top: 40, left: 10, right: 10),
child: Column( child: ListView(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[ children: <Widget>[
Image(image: AssetImage('assets/logo.png'), width: 100), Text('Login with your Treadl account', style: TextStyle(fontSize: 20)),
SizedBox(height: 20), SizedBox(height: 30),
Text('Login using your Treadl account.', style: TextStyle(fontSize: 18)),
SizedBox(height: 20),
TextField( TextField(
autofocus: true, autofocus: true,
controller: _emailController, controller: _emailController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'sam@example.com', labelText: 'Email address or username' hintText: 'sam@example.com', labelText: 'Email address or username',
border: OutlineInputBorder(),
), ),
), ),
SizedBox(height: 10), SizedBox(height: 10),
@ -67,7 +66,8 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _passwordController, controller: _passwordController,
obscureText: true, obscureText: true,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Type your password', labelText: 'Your password' hintText: 'Type your password', labelText: 'Your password',
border: OutlineInputBorder(),
), ),
), ),
SizedBox(height: 5), SizedBox(height: 5),

View File

@ -3,19 +3,54 @@ import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:go_router/go_router.dart';
//import 'package:fluttertoast/fluttertoast.dart'; //import 'package:fluttertoast/fluttertoast.dart';
import 'api.dart'; import 'api.dart';
import 'store.dart'; import 'model.dart';
import 'welcome.dart'; import 'welcome.dart';
import 'login.dart'; import 'login.dart';
import 'register.dart'; import 'register.dart';
import 'onboarding.dart'; import 'onboarding.dart';
import 'home.dart'; import 'home.dart';
import 'project.dart';
import 'object.dart';
import 'settings.dart';
import 'group.dart';
import 'user.dart';
final router = GoRouter(
routes: [
GoRoute(path: '/', builder: (context, state) => Startup()),
GoRoute(path: '/welcome', pageBuilder: (context, state) {
return CustomTransitionPage(
key: state.pageKey,
child: WelcomeScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// Change the opacity of the screen using a Curve based on the the animation's value
return FadeTransition(
opacity:
CurveTween(curve: Curves.easeInOutCirc).animate(animation),
child: child,
);
},
);
}),
GoRoute(path: '/login', builder: (context, state) => LoginScreen()),
GoRoute(path: '/register', builder: (context, state) => RegisterScreen()),
GoRoute(path: '/onboarding', builder: (context, state) => OnboardingScreen()),
GoRoute(path: '/home', builder: (context, state) => HomeScreen()),
GoRoute(path: '/settings', builder: (context, state) => SettingsScreen()),
GoRoute(path: '/groups/:id', builder: (context, state) => GroupScreen(state.pathParameters['id']!)),
GoRoute(path: '/:username', builder: (context, state) => UserScreen(state.pathParameters['username']!)),
GoRoute(path: '/:username/:path', builder: (context, state) => ProjectScreen(state.pathParameters['username']!, state.pathParameters['path']!)),
GoRoute(path: '/:username/:path/:id', builder: (context, state) => ObjectScreen(state.pathParameters['username']!, state.pathParameters['path']!, state.pathParameters['id']!)),
],
);
void main() { void main() {
runApp( runApp(
ChangeNotifierProvider( ChangeNotifierProvider(
create: (context) => Store(), create: (context) => AppModel(),
child: MyApp() child: MyApp()
) )
); );
@ -37,21 +72,19 @@ class _AppState extends State<MyApp> {
// Initialize FlutterFire: // Initialize FlutterFire:
future: _initialization, future: _initialization,
builder: (context, snapshot) { builder: (context, snapshot) {
return MaterialApp( return MaterialApp.router(
routerConfig: router,
/*return MaterialApp(*/
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
title: 'Treadl', title: 'Treadl',
theme: ThemeData( theme: ThemeData(
primarySwatch: Colors.pink, primarySwatch: Colors.pink,
//textSelectionColor: Colors.blue, //textSelectionColor: Colors.blue,
), ),
home: Startup(), /*home: Startup(),
routes: <String, WidgetBuilder>{ routes: <String, WidgetBuilder>{
'/welcome': (BuildContext context) => WelcomeScreen(),
'/login': (BuildContext context) => LoginScreen(), }*/
'/register': (BuildContext context) => RegisterScreen(),
'/onboarding': (BuildContext context) => OnboardingScreen(),
'/home': (BuildContext context) => HomeScreen(),
}
); );
}, },
); );
@ -88,8 +121,8 @@ class Startup extends StatelessWidget {
SharedPreferences prefs = await SharedPreferences.getInstance(); SharedPreferences prefs = await SharedPreferences.getInstance();
String? token = prefs.getString('apiToken'); String? token = prefs.getString('apiToken');
if (token != null) { if (token != null) {
Provider.of<Store>(context, listen: false).setToken(token!); AppModel model = Provider.of<AppModel>(context, listen: false);
await model.setToken(token!);
FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance; FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
await _firebaseMessaging.requestPermission( await _firebaseMessaging.requestPermission(
alert: true, alert: true,
@ -106,11 +139,8 @@ class Startup extends StatelessWidget {
Api api = Api(); Api api = Api();
api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken!}); api.request('PUT', '/accounts/pushToken', {'pushToken': _pushToken!});
} }
// Push without including current route in stack:
Navigator.of(context, rootNavigator: true).pushNamedAndRemoveUntil('/home', (Route<dynamic> route) => false);
} else {
Navigator.of(context).pushNamedAndRemoveUntil('/welcome', (Route<dynamic> route) => false);
} }
context.go('/home');
} }
@override @override

65
mobile/lib/model.dart Normal file
View File

@ -0,0 +1,65 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api.dart';
class User {
final String id;
final String username;
String? avatar;
String? avatarUrl;
User(this.id, this.username, {this.avatar, this.avatarUrl}) {}
static User loadJSON(Map<String,dynamic> input) {
return User(input['_id'], input['username'], avatar: input['avatar'], avatarUrl: input['avatarUrl']);
}
}
class AppModel extends ChangeNotifier {
User? user;
void setUser(User? u) {
user = u;
notifyListeners();
}
String? apiToken;
Future<void> setToken(String? newToken) async {
apiToken = newToken;
SharedPreferences prefs = await SharedPreferences.getInstance();
if (apiToken != null) {
Api api = Api(token: apiToken!);
prefs.setString('apiToken', apiToken!);
var data = await api.request('GET', '/users/me');
if (data['success'] == true) {
setUser(User.loadJSON(data['payload']));
print(data);
}
} else {
prefs.remove('apiToken');
}
}
/*
/// Internal, private state of the cart.
final List<Item> _items = [];
/// An unmodifiable view of the items in the cart.
UnmodifiableListView<Item> get items => UnmodifiableListView(_items);
/// The current total price of all items (assuming all items cost $42).
int get totalPrice => _items.length * 42;
/// Adds [item] to cart. This and [removeAll] are the only ways to modify the
/// cart from the outside.
void add(Item item) {
_items.add(item);
// This call tells the widgets that are listening to this model to rebuild.
notifyListeners();
}
/// Removes all items from the cart.
void removeAll() {
_items.clear();
// This call tells the widgets that are listening to this model to rebuild.
notifyListeners();
}*/
}

View File

@ -3,37 +3,38 @@ import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/flutter_html.dart';
import 'package:go_router/go_router.dart';
import 'dart:io'; import 'dart:io';
import 'api.dart'; import 'api.dart';
import 'util.dart'; import 'util.dart';
import 'model.dart';
import 'patterns/pattern.dart'; import 'patterns/pattern.dart';
import 'patterns/viewer.dart'; import 'patterns/viewer.dart';
class _ObjectScreenState extends State<ObjectScreen> { class _ObjectScreenState extends State<ObjectScreen> {
final Map<String,dynamic> _project; final String username;
Map<String,dynamic> _object; final String projectPath;
Map<String,dynamic>? _pattern; final String id;
Map<String,dynamic>? object;
Map<String,dynamic>? pattern;
bool _isLoading = false; bool _isLoading = false;
final Function _onUpdate;
final Function _onDelete;
final Api api = Api(); final Api api = Api();
final Util util = Util(); final Util util = Util();
_ObjectScreenState(this._object, this._project, this._onUpdate, this._onDelete) { } _ObjectScreenState(this.username, this.projectPath, this.id) { }
@override @override
initState() { initState() {
super.initState(); super.initState();
if (_object['type'] == 'pattern') { fetchObject();
_fetchPattern();
}
} }
void _fetchPattern() async { void fetchObject() async {
var data = await api.request('GET', '/objects/' + _object['_id']); var data = await api.request('GET', '/objects/' + id);
if (data['success'] == true) { if (data['success'] == true) {
setState(() { setState(() {
_pattern = data['payload']['pattern']; object = data['payload'];
pattern = data['payload']['pattern'];
}); });
} }
} }
@ -41,14 +42,14 @@ class _ObjectScreenState extends State<ObjectScreen> {
void _shareObject() async { void _shareObject() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
File? file; File? file;
if (_object['type'] == 'pattern') { if (object!['type'] == 'pattern') {
var data = await api.request('GET', '/objects/' + _object['_id'] + '/wif'); var data = await api.request('GET', '/objects/' + id + '/wif');
if (data['success'] == true) { if (data['success'] == true) {
file = await util.writeFile(_object['name'] + '.wif', data['payload']['wif']); file = await util.writeFile(object!['name'] + '.wif', data['payload']['wif']);
} }
} else { } else {
String fileName = Uri.file(_object['url']).pathSegments.last; String fileName = Uri.file(object!['url']).pathSegments.last;
file = await api.downloadFile(_object['url'], fileName); file = await api.downloadFile(object!['url'], fileName);
} }
if (file != null) { if (file != null) {
@ -58,12 +59,9 @@ class _ObjectScreenState extends State<ObjectScreen> {
} }
void _deleteObject(BuildContext context, BuildContext modalContext) async { void _deleteObject(BuildContext context, BuildContext modalContext) async {
var data = await api.request('DELETE', '/objects/' + _object['_id']); var data = await api.request('DELETE', '/objects/' + id);
if (data['success']) { if (data['success']) {
Navigator.pop(context); context.go('/home');
Navigator.pop(modalContext);
Navigator.pop(context);
_onDelete(_object['_id']);
} }
} }
@ -77,7 +75,7 @@ class _ObjectScreenState extends State<ObjectScreen> {
CupertinoDialogAction( CupertinoDialogAction(
isDefaultAction: true, isDefaultAction: true,
child: Text('No'), child: Text('No'),
onPressed: () => Navigator.pop(context), onPressed: () => context.pop(),
), ),
CupertinoDialogAction( CupertinoDialogAction(
isDestructiveAction: true, isDestructiveAction: true,
@ -105,22 +103,21 @@ class _ObjectScreenState extends State<ObjectScreen> {
TextButton( TextButton(
child: Text('CANCEL'), child: Text('CANCEL'),
onPressed: () { onPressed: () {
Navigator.pop(context); context.pop();
}, },
), ),
TextButton( TextButton(
child: Text('OK'), child: Text('OK'),
onPressed: () async { onPressed: () async {
var data = await api.request('PUT', '/objects/' + _object['_id'], {'name': renameController.text}); var data = await api.request('PUT', '/objects/' + id, {'name': renameController.text});
if (data['success']) { if (data['success']) {
Navigator.pop(context); context.pop();
_object['name'] = data['payload']['name']; object!['name'] = data['payload']['name'];
_onUpdate(_object['_id'], data['payload']);
setState(() { setState(() {
_object = _object; object = object;
}); });
} }
Navigator.pop(context); context.pop();
}, },
), ),
], ],
@ -136,7 +133,7 @@ class _ObjectScreenState extends State<ObjectScreen> {
return CupertinoActionSheet( return CupertinoActionSheet(
title: Text('Manage this object'), title: Text('Manage this object'),
cancelButton: CupertinoActionSheetAction( cancelButton: CupertinoActionSheetAction(
onPressed: () => Navigator.of(modalContext).pop(), onPressed: () => modalContext.pop(),
child: Text('Cancel') child: Text('Cancel')
), ),
actions: [ actions: [
@ -156,15 +153,22 @@ class _ObjectScreenState extends State<ObjectScreen> {
} }
Widget getObjectWidget() { Widget getObjectWidget() {
if (_object['isImage'] == true) { if (object == null) {
return Image.network(_object['url']); return Center(child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [CircularProgressIndicator()]
));
} }
else if (_object['type'] == 'pattern') { else if (object!['isImage'] == true && object!['url'] != null) {
if (_pattern != null) { print(object!['url']);
return PatternViewer(_pattern!, withEditor: true); return Image.network(object!['url']);
}
else if (object!['type'] == 'pattern') {
if (pattern != null) {
return PatternViewer(pattern!, withEditor: true);
} }
else if (_object['previewUrl'] != null) { else if (object!['previewUrl'] != null) {
return Image.network(_object['previewUrl']!);; return Image.network(object!['previewUrl']!);;
} }
else { else {
return Column( return Column(
@ -184,7 +188,7 @@ class _ObjectScreenState extends State<ObjectScreen> {
Text('Treadl cannot display this type of item.'), Text('Treadl cannot display this type of item.'),
SizedBox(height: 20), SizedBox(height: 20),
ElevatedButton(child: Text('View file'), onPressed: () { ElevatedButton(child: Text('View file'), onPressed: () {
launch(_object['url']); launch(object!['url']);
}), }),
], ],
)); ));
@ -193,12 +197,14 @@ class _ObjectScreenState extends State<ObjectScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
AppModel model = Provider.of<AppModel>(context);
User? user = model.user;
String description = ''; String description = '';
if (_object['description'] != null) if (object?['description'] != null)
description = _object['description']; description = object!['description']!;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(_object['name']), title: Text(object?['name'] ?? 'Object'),
actions: <Widget>[ actions: <Widget>[
IconButton( IconButton(
icon: Icon(Icons.ios_share), icon: Icon(Icons.ios_share),
@ -206,12 +212,12 @@ class _ObjectScreenState extends State<ObjectScreen> {
_shareObject(); _shareObject();
}, },
), ),
IconButton( Util.canEditProject(user, object?['projectObject']) ? IconButton(
icon: Icon(Icons.settings), icon: Icon(Icons.settings),
onPressed: () { onPressed: () {
_showSettingsModal(context); _showSettingsModal(context);
}, },
), ) : SizedBox(height: 0),
] ]
), ),
body: Container( body: Container(
@ -228,12 +234,11 @@ class _ObjectScreenState extends State<ObjectScreen> {
} }
class ObjectScreen extends StatefulWidget { class ObjectScreen extends StatefulWidget {
final Map<String,dynamic> _object; final String username;
final Map<String,dynamic> _project; final String projectPath;
final Function _onUpdate; final String id;
final Function _onDelete; ObjectScreen(this.username, this.projectPath, this.id, ) { }
ObjectScreen(this._object, this._project, this._onUpdate, this._onDelete) { }
@override @override
_ObjectScreenState createState() => _ObjectScreenState(_object, _project, _onUpdate, _onDelete); _ObjectScreenState createState() => _ObjectScreenState(username, projectPath, id);
} }

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:go_router/go_router.dart';
import 'api.dart'; import 'api.dart';
class _OnboardingScreenState extends State<OnboardingScreen> { class _OnboardingScreenState extends State<OnboardingScreen> {
@ -111,7 +112,7 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
CupertinoButton( CupertinoButton(
color: Colors.white, color: Colors.white,
child: Text('Get started', style: TextStyle(color: Colors.pink)), child: Text('Get started', style: TextStyle(color: Colors.pink)),
onPressed: () => Navigator.of(context).pushNamedAndRemoveUntil('/home', (Route<dynamic> route) => false), onPressed: () => context.go('/home'),
), ),
] ]
) )

View File

@ -4,29 +4,47 @@ import 'package:provider/provider.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart'; import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'dart:io'; import 'dart:io';
import 'api.dart'; import 'api.dart';
import 'util.dart'; import 'util.dart';
import 'object.dart'; import 'model.dart';
import 'lib.dart';
class _ProjectScreenState extends State<ProjectScreen> { class _ProjectScreenState extends State<ProjectScreen> {
final Function _onUpdate; final String username;
final Function _onDelete; final String projectPath;
final String fullPath;
final Function? onUpdate;
final Function? onDelete;
final picker = ImagePicker(); final picker = ImagePicker();
final Api api = Api(); final Api api = Api();
final Util util = Util(); final Util util = Util();
Map<String,dynamic> _project; Map<String,dynamic>? project;
List<dynamic> _objects = []; List<dynamic> _objects = [];
bool _loading = false; bool _loading = false;
bool _creating = false; bool _creating = false;
_ProjectScreenState(this._project, this._onUpdate, this._onDelete) { } _ProjectScreenState(this.username, this.projectPath, {this.project, this.onUpdate, this.onDelete}) :
fullPath = username + '/' + projectPath;
@override @override
initState() { initState() {
super.initState(); super.initState();
getObjects(_project['fullName']); getProject(fullPath);
getObjects(fullPath);
}
void getProject(String fullName) async {
setState(() => _loading = true);
var data = await api.request('GET', '/projects/' + fullName);
if (data['success'] == true) {
setState(() {
project = data['payload'];
_loading = false;
});
}
} }
void getObjects(String fullName) async { void getObjects(String fullName) async {
@ -41,18 +59,18 @@ class _ProjectScreenState extends State<ProjectScreen> {
} }
void _shareProject() { void _shareProject() {
util.shareUrl('Check out my project on Treadl', util.appUrl(_project['fullName'])); util.shareUrl('Check out my project on Treadl', util.appUrl(fullPath));
} }
void _onDeleteProject() { void _onDeleteProject() {
Navigator.pop(context); context.pop();
_onDelete(_project['_id']); onDelete!(project!['_id']);
} }
void _onUpdateProject(project) { void _onUpdateProject(project) {
setState(() { setState(() {
_project = project; project = project;
}); });
_onUpdate(project['_id'], project); onUpdate!(project!['_id'], project!);
} }
void _onUpdateObject(String id, Map<String,dynamic> update) { void _onUpdateObject(String id, Map<String,dynamic> update) {
@ -74,7 +92,6 @@ class _ProjectScreenState extends State<ProjectScreen> {
} }
void _createObject(objectData) async { void _createObject(objectData) async {
String fullPath = _project['fullName'];
var resp = await api.request('POST', '/projects/$fullPath/objects', objectData); var resp = await api.request('POST', '/projects/$fullPath/objects', objectData);
setState(() => _creating = false); setState(() => _creating = false);
if (resp['success']) { if (resp['success']) {
@ -97,7 +114,7 @@ class _ProjectScreenState extends State<ProjectScreen> {
void _createObjectFromFile(String name, XFile file) async { void _createObjectFromFile(String name, XFile file) async {
final int size = await file.length(); final int size = await file.length();
final String forId = _project['_id']; final String forId = project!['_id'];
final String type = file.mimeType ?? 'text/plain'; final String type = file.mimeType ?? 'text/plain';
setState(() => _creating = true); setState(() => _creating = true);
var data = await api.request('GET', '/uploads/file/request?name=$name&size=$size&type=$type&forType=project&forId=$forId'); var data = await api.request('GET', '/uploads/file/request?name=$name&size=$size&type=$type&forType=project&forId=$forId');
@ -139,7 +156,7 @@ class _ProjectScreenState extends State<ProjectScreen> {
if (imageFile == null) return; if (imageFile == null) return;
final f = new DateFormat('yyyy-MM-dd_hh-mm-ss'); final f = new DateFormat('yyyy-MM-dd_hh-mm-ss');
String time = f.format(new DateTime.now()); String time = f.format(new DateTime.now());
String name = _project['name'] + ' ' + time + '.' + imageFile.name.split('.').last; String name = project!['name'] + ' ' + time + '.' + imageFile.name.split('.').last;
_createObjectFromFile(name, imageFile); _createObjectFromFile(name, imageFile);
} }
on Exception { on Exception {
@ -152,7 +169,7 @@ class _ProjectScreenState extends State<ProjectScreen> {
CupertinoDialogAction( CupertinoDialogAction(
isDefaultAction: true, isDefaultAction: true,
child: Text('OK'), child: Text('OK'),
onPressed: () => Navigator.pop(context), onPressed: () => context.pop(),
), ),
], ],
) )
@ -161,7 +178,7 @@ class _ProjectScreenState extends State<ProjectScreen> {
} }
void showSettingsModal() { void showSettingsModal() {
Widget settingsDialog = new _ProjectSettingsDialog(_project, _onDeleteProject, _onUpdateProject); Widget settingsDialog = new _ProjectSettingsDialog(project!, _onDeleteProject, _onUpdateProject);
showCupertinoModalPopup(context: context, builder: (BuildContext context) => settingsDialog); showCupertinoModalPopup(context: context, builder: (BuildContext context) => settingsDialog);
} }
@ -236,12 +253,7 @@ class _ProjectScreenState extends State<ProjectScreen> {
return new Card( return new Card(
child: InkWell( child: InkWell(
onTap: () { onTap: () {
Navigator.push( context.push('/' + username + '/' + projectPath + '/' + object['_id']);
context,
MaterialPageRoute(
builder: (context) => ObjectScreen(object, _project, _onUpdateObject, _onDeleteObject),
),
);
}, },
child: ListTile( child: ListTile(
leading: leader, leading: leader,
@ -253,11 +265,27 @@ class _ProjectScreenState extends State<ProjectScreen> {
); );
} }
Widget getBody() {
if (_loading || project == null)
return CircularProgressIndicator();
else if ((_objects != null && _objects.length > 0) || _creating)
return ListView.builder(
itemCount: _objects.length + (_creating ? 1 : 0),
itemBuilder: (BuildContext context, int index) {
return getObjectCard(index);
},
);
else
return EmptyBox('This project is currently empty', description: 'If this is your project, you can add a pattern file, an image, or something else to this project using the + button below.');
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
AppModel model = Provider.of<AppModel>(context);
User? user = model.user;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(_project['name']), title: Text(project?['name'] ?? 'Project'),
actions: <Widget>[ actions: <Widget>[
IconButton( IconButton(
icon: Icon(Icons.ios_share), icon: Icon(Icons.ios_share),
@ -265,41 +293,21 @@ class _ProjectScreenState extends State<ProjectScreen> {
_shareProject(); _shareProject();
}, },
), ),
IconButton( onUpdate != null ? IconButton(
icon: Icon(Icons.settings), icon: Icon(Icons.settings),
onPressed: () { onPressed: () {
showSettingsModal(); showSettingsModal();
}, },
), ) : SizedBox(width: 0),
] ]
), ),
body: _loading ? body: Container(
Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: CircularProgressIndicator()
)
: Container(
margin: const EdgeInsets.all(10.0), margin: const EdgeInsets.all(10.0),
child: ((_objects != null && _objects.length > 0) || _creating) ? alignment: Alignment.center,
ListView.builder( child: getBody(),
itemCount: _objects.length + (_creating ? 1 : 0),
itemBuilder: (BuildContext context, int index) {
return getObjectCard(index);
},
)
:
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('This project is currently empty', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/empty.png'), width: 300),
Text('Add a pattern file, an image, or something else to this project using the + button below.', textAlign: TextAlign.center),
])
), ),
floatingActionButtonLocation: ExpandableFab.location, floatingActionButtonLocation: ExpandableFab.location,
floatingActionButton: ExpandableFab( floatingActionButton: Util.canEditProject(user, project) ? ExpandableFab(
distance: 70, distance: 70,
type: ExpandableFabType.up, type: ExpandableFabType.up,
openButtonBuilder: RotateFloatingActionButtonBuilder( openButtonBuilder: RotateFloatingActionButtonBuilder(
@ -339,26 +347,30 @@ class _ProjectScreenState extends State<ProjectScreen> {
), ),
]), ]),
], ],
), ) : null,
); );
} }
} }
class ProjectScreen extends StatefulWidget { class ProjectScreen extends StatefulWidget {
final Map<String,dynamic> _project; final String username;
final Function _onUpdate; final String projectPath;
final Function _onDelete; final Map<String,dynamic>? project;
ProjectScreen(this._project, this._onUpdate, this._onDelete) { } final Function? onUpdate;
final Function? onDelete;
ProjectScreen(this.username, this.projectPath, {this.project, this.onUpdate, this.onDelete}) { }
@override @override
_ProjectScreenState createState() => _ProjectScreenState(_project, _onUpdate, _onDelete); _ProjectScreenState createState() => _ProjectScreenState(username, projectPath, project: project, onUpdate: onUpdate, onDelete: onDelete);
} }
class _ProjectSettingsDialog extends StatelessWidget { class _ProjectSettingsDialog extends StatelessWidget {
final Map<String,dynamic> _project; final String fullPath;
final Map<String,dynamic> project;
final Function _onDelete; final Function _onDelete;
final Function _onUpdateProject; final Function _onUpdateProject;
final Api api = Api(); final Api api = Api();
_ProjectSettingsDialog(this._project, this._onDelete, this._onUpdateProject) {} _ProjectSettingsDialog(this.project, this._onDelete, this._onUpdateProject) :
fullPath = project['owner']['username'] + '/' + project['path'];
void _renameProject(BuildContext context) async { void _renameProject(BuildContext context) async {
TextEditingController renameController = TextEditingController(); TextEditingController renameController = TextEditingController();
@ -376,18 +388,18 @@ class _ProjectSettingsDialog extends StatelessWidget {
TextButton( TextButton(
child: Text('CANCEL'), child: Text('CANCEL'),
onPressed: () { onPressed: () {
Navigator.pop(context); context.pop();
}, },
), ),
TextButton( TextButton(
child: Text('OK'), child: Text('OK'),
onPressed: () async { onPressed: () async {
var data = await api.request('PUT', '/projects/' + _project['owner']['username'] + '/' + _project['path'], {'name': renameController.text}); var data = await api.request('PUT', '/projects/' + fullPath, {'name': renameController.text});
if (data['success']) { if (data['success']) {
Navigator.pop(context); context.pop();
_onUpdateProject(data['payload']); _onUpdateProject(data['payload']);
} }
Navigator.pop(context); context.pop();
}, },
), ),
], ],
@ -397,18 +409,18 @@ class _ProjectSettingsDialog extends StatelessWidget {
} }
void _toggleVisibility(BuildContext context, bool checked) async { void _toggleVisibility(BuildContext context, bool checked) async {
var data = await api.request('PUT', '/projects/' + _project['owner']['username'] + '/' + _project['path'], {'visibility': checked ? 'private': 'public'}); var data = await api.request('PUT', '/projects/' + fullPath, {'visibility': checked ? 'private': 'public'});
if (data['success']) { if (data['success']) {
Navigator.pop(context); context.pop();
_onUpdateProject(data['payload']); _onUpdateProject(data['payload']);
} }
} }
void _deleteProject(BuildContext context, BuildContext modalContext) async { void _deleteProject(BuildContext context, BuildContext modalContext) async {
var data = await api.request('DELETE', '/projects/' + _project['owner']['username'] + '/' + _project['path']); var data = await api.request('DELETE', '/projects/' + fullPath);
if (data['success']) { if (data['success']) {
Navigator.pop(context); context.pop();
Navigator.pop(modalContext); context.pop();
_onDelete(); _onDelete();
} }
} }
@ -423,7 +435,7 @@ class _ProjectSettingsDialog extends StatelessWidget {
CupertinoDialogAction( CupertinoDialogAction(
isDefaultAction: true, isDefaultAction: true,
child: Text('No'), child: Text('No'),
onPressed: () => Navigator.pop(context), onPressed: () => context.pop(),
), ),
CupertinoDialogAction( CupertinoDialogAction(
isDestructiveAction: true, isDestructiveAction: true,
@ -440,7 +452,7 @@ class _ProjectSettingsDialog extends StatelessWidget {
return CupertinoActionSheet( return CupertinoActionSheet(
title: Text('Manage this project'), title: Text('Manage this project'),
cancelButton: CupertinoActionSheetAction( cancelButton: CupertinoActionSheetAction(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text('Cancel') child: Text('Cancel')
), ),
actions: [ actions: [
@ -450,7 +462,7 @@ class _ProjectSettingsDialog extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
CupertinoSwitch( CupertinoSwitch(
value: _project['visibility'] == 'private', value: project?['visibility'] == 'private',
onChanged: (c) => _toggleVisibility(context, c), onChanged: (c) => _toggleVisibility(context, c),
), ),
SizedBox(width: 10), SizedBox(width: 10),

View File

@ -1,10 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'routeArguments.dart'; import 'package:go_router/go_router.dart';
import 'api.dart'; import 'api.dart';
import 'project.dart'; import 'model.dart';
import 'settings.dart'; import 'lib.dart';
class _ProjectsTabState extends State<ProjectsTab> { class _ProjectsTabState extends State<ProjectsTab> {
List<dynamic> _projects = []; List<dynamic> _projects = [];
@ -19,6 +19,8 @@ class _ProjectsTabState extends State<ProjectsTab> {
} }
void getProjects() async { void getProjects() async {
AppModel model = Provider.of<AppModel>(context, listen: false);
if (model.user == null) return;
setState(() { setState(() {
_loading = true; _loading = true;
}); });
@ -81,12 +83,7 @@ class _ProjectsTabState extends State<ProjectsTab> {
return new Card( return new Card(
child: InkWell( child: InkWell(
onTap: () { onTap: () {
Navigator.push( context.push('/' + project['owner']['username'] + '/' + project['path']);
context,
MaterialPageRoute(
builder: (context) => ProjectScreen(project, _onUpdateProject, _onDeleteProject),
),
);
}, },
child: Container( child: Container(
padding: EdgeInsets.all(5), padding: EdgeInsets.all(5),
@ -110,56 +107,56 @@ class _ProjectsTabState extends State<ProjectsTab> {
; ;
} }
Widget getBody() {
AppModel model = Provider.of<AppModel>(context);
if (model.user == null)
return LoginNeeded(text: 'Once logged in, you\'ll find your own projects shown here.');
if (_loading)
return CircularProgressIndicator();
else if (_projects != null && _projects.length > 0)
return ListView.builder(
itemCount: _projects.length,
itemBuilder: (BuildContext context, int index) {
return buildProjectCard(_projects[index]);
},
);
else return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Create your first project', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/reading.png'), width: 300),
Text('Projects contain all the files and patterns that make up a piece of work. Create one using the + button below.', textAlign: TextAlign.center),
]
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
AppModel model = Provider.of<AppModel>(context);
User? user = model.user;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('Your Projects'), title: Text('My Projects'),
actions: <Widget>[ actions: <Widget>[
IconButton( IconButton(
icon: Icon(Icons.info_outline), icon: Icon(Icons.info_outline),
onPressed: () { onPressed: () {
Navigator.push( context.push('/settings');
context,
MaterialPageRoute(
builder: (context) => SettingsScreen(),
),
);
}, },
), ),
] ]
), ),
body: _loading ? body: Container(
Container(
margin: const EdgeInsets.all(10.0),
alignment: Alignment.center,
child: CircularProgressIndicator()
)
: Container(
margin: const EdgeInsets.all(10.0), margin: const EdgeInsets.all(10.0),
alignment: Alignment.center, alignment: Alignment.center,
child: (_projects != null && _projects.length > 0) ? child: getBody()
ListView.builder(
itemCount: _projects.length,
itemBuilder: (BuildContext context, int index) {
return buildProjectCard(_projects[index]);
},
)
:
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Create your first project', style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
Image(image: AssetImage('assets/reading.png'), width: 300),
Text('Projects contain all the files and patterns that make up a piece of work. Create one using the + button below.', textAlign: TextAlign.center),
])
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: user != null ? FloatingActionButton(
onPressed: showNewProjectDialog, onPressed: showNewProjectDialog,
child: _creatingProject ? CircularProgressIndicator(backgroundColor: Colors.white) : Icon(Icons.add), child: _creatingProject ? CircularProgressIndicator(backgroundColor: Colors.white) : Icon(Icons.add),
backgroundColor: Colors.pink[500], backgroundColor: Colors.pink[500],
), ) : null,
); );
} }
} }
@ -192,7 +189,7 @@ class _NewProjectDialogState extends State<_NewProjectDialog> {
var data = await api.request('POST', '/projects', {'name': name, 'visibility': priv ? 'private' : 'public'}); var data = await api.request('POST', '/projects', {'name': name, 'visibility': priv ? 'private' : 'public'});
if (data['success'] == true) { if (data['success'] == true) {
_onComplete(data['payload']); _onComplete(data['payload']);
Navigator.of(context).pop(); context.pop();
} }
} }
@ -228,7 +225,7 @@ class _NewProjectDialogState extends State<_NewProjectDialog> {
SizedBox(height: 10), SizedBox(height: 10),
CupertinoButton( CupertinoButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(); context.pop();
}, },
child: Text('Cancel'), child: Text('Cancel'),
) )

View File

@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'api.dart'; import 'api.dart';
import 'model.dart';
class _RegisterScreenState extends State<RegisterScreen> { class _RegisterScreenState extends State<RegisterScreen> {
final TextEditingController _usernameController = TextEditingController(); final TextEditingController _usernameController = TextEditingController();
@ -17,10 +19,9 @@ class _RegisterScreenState extends State<RegisterScreen> {
var data = await api.request('POST', '/accounts/register', {'username': _usernameController.text, 'email': _emailController.text, 'password': _passwordController.text}); var data = await api.request('POST', '/accounts/register', {'username': _usernameController.text, 'email': _emailController.text, 'password': _passwordController.text});
setState(() => _registering = false); setState(() => _registering = false);
if (data['success'] == true) { if (data['success'] == true) {
String token = data['payload']['token']; AppModel model = Provider.of<AppModel>(context, listen: false);
SharedPreferences prefs = await SharedPreferences.getInstance(); model.setToken(data['payload']['token']);
prefs.setString('apiToken', token); context.go('/onboarding');
Navigator.of(context).pushNamedAndRemoveUntil('/onboarding', (Route<dynamic> route) => false);
} }
else { else {
showDialog( showDialog(
@ -32,7 +33,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
CupertinoDialogAction( CupertinoDialogAction(
isDefaultAction: true, isDefaultAction: true,
child: Text('Try again'), child: Text('Try again'),
onPressed: () => Navigator.pop(context), onPressed: () => context.pop(),
), ),
], ],
) )
@ -47,66 +48,59 @@ class _RegisterScreenState extends State<RegisterScreen> {
title: Text('Register with Treadl'), title: Text('Register with Treadl'),
), ),
body: Container( body: Container(
margin: const EdgeInsets.all(10.0), margin: const EdgeInsets.only(top: 40, left: 10, right: 10),
child: SingleChildScrollView( child: ListView(
child:Column( children: <Widget>[
mainAxisAlignment: MainAxisAlignment.center, TextField(
children: <Widget>[ autofocus: true,
Image(image: AssetImage('assets/logo.png'), width: 100), controller: _usernameController,
SizedBox(height: 20), decoration: InputDecoration(
Text('Register a free account.', style: TextStyle(fontSize: 18)), hintText: 'username', labelText: 'Choose a username',
SizedBox(height: 20), border: OutlineInputBorder(),
TextField(
autofocus: true,
controller: _usernameController,
decoration: InputDecoration(
hintText: 'username', labelText: 'Choose a username',
border: OutlineInputBorder(),
),
), ),
SizedBox(height: 10), ),
TextField( SizedBox(height: 10),
controller: _emailController, TextField(
decoration: InputDecoration( controller: _emailController,
hintText: 'sam@example.com', labelText: 'Your email address', helperText: 'For notifications & password resets - we never share this.', decoration: InputDecoration(
border: OutlineInputBorder() hintText: 'sam@example.com', labelText: 'Your email address', helperText: 'For notifications & password resets - we never share this.',
), border: OutlineInputBorder()
), ),
SizedBox(height: 10), ),
TextField( SizedBox(height: 10),
onEditingComplete: () => _submit(context), TextField(
controller: _passwordController, onEditingComplete: () => _submit(context),
obscureText: true, controller: _passwordController,
decoration: InputDecoration( obscureText: true,
hintText: 'Type your password', labelText: 'Choose a strong password', decoration: InputDecoration(
border: OutlineInputBorder() hintText: 'Type your password', labelText: 'Choose a strong password',
), border: OutlineInputBorder()
), ),
SizedBox(height: 20), ),
RichText( SizedBox(height: 20),
RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: 'By registering you agree to Treadl\'s ',
style: Theme.of(context).textTheme.bodyText1,
children: <TextSpan>[
TextSpan(text: 'Terms of Use', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.pink), recognizer: new TapGestureRecognizer()..onTap = () => launch('https://treadl.com/terms-of-use')),
TextSpan(text: ' and '),
TextSpan(text: 'Privacy Policy', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.pink), recognizer: new TapGestureRecognizer()..onTap = () => launch('https://treadl.com/privacy')),
TextSpan(text: '.'),
],
),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: () => _submit(context),
//color: Colors.pink,
child: _registering ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Register",
textAlign: TextAlign.center, textAlign: TextAlign.center,
text: TextSpan( style: TextStyle(color: Colors.white, fontSize: 15)
text: 'By registering you agree to Treadl\'s ', )
style: Theme.of(context).textTheme.bodyText1, ),
children: <TextSpan>[ ]
TextSpan(text: 'Terms of Use', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.pink), recognizer: new TapGestureRecognizer()..onTap = () => launch('https://treadl.com/terms-of-use')),
TextSpan(text: ' and '),
TextSpan(text: 'Privacy Policy', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.pink), recognizer: new TapGestureRecognizer()..onTap = () => launch('https://treadl.com/privacy')),
TextSpan(text: '.'),
],
),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: () => _submit(context),
//color: Colors.pink,
child: _registering ? SizedBox(height: 20, width: 20, child:CircularProgressIndicator(backgroundColor: Colors.white)) : Text("Register",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white, fontSize: 15)
)
),
]
)
) )
), ),
); );

View File

@ -1,18 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'api.dart'; import 'api.dart';
import 'model.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
final TextEditingController _passwordController = TextEditingController(); final TextEditingController _passwordController = TextEditingController();
void _logout(BuildContext context) async { void _logout(BuildContext context) async {
AppModel model = Provider.of<AppModel>(context, listen: false);
Api api = Api(); Api api = Api();
api.request('POST', '/accounts/logout'); api.request('POST', '/accounts/logout');
SharedPreferences prefs = await SharedPreferences.getInstance(); model.setToken(null);
prefs.remove('apiToken'); model.setUser(null);
Navigator.of(context).pushNamedAndRemoveUntil('/welcome', (Route<dynamic> route) => false); context.pop();
} }
void _deleteAccount(BuildContext context) async { void _deleteAccount(BuildContext context) async {
@ -33,7 +36,7 @@ class SettingsScreen extends StatelessWidget {
actions: [ actions: [
TextButton( TextButton(
child: Text('Cancel'), child: Text('Cancel'),
onPressed: () { Navigator.of(context).pop(); } onPressed: () => context.pop(),
), ),
ElevatedButton( ElevatedButton(
child: Text('Delete Account'), child: Text('Delete Account'),
@ -41,9 +44,10 @@ class SettingsScreen extends StatelessWidget {
Api api = Api(); Api api = Api();
var data = await api.request('DELETE', '/accounts', {'password': _passwordController.text}); var data = await api.request('DELETE', '/accounts', {'password': _passwordController.text});
if (data['success'] == true) { if (data['success'] == true) {
SharedPreferences prefs = await SharedPreferences.getInstance(); AppModel model = Provider.of<AppModel>(context, listen: false);
prefs.remove('apiToken'); model.setToken(null);
Navigator.of(context).pushNamedAndRemoveUntil('/welcome', (Route<dynamic> route) => false); model.setUser(null);
context.go('/home');
} else { } else {
showDialog( showDialog(
context: context, context: context,
@ -54,7 +58,7 @@ class SettingsScreen extends StatelessWidget {
CupertinoDialogAction( CupertinoDialogAction(
isDefaultAction: true, isDefaultAction: true,
child: Text('OK'), child: Text('OK'),
onPressed: () => Navigator.pop(context), onPressed: () => context.pop(),
), ),
], ],
) )
@ -75,6 +79,8 @@ class SettingsScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
AppModel model = Provider.of<AppModel>(context);
User? user = model.user;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('About Treadl'), title: Text('About Treadl'),
@ -93,15 +99,23 @@ class SettingsScreen extends StatelessWidget {
SizedBox(height: 30), SizedBox(height: 30),
ListTile( user != null ? Column(
leading: Icon(Icons.exit_to_app), children: [
title: Text('Logout'), ListTile(
onTap: () => _logout(context), leading: Icon(Icons.exit_to_app),
), title: Text('Logout'),
ListTile( onTap: () => _logout(context),
leading: Icon(Icons.delete), ),
title: Text('Delete Account'), ListTile(
onTap: () => _deleteAccount(context), leading: Icon(Icons.delete),
title: Text('Delete Account'),
onTap: () => _deleteAccount(context),
),
]
) : CupertinoButton(
color: Colors.pink,
child: Text('Join Treadl', style: TextStyle(color: Colors.white)),
onPressed: () => context.push('/welcome'),
), ),
SizedBox(height: 30), SizedBox(height: 30),

View File

@ -5,18 +5,22 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'util.dart'; import 'util.dart';
import 'api.dart'; import 'api.dart';
import 'lib.dart';
class _UserScreenState extends State<UserScreen> { class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateMixin {
final String username;
final Util util = new Util(); final Util util = new Util();
final Api api = Api(); final Api api = Api();
Map<String,dynamic> _user; TabController? _tabController;
Map<String,dynamic>? _user;
bool _loading = false; bool _loading = false;
_UserScreenState(this._user) { } _UserScreenState(this.username) { }
@override @override
initState() { initState() {
super.initState(); super.initState();
getUser(_user['username']); _tabController = new TabController(length: 2, vsync: this);
getUser(username);
} }
void getUser(String username) async { void getUser(String username) async {
@ -31,85 +35,136 @@ class _UserScreenState extends State<UserScreen> {
} }
} }
Widget getBody() {
if (_loading)
return CircularProgressIndicator();
else if (_user != null && _tabController != null) {
var u = _user!;
String? created;
if (u['createdAt'] != null) {
DateTime createdAt = DateTime.parse(u['createdAt']!);
created = DateFormat('MMMM y').format(createdAt);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(children: [
util.avatarImage(util.avatarUrl(u), size: 120),
Container(
padding: EdgeInsets.only(left: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(u['username'], style: Theme.of(context).textTheme.titleLarge),
SizedBox(height: 5),
u['location'] != null ?
Row(children: [
Icon(CupertinoIcons.location),
SizedBox(width: 10),
Text(u['location'])
]) : SizedBox(height: 1),
SizedBox(height: 10),
Text('Member' + (created != null ? (' since ' + created!) : ''),
style: TextStyle(color: Colors.grey[500])
),
SizedBox(height: 10),
u['website'] != null ?
GestureDetector(
onTap: () {
String url = u['website'];
if (!url.startsWith('http')) {
url = 'http://' + url;
}
launch(url);
},
child: Text(u['website'],
style: TextStyle(color: Colors.pink))
) : SizedBox(height: 1),
]
)
)
]),
SizedBox(height: 10),
TabBar(
unselectedLabelColor: Colors.black,
labelColor: Colors.pink,
tabs: [
Tab(
text: 'Profile',
icon: Icon(Icons.person),
),
Tab(
text: 'Projects',
icon: Icon(Icons.folder),
)
],
controller: _tabController!,
indicatorSize: TabBarIndicatorSize.tab,
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 30),
Text(u['bio'] != null ? u['bio'] : 'The user doesn\'t have any more profile information.'),
]
),
(u['projects'] != null && u['projects'].length > 0) ?
Container(
margin: EdgeInsets.only(top: 10),
child: GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 5,
crossAxisSpacing: 5,
childAspectRatio: 1.3,
children: u['projects'].map<Widget>((p) =>
ProjectCard(p)
).toList()
),
) :
Container(
margin: EdgeInsets.all(10),
child: EmptyBox('This user doesn\'t have any public projects'),
),
],
),
)
]);
}
else
return Text('User not found');
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
String? created;
if (_user['createdAt'] != null) {
DateTime createdAt = DateTime.parse(_user['createdAt']);
created = DateFormat('MMMM y').format(createdAt);
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(_user['username']), title: Text(username),
actions: <Widget>[ actions: <Widget>[
IconButton( IconButton(
icon: Icon(Icons.person), icon: Icon(Icons.person),
onPressed: () { onPressed: () {
launch('https://www.treadl.com/' + _user['username']); launch('https://www.treadl.com/' + username);
}, },
), ),
] ]
), ),
body: _loading ? body: Container(
Container( margin: const EdgeInsets.all(10.0),
margin: const EdgeInsets.all(10.0), alignment: Alignment.center,
alignment: Alignment.center, child: getBody()
child: CircularProgressIndicator()
)
: Container(
padding: EdgeInsets.all(10),
margin: EdgeInsets.only(top: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(children: [
util.avatarImage(util.avatarUrl(_user), size: 120),
Expanded(child: Container(
padding: EdgeInsets.only(left: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(_user['username'], style: Theme.of(context).textTheme.titleLarge),
SizedBox(height: 5),
_user['location'] != null ?
Row(children: [
Icon(CupertinoIcons.location),
SizedBox(width: 10),
Text(_user['location'])
]) : SizedBox(height: 1),
SizedBox(height: 10),
Text('Member' + (created != null ? (' since ' + created!) : ''),
style: TextStyle(color: Colors.grey[500])
),
SizedBox(height: 10),
_user['website'] != null ?
GestureDetector(
onTap: () {
String url = _user['website'];
if (!url.startsWith('http')) {
url = 'http://' + url;
}
launch(url);
},
child: Text(_user['website'],
style: TextStyle(color: Colors.pink))
) : SizedBox(height: 1),
]
)
))
]),
SizedBox(height: 30),
Text(_user['bio'] != null ? _user['bio'] : '')
]
)
), ),
); );
} }
} }
class UserScreen extends StatefulWidget { class UserScreen extends StatefulWidget {
final Map<String,dynamic> user; final String username;
UserScreen(this.user) { } UserScreen(this.username) { }
@override @override
_UserScreenState createState() => _UserScreenState(user); _UserScreenState createState() => _UserScreenState(username);
} }

View File

@ -3,7 +3,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'dart:io'; import 'dart:io';
import 'dart:convert'; import 'dart:convert';
import 'api.dart'; import 'model.dart';
String APP_URL = 'https://www.treadl.com'; String APP_URL = 'https://www.treadl.com';
@ -80,4 +80,15 @@ class Util {
void shareUrl(String text, String url) async { void shareUrl(String text, String url) async {
await Share.share('$text: $url'); await Share.share('$text: $url');
} }
static String ellipsis(String input, int cutoff) {
return (input.length <= cutoff)
? input
: '${input.substring(0, cutoff)}...';
}
static bool canEditProject(User? user, Map<String,dynamic>? project) {
if (user == null || project == null) return false;
return project['user'] == user.id;
}
} }

View File

@ -1,14 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:go_router/go_router.dart';
import 'store.dart'; import 'store.dart';
import 'login.dart'; import 'login.dart';
class WelcomeScreen extends StatelessWidget { class WelcomeScreen extends StatelessWidget {
void _login(BuildContext context) { void _login(BuildContext context) {
Navigator.of(context).pushNamed('/login'); context.push('/login');
} }
void _register(BuildContext context) { void _register(BuildContext context) {
Navigator.of(context).pushNamed('/register'); context.push('/register');
} }
@override @override
@ -36,11 +37,20 @@ class WelcomeScreen extends StatelessWidget {
SizedBox(height: 15), SizedBox(height: 15),
CupertinoButton( CupertinoButton(
onPressed: () => _register(context), onPressed: () => _register(context),
color: Colors.pink[400],
child: new Text("Register", child: new Text("Register",
style: TextStyle(color: Colors.white), style: TextStyle(color: Colors.white),
textAlign: TextAlign.center, textAlign: TextAlign.center,
) )
), ),
SizedBox(height: 35),
CupertinoButton(
onPressed: () => context.pop(),
child: new Text("Cancel",
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
)
),
]), ]),
)) ))
); );

View File

@ -264,6 +264,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: "3b40e751eaaa855179b416974d59d29669e750d2e50fcdb2b37f1cb0ca8c803a"
url: "https://pub.dev"
source: hosted
version: "13.0.1"
html: html:
dependency: transitive dependency: transitive
description: description:
@ -384,6 +392,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.2" version: "1.0.2"
logging:
dependency: transitive
description:
name: logging
sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:

View File

@ -37,6 +37,7 @@ dependencies:
path_provider: ^2.1.1 path_provider: ^2.1.1
share_plus: ^7.2.1 share_plus: ^7.2.1
flutter_expandable_fab: ^2.0.0 flutter_expandable_fab: ^2.0.0
go_router: ^13.0.1
#fluttertoast: ^8.0.9 #fluttertoast: ^8.0.9

View File

@ -0,0 +1,11 @@
{
"applinks": {
"apps": [],
"details": [
{
"appID": "38T664W57F.com.treadl",
"paths": ["*"]
}
]
}
}