diff --git a/backend/src/mongodb/member.go b/backend/src/mongodb/member.go index 02f04143..ef7fcd27 100644 --- a/backend/src/mongodb/member.go +++ b/backend/src/mongodb/member.go @@ -183,48 +183,62 @@ func (m *MembersType) GetMembers(options GetMemberOptions) ([]*models.Member, er var members []*models.Member = make([]*models.Member, 0) - query := mongo.Pipeline{ - - // filter by name first - {{ - Key: "$match", Value: bson.M{ - "name": bson.M{ - "$regex": fmt.Sprintf(".*%s.*", nameFilter), - "$options": "i", + var query mongo.Pipeline + if len(nameFilter) > 0 { + query = mongo.Pipeline{ + {{ + "$match", bson.M{ + "name": bson.M{ + "$regex": fmt.Sprintf(".*%s.*", nameFilter), + "$options": "i", + }, }, - }, - }}, - - // get all the teams on which each member is participating, - // and add them to each member correspondingly - {{ - Key: "$lookup", Value: bson.D{ - {Key: "from", Value: Teams.Collection.Name()}, - {Key: "localField", Value: "_id"}, - {Key: "foreignField", Value: "members.member"}, - {Key: "as", Value: "team"}, - }, - }}, - - // get an instance of each member for every team he/she belonged to - {{ - Key: "$unwind", Value: "$team", - }}, - - // get the event associated with each team on each member - {{ - Key: "$lookup", Value: bson.D{ - {Key: "from", Value: Events.Collection.Name()}, - {Key: "localField", Value: "team._id"}, - {Key: "foreignField", Value: "teams"}, - {Key: "as", Value: "event"}, - }, - }}, + }}, + } + } else { + query = mongo.Pipeline{ + + // filter by name first + {{ + "$match", bson.M{ + "name": bson.M{ + "$regex": fmt.Sprintf(".*%s.*", nameFilter), + "$options": "i", + }, + }, + }}, + + // get all the teams on which each member is participating, + // and add them to each member correspondingly + {{ + "$lookup", bson.D{ + {"from", Teams.Collection.Name()}, + {"localField", "_id"}, + {"foreignField", "members.member"}, + {"as", "team"}, + }, + }}, + + // get an instance of each member for every team he/she belonged to + {{ + "$unwind", "$team", + }}, + + // get the event associated with each team on each member + {{ + "$lookup", bson.D{ + {"from", Events.Collection.Name()}, + {"localField", "team._id"}, + {"foreignField", "teams"}, + {"as", "event"}, + }, + }}, - // get an instance of each member for every event he/she belonged to - {{ - Key: "$unwind", Value: "$event", - }}, + // get an instance of each member for every event he/she belonged to + {{ + "$unwind", "$event", + }}, + } } if options.Event != nil { @@ -330,6 +344,7 @@ func (m *MembersType) GetMembersParticipations(id primitive.ObjectID) ([]*models options := GetEventsOptions{} events, err := Events.GetEvents(options) if err != nil { + print("ERROR #1", err) return nil, err } @@ -339,6 +354,9 @@ func (m *MembersType) GetMembersParticipations(id primitive.ObjectID) ([]*models for _, teamID := range event.Teams { team, err := Teams.GetTeam(teamID) if err != nil { + print("TEAM ID: ", teamID.Hex()) + print("EVENT: ", event) + print("ERROR #2", err) return nil, err } for _, teamMember := range team.Members { diff --git a/backend/src/mongodb/team.go b/backend/src/mongodb/team.go index f3a4d226..faeff39d 100644 --- a/backend/src/mongodb/team.go +++ b/backend/src/mongodb/team.go @@ -42,7 +42,7 @@ type UpdateTeamMemberData struct { // CreateTeamMemberData contains data needed to create a team member type CreateTeamMemberData struct { - Member primitive.ObjectID `json:"id"` + Member primitive.ObjectID `json:"member"` Role models.TeamRole `json:"role"` } diff --git a/frontend/lib/components/appbar.dart b/frontend/lib/components/appbar.dart index 325558cc..e8d5230c 100644 --- a/frontend/lib/components/appbar.dart +++ b/frontend/lib/components/appbar.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:frontend/components/SearchResultWidget.dart'; import 'package:frontend/components/deckTheme.dart'; import 'package:frontend/components/eventNotifier.dart'; +import 'package:frontend/components/SearchResultWidget.dart'; +import 'package:frontend/main.dart'; import 'package:frontend/models/event.dart'; import 'package:frontend/routes/company/CompanyScreen.dart'; import 'package:frontend/routes/member/MemberScreen.dart'; diff --git a/frontend/lib/components/filterBarTeam.dart b/frontend/lib/components/filterBarTeam.dart index a628c3f9..43daca2e 100644 --- a/frontend/lib/components/filterBarTeam.dart +++ b/frontend/lib/components/filterBarTeam.dart @@ -1,29 +1,34 @@ import 'package:flutter/material.dart'; class FilterBarTeam extends StatefulWidget { + String currentFilter; final Function onSelected; + final List teamFilters; - FilterBarTeam({Key? key, required this.onSelected}) : super(key: key); + FilterBarTeam( + {Key? key, + required this.currentFilter, + required this.teamFilters, + required this.onSelected}) + : super(key: key); @override - FilterBarTeamState createState() => FilterBarTeamState(onSelected: onSelected); + FilterBarTeamState createState() => FilterBarTeamState( + currentFilter: currentFilter, + teamFilters: teamFilters, + onSelected: onSelected); } class FilterBarTeamState extends State { + String currentFilter; + final List teamFilters; final Function onSelected; - FilterBarTeamState({Key? key, required this.onSelected}); - - int _currentIndex = 0; - List _filters = [ - "All", - "Coordination", - "DevTeam", - "Logistics", - "Multimedia", - "Partnerships", - "Social Network", - ]; + FilterBarTeamState( + {Key? key, + required this.currentFilter, + required this.teamFilters, + required this.onSelected}); @override Widget build(BuildContext context) { @@ -35,8 +40,8 @@ class FilterBarTeamState extends State { rowChips() { List filters = []; - for (int i = 0; i < _filters.length; i++) { - filters.add(createChip(_filters[i], i)); + for (int i = 0; i < teamFilters.length; i++) { + filters.add(createChip(teamFilters[i], i)); } return Row(children: filters); } @@ -45,25 +50,26 @@ class FilterBarTeamState extends State { return Container( margin: EdgeInsets.all(7.0), child: ChoiceChip( - selected: _currentIndex == index, + selected: label.toLowerCase() == currentFilter.toLowerCase(), backgroundColor: Colors.indigo[100], shape: RoundedRectangleBorder( side: BorderSide(color: Colors.black12, width: 1), borderRadius: BorderRadius.circular(15), ), - elevation: 2, - pressElevation: 1, + elevation: 1, + pressElevation: 3, shadowColor: Colors.teal, selectedColor: Colors.indigo[400], onSelected: (bool selected) { setState(() { - _currentIndex = selected ? index : _currentIndex; - onSelected(_filters[_currentIndex].toUpperCase()); + onSelected(label.toUpperCase()); }); }, label: Text(label), labelStyle: TextStyle( - color: _currentIndex != index ? Colors.indigo[400] : Colors.white, + color: label.toLowerCase() != currentFilter.toLowerCase() + ? Colors.indigo[400] + : Colors.white, ), padding: EdgeInsets.all(6.0), ), diff --git a/frontend/lib/components/filterbar.dart b/frontend/lib/components/filterbar.dart index 402388e3..1abfe232 100644 --- a/frontend/lib/components/filterbar.dart +++ b/frontend/lib/components/filterbar.dart @@ -17,16 +17,6 @@ class FilterBarState extends State { FilterBarState({Key? key, required this.onSelected}); int _currentIndex = 0; - List _filters = [ - "All", - "Suggested", - "Contacted", - "Rejected", - "Give Up", - "Announced", - "In Conversations", - "In Negotiations" - ]; @override Widget build(BuildContext context) { diff --git a/frontend/lib/components/router.dart b/frontend/lib/components/router.dart index 610c8b41..89861f6f 100644 --- a/frontend/lib/components/router.dart +++ b/frontend/lib/components/router.dart @@ -10,9 +10,9 @@ import 'package:frontend/routes/member/AddMemberForm.dart'; import 'package:frontend/routes/member/MemberListWidget.dart'; import 'package:frontend/routes/speaker/SpeakerListWidget.dart'; import 'package:frontend/routes/speaker/AddSpeakerForm.dart'; +import 'package:frontend/routes/teams/AddTeamMemberForm.dart'; import 'package:frontend/routes/session/AddSessionForm.dart'; - class Routes { static const String BaseRoute = '/'; static const String LoginRoute = '/login'; @@ -23,9 +23,9 @@ class Routes { static const String AddSpeaker = '/add/speaker'; static const String ShowAllMembers = '/all/members'; static const String AddMember = '/add/member'; + static const String AddTeamMember = '/add/teamMember'; static const String AddMeeting = '/add/meeting'; static const String AddSession = '/add/session'; - } Route generateRoute(RouteSettings settings) { @@ -46,6 +46,8 @@ Route generateRoute(RouteSettings settings) { return SlideRoute(page: AddSpeakerForm()); case Routes.ShowAllMembers: return MaterialPageRoute(builder: (context) => MemberListWidget()); + case Routes.AddTeamMember: + return MaterialPageRoute(builder: (context) => AddTeamMemberForm()); case Routes.AddMember: return SlideRoute(page: AddMemberForm()); case Routes.AddMeeting: diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 1b1ef389..2e7f60c0 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -8,6 +8,7 @@ import 'package:frontend/routes/meeting/MeetingsNotifier.dart'; import 'package:frontend/routes/session/SessionsNotifier.dart'; import 'package:frontend/routes/speaker/speakerNotifier.dart'; import 'package:frontend/models/event.dart'; +import 'package:frontend/routes/teams/TeamsNotifier.dart'; import 'package:frontend/services/authService.dart'; import 'package:frontend/services/eventService.dart'; import 'package:provider/provider.dart'; @@ -49,6 +50,9 @@ Future main() async { ChangeNotifierProvider( create: (_) => BottomNavigationBarProvider(), ), + ChangeNotifierProvider( + create: (_) => TeamsNotifier(teams: []), + ), ], child: App(), )); diff --git a/frontend/lib/models/member.dart b/frontend/lib/models/member.dart index b297e15f..521d1119 100644 --- a/frontend/lib/models/member.dart +++ b/frontend/lib/models/member.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:frontend/models/contact.dart'; +import 'package:frontend/models/team.dart'; class Member { final String id; diff --git a/frontend/lib/models/team.dart b/frontend/lib/models/team.dart index d401b1e4..90fb9fff 100644 --- a/frontend/lib/models/team.dart +++ b/frontend/lib/models/team.dart @@ -57,7 +57,7 @@ class TeamPublic { class Team { final String? id; - final String? name; + String? name; final List? members; final List? meetings; @@ -71,7 +71,7 @@ class Team { id: json['id'], name: json['name'], members: members.map((e) => TeamMember.fromJson(e)).toList(), - meetings: meetings.length == 0? [] : meetings as List, + meetings: meetings.length == 0 ? [] : meetings as List, ); } diff --git a/frontend/lib/routes/member/MemberScreen.dart b/frontend/lib/routes/member/MemberScreen.dart index c2cd84ef..e6e4dffc 100644 --- a/frontend/lib/routes/member/MemberScreen.dart +++ b/frontend/lib/routes/member/MemberScreen.dart @@ -71,11 +71,10 @@ class _MemberScreen extends State length: 2, child: Column(children: [ MemberBanner( - member: widget.member, - onEdit: (context, _member) { + member: widget.member, + onEdit: (context, _member) { memberChangedCallback(context, member: _member); - } - ), + }), TabBar( isScrollable: small, controller: _tabController, @@ -103,11 +102,8 @@ class MemberBanner extends StatefulWidget { final Member member; final void Function(BuildContext, Member?) onEdit; - const MemberBanner( - {Key? key, - required this.member, - required this.onEdit}) - : super(key: key); + const MemberBanner({Key? key, required this.member, required this.onEdit}) + : super(key: key); void _editMemberModal(context) { showModalBottomSheet( @@ -133,7 +129,9 @@ class _MemberBannerState extends State { Role r = snapshot.data as Role; Member me = Provider.of(context)!; - if (r == Role.ADMIN || r == Role.COORDINATOR || me.id == widget.member.id) { + if (r == Role.ADMIN || + r == Role.COORDINATOR || + me.id == widget.member.id) { return Positioned( bottom: 15, right: 15, @@ -221,7 +219,8 @@ class _MemberBannerState extends State { class DisplayParticipations extends StatefulWidget { final Member member; final bool small; - const DisplayParticipations({Key? key, required this.member, required this.small}) + const DisplayParticipations( + {Key? key, required this.member, required this.small}) : super(key: key); @override @@ -238,63 +237,77 @@ class _DisplayParticipationsState extends State { @override void initState() { super.initState(); - this.memberParticipations = memberService.getMemberParticipations(widget.member.id); + this.memberParticipations = + memberService.getMemberParticipations(widget.member.id); } @override Widget build(BuildContext context) => Scaffold( body: FutureBuilder( - future: Future.wait([memberParticipations, Provider.of(context).role]), + future: Future.wait( + [memberParticipations, Provider.of(context).role]), builder: (context, AsyncSnapshot> snapshot) { if (snapshot.hasData) { - List memParticipations = snapshot.data![0] as List; + List memParticipations = + snapshot.data![0] as List; Role r = snapshot.data![1] as Role; Member me = Provider.of(context)!; return Scaffold( backgroundColor: Color.fromRGBO(186, 196, 242, 0.1), - body: - ListView( - padding: EdgeInsets.symmetric(horizontal: 32), - children: [ - ListView.builder( - shrinkWrap: true, - physics: BouncingScrollPhysics(), - itemCount: memParticipations.length, - itemBuilder: (BuildContext context, int index) { - var e = memParticipations.reversed.elementAt(index); - return MemberPartCard( - event: e.event!, cardRole: e.role!, myRole: r.name, team: e.team!, small: widget.small, - canEdit: (authService.convert(e.role!) == Role.ADMIN) ? - (r == Role.ADMIN) - : (r == Role.ADMIN || r == Role.COORDINATOR) - , - onChanged: (role) async { - List teamsByName = await teamService.getTeams(name: e.team); - Team? team = await teamService.updateTeamMemberRole(teamsByName[0].id!, widget.member.id, widget.member, role); - if (team==null) - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Unable to change that role', - style: TextStyle(color: Colors.white, - backgroundColor: Colors.red))), - ); - else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Updated member role', - style: TextStyle(color: Colors.white),)), - ); - if(me.id == widget.member.id){ - await authService.signOut(); - Navigator.pushReplacementNamed(context, Routes.LoginRoute); + body: ListView( + padding: EdgeInsets.symmetric(horizontal: 32), + children: [ + ListView.builder( + shrinkWrap: true, + physics: BouncingScrollPhysics(), + itemCount: memParticipations.length, + itemBuilder: (BuildContext context, int index) { + var e = memParticipations.reversed.elementAt(index); + return MemberPartCard( + event: e.event!, + cardRole: e.role!, + myRole: r.name, + team: e.team!, + small: widget.small, + canEdit: (authService.convert(e.role!) == + Role.ADMIN) + ? (r == Role.ADMIN) + : (r == Role.ADMIN || r == Role.COORDINATOR), + onChanged: (role) async { + List teamsByName = + await teamService.getTeams(name: e.team); + Team? team = + await teamService.updateTeamMemberRole( + teamsByName[0].id!, + widget.member.id, + role); + if (team == null) + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Unable to change that role', + style: TextStyle( + color: Colors.white, + backgroundColor: Colors.red))), + ); + else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Updated member role', + style: TextStyle(color: Colors.white), + )), + ); + if (me.id == widget.member.id) { + await authService.signOut(); + Navigator.pushReplacementNamed( + context, Routes.LoginRoute); + } } - } - - } - ); - }, - ), - ], + }); + }, + ), + ], ), ); } else { diff --git a/frontend/lib/routes/teams/AddTeamMemberForm.dart b/frontend/lib/routes/teams/AddTeamMemberForm.dart new file mode 100644 index 00000000..35c5e0f3 --- /dev/null +++ b/frontend/lib/routes/teams/AddTeamMemberForm.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:frontend/components/appbar.dart'; +import 'package:frontend/components/SearchResultWidget.dart'; +import 'package:frontend/models/member.dart'; +import 'package:frontend/models/team.dart'; +import 'package:frontend/services/teamService.dart'; +import 'package:frontend/services/memberService.dart'; + +final Map roles = { + "MEMBER": "Member", + "TEAMLEADER": "Team Leader" +}; + +class AddTeamMemberForm extends StatefulWidget { + final Team? team; + final void Function(BuildContext, Team?)? onEditTeam; + + AddTeamMemberForm({Key? key, this.team, this.onEditTeam}) : super(key: key); + + @override + _AddTeamMemberFormState createState() => _AddTeamMemberFormState(); +} + +class _AddTeamMemberFormState extends State { + final _formKey = GlobalKey(); + MemberService _memberService = new MemberService(); + final _searchMembersController = TextEditingController(); + TeamService service = TeamService(); + late Future> membs; + String memberRole = ""; + String _memberID = ''; + bool disappearSearchResults = false; + String role = ""; + + void _submit(BuildContext context) async { + if (_formKey.currentState!.validate()) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Adding member...')), + ); + Team? t = await service.addTeamMember( + id: widget.team!.id, memberId: _memberID, role: role); + if (t != null) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Done'), + duration: Duration(seconds: 2), + ), + ); + Navigator.pop(context); + widget.onEditTeam!(context, t); + } else { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('An error occured.')), + ); + + Navigator.pop(context); + } + } + } + + Widget _buildForm() { + return Form( + key: _formKey, + child: SingleChildScrollView( + physics: BouncingScrollPhysics(), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: TextFormField( + controller: _searchMembersController, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a member'; + } + return null; + }, + decoration: const InputDecoration( + icon: const Icon(Icons.person_add), + labelText: "Member *", + ), + onChanged: (newQuery) { + setState(() {}); + if (_searchMembersController.text.length > 1) { + this.membs = _memberService.getMembers( + name: _searchMembersController.text); + } + })), + ...getResults(MediaQuery.of(context).size.height * 0.7), + Padding( + padding: const EdgeInsets.all(8.0), + child: DropdownButtonFormField( + validator: (value) { + if (value == null) { + return 'Please select a role'; + } + return null; + }, + decoration: const InputDecoration( + icon: const Icon(Icons.grid_3x3), + labelText: "Role *", + ), + items: roles.keys.map((e) { + return new DropdownMenuItem( + value: e, child: Text(roles[e]!)); + }).toList(), + onChanged: (newValue) { + setState(() => role = newValue.toString()); + }), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.secondary, + padding: EdgeInsets.symmetric(horizontal: 50), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20)), + ), + onPressed: () => _submit(context), + child: const Text('SUBMIT'), + ), + ), + ], + ), + ), + ); + } + + List getResults(double height) { + if (_searchMembersController.text.length > 1 && !disappearSearchResults) { + return [ + Container( + decoration: new BoxDecoration( + color: Theme.of(context).cardColor, + ), + child: FutureBuilder( + future: this.membs, + builder: (context, snapshot) { + if (snapshot.hasData) { + List membsMatched = snapshot.data as List; + print("membs matched:" + membsMatched.toString()); + return searchResults(membsMatched, height); + } else { + return Center(child: CircularProgressIndicator()); + } + })) + ]; + } else { + return []; + } + } + + Widget searchResults(List members, double listHeight) { + List results = getListCards(members); + return Container( + constraints: BoxConstraints(maxHeight: listHeight), + child: ListView.builder( + scrollDirection: Axis.vertical, + shrinkWrap: true, + itemCount: results.length, + itemBuilder: (BuildContext context, int index) { + return results[index]; + })); + } + + void _getMemberData(String id, String name) { + widget.team!.members!.map((memberteam) { + if (id == memberteam.memberID) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text("Member already exist"), + content: Text("Can't add again"), + actions: [ + TextButton( + child: Text("OK"), + onPressed: () { + Navigator.pop(context); + Navigator.pop(context); + }, + ), + ], + ); + }, + ); + return; + } + }).toList(); + + _memberID = id; + _searchMembersController.text = name; + disappearSearchResults = true; + setState(() {}); + } + + List getListCards(List members) { + List results = []; + if (members.length != 0) { + results.add(getDivider("Members")); + results.addAll(members.map((e) => SearchResultWidget( + member: e, + getMemberData: _getMemberData, + ))); + } + return results; + } + + Widget getDivider(String name) { + return Card( + margin: EdgeInsets.zero, + child: Column( + children: [ + Container( + child: Text(name, style: TextStyle(fontSize: 18)), + margin: EdgeInsets.fromLTRB(0, 8, 0, 4), + ), + ], + )); + } + + @override + Widget build(BuildContext context) { + return _buildForm(); + } +} diff --git a/frontend/lib/routes/teams/TeamScreen.dart b/frontend/lib/routes/teams/TeamScreen.dart index fff677e6..fd1047da 100644 --- a/frontend/lib/routes/teams/TeamScreen.dart +++ b/frontend/lib/routes/teams/TeamScreen.dart @@ -2,17 +2,33 @@ import 'package:flutter/material.dart'; import 'package:frontend/models/meeting.dart'; import 'package:frontend/models/member.dart'; import 'package:frontend/models/team.dart'; +import 'package:frontend/routes/UnknownScreen.dart'; import 'package:frontend/routes/meeting/MeetingCard.dart'; import 'package:frontend/routes/member/MemberScreen.dart'; +import 'package:frontend/routes/teams/AddTeamMemberForm.dart'; +import 'package:frontend/routes/teams/TeamsNotifier.dart'; import 'package:frontend/services/meetingService.dart'; +import 'package:frontend/services/memberService.dart'; import 'package:frontend/services/teamService.dart'; +import 'package:frontend/services/authService.dart'; +import 'package:flutter_speed_dial/flutter_speed_dial.dart'; +import 'package:provider/provider.dart'; + +import '../../components/blurryDialog.dart'; + +final Map roles = { + "MEMBER": "Member", + "TEAMLEADER": "Team Leader", + "COORDINATOR": "Coordinator", + "ADMIN": "Administrator" +}; + +bool membersPage = true; class TeamScreen extends StatefulWidget { - final Team team; - final List members; + Team team; - TeamScreen({Key? key, required this.team, required this.members}) - : super(key: key); + TeamScreen({Key? key, required this.team}) : super(key: key); @override _TeamScreen createState() => _TeamScreen(); @@ -21,15 +37,16 @@ class TeamScreen extends StatefulWidget { class _TeamScreen extends State with SingleTickerProviderStateMixin { late final TabController _tabController; - TeamService teamService = new TeamService(); - - _TeamScreen({Key? key}); + TeamService _teamService = new TeamService(); + MemberService _memberService = new MemberService(); + late List> _members; @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); _tabController.addListener(_handleTabIndex); + _getTeamMembers(widget.team); } @override @@ -43,44 +60,438 @@ class _TeamScreen extends State setState(() {}); } - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: GestureDetector( - child: Image.asset( - 'assets/logo-branco2.png', - height: 100, - width: 100, - )), + void _getTeamMembers(Team t) { + _members = t.members! + .map((teamMember) => _memberService.getMember(teamMember.memberID!)) + .toList(); + } + + Future teamChangedCallback(BuildContext context, + {Future? fm, Team? team}) async { + Team? m; + if (fm != null) { + m = await fm; + } else if (team != null) { + m = team; + } + if (m != null) { + Provider.of(context, listen: false).edit(m); + setState(() { + widget.team = m!; + _getTeamMembers(m); + }); + } + } + + void _addTeamMember(context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) { + return FractionallySizedBox( + heightFactor: 0.7, + child: Container( + child: AddTeamMemberForm( + team: widget.team, + onEditTeam: (context, _team) { + teamChangedCallback(context, team: _team); + }), + ), + ); + }, + ); + } + + buildSpeedDial(BuildContext context) { + return FutureBuilder( + future: Provider.of(context).role, + builder: (context, snapshot) { + if (snapshot.hasData) { + Role r = snapshot.data as Role; + + if (r == Role.ADMIN || r == Role.COORDINATOR) { + if (membersPage) { + return SpeedDial( + animatedIcon: AnimatedIcons.menu_close, + animatedIconTheme: IconThemeData(size: 28.0), + backgroundColor: Color(0xff5C7FF2), + visible: true, + curve: Curves.bounceInOut, + children: [ + SpeedDialChild( + child: Icon(Icons.person_remove, color: Colors.white), + backgroundColor: Colors.indigo, + onTap: () => showRemoveMemberDialog(context), + label: 'Remove Members', + labelStyle: TextStyle( + fontWeight: FontWeight.w500, color: Colors.white), + labelBackgroundColor: Colors.black, + ), + SpeedDialChild( + child: Icon(Icons.person_add, color: Colors.white), + backgroundColor: Colors.indigo, + onTap: () => _addTeamMember(context), + label: 'Add Member', + labelStyle: TextStyle( + fontWeight: FontWeight.w500, color: Colors.white), + labelBackgroundColor: Colors.black, + ), + SpeedDialChild( + child: Icon(Icons.delete, color: Colors.white), + backgroundColor: Colors.indigo, + onTap: () => + showDeleteTeamDialog(context, widget.team.id), + label: 'Delete Team', + labelStyle: TextStyle( + fontWeight: FontWeight.w500, color: Colors.white), + labelBackgroundColor: Colors.black, + ), + SpeedDialChild( + child: Icon(Icons.edit, color: Colors.white), + backgroundColor: Colors.indigo, + onTap: () => showEditTeamDialog(), + label: 'Edit Team', + labelStyle: TextStyle( + fontWeight: FontWeight.w500, color: Colors.white), + labelBackgroundColor: Colors.black, + ), + ], + ); + } else { + return SpeedDial( + animatedIcon: AnimatedIcons.menu_close, + animatedIconTheme: IconThemeData(size: 28.0), + backgroundColor: Color(0xff5C7FF2), + visible: true, + curve: Curves.bounceInOut, + children: [ + SpeedDialChild( + child: Icon(Icons.groups, color: Colors.white), + backgroundColor: Colors.indigo, + onTap: () => {}, + label: 'Add Meetings', + labelStyle: TextStyle( + fontWeight: FontWeight.w500, color: Colors.white), + labelBackgroundColor: Colors.black, + ), + SpeedDialChild( + child: Icon(Icons.delete, color: Colors.white), + backgroundColor: Colors.indigo, + onTap: () => + showDeleteTeamDialog(context, widget.team.id), + label: 'Delete Team', + labelStyle: TextStyle( + fontWeight: FontWeight.w500, color: Colors.white), + labelBackgroundColor: Colors.black, + ), + SpeedDialChild( + child: Icon(Icons.edit, color: Colors.white), + backgroundColor: Colors.indigo, + onTap: () => showEditTeamDialog(), + label: 'Edit Team', + labelStyle: TextStyle( + fontWeight: FontWeight.w500, color: Colors.white), + labelBackgroundColor: Colors.black, + ), + ], + ); + } + } else { + return Container(); //CONFIRMAR + } + } else { + return Container(); + } + }); + } + + showEditTeamDialog() { + String name = widget.team.name ?? ""; + return showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text("Edit Team"), + content: TextFormField( + initialValue: name, + onChanged: (value) { + name = value; + }, + decoration: const InputDecoration(hintText: "New name for the team"), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, "Cancel"), + child: const Text("Cancel"), + ), + TextButton( + onPressed: () => editTeam(widget.team.id, name), + child: const Text("Update"), + ), + ], ), - body: Container( - child: DefaultTabController( - length: 2, - child: Column(children: [ - TeamBanner(team: widget.team), - TabBar( - controller: _tabController, - labelColor: Colors.black, - tabs: [ - Tab( - text: 'Members', - ), - Tab(text: 'Meetings'), - ], - ), - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - DisplayMembers(members: widget.members), - DisplayMeeting(meetingsIds: widget.team.meetings), - ], - )) - ])), + ); + } + + showDeleteTeamDialog(context, id) { + final String name = widget.team.name ?? "team"; + return showDialog( + context: context, + builder: (BuildContext context) { + return BlurryDialog( + 'Warning', 'Are you sure you want to delete meeting $name?', () { + deleteTeam(context, id); + }); + } + /* => + AlertDialog( + title: const Text("Remove Team"), + content: Text("Are you sure you want to delete \"$name\"?"), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, "No"), + child: const Text("No"), + ), + TextButton( + onPressed: () => deleteTeam(context, widget.team.id), + child: const Text("Yes"), + ), + ], + ),*/ + ); + } + + showRemoveMemberDialog(context) async { + String memberId = ""; + List _membs = await Future.wait(_members); + return showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text("Remove Team Member"), + content: DropdownButtonFormField( + validator: (value) { + if (value == null) { + return 'Please select one member'; + } + return null; + }, + decoration: const InputDecoration( + icon: const Icon(Icons.grid_3x3), + labelText: "MemberId *", + ), + items: _membs.map((Member? member) { + return new DropdownMenuItem( + value: member!.id, child: Text(member.name)); + }).toList(), + onChanged: (newValue) { + setState(() => memberId = newValue.toString()); + }), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, "Cancel"), + child: const Text("Cancel"), + ), + TextButton( + onPressed: () => + removeTeamMember(context, widget.team.id, memberId), + child: const Text("Delete"), + ), + ], ), ); } + + showError() { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('An error has occured. Please contact the admins'), + duration: Duration(seconds: 4), + ), + ); + return Center( + child: Icon( + Icons.error, + size: 200, + )); + } + + void removeTeamMember(context, String? id, String memberId) async { + showDialog( + context: context, + builder: (BuildContext context) { + return BlurryDialog( + 'Warning', 'Are you sure you want to delete this member?', + () async { + Team? team = await _teamService.deleteTeamMember(id!, memberId); + if (team != null) { + TeamsNotifier notifier = + Provider.of(context, listen: false); + notifier.edit(team); + + teamChangedCallback(context, team: team); + + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Done'), + duration: Duration(seconds: 2), + ), + ); + + Navigator.pop(context); + } else { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('An error occured.')), + ); + } + }); + }, + ); + } + + void editTeam(String? id, String name) async { + Team? t = await _teamService.updateTeam(id!, name); + if (t != null) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Done'), + duration: Duration(seconds: 2), + ), + ); + + teamChangedCallback(context, team: t); + } else { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('An error occured.')), + ); + + Navigator.pop(context); + } + Navigator.pop(context, "Update"); + } + + void deleteTeam(context, String? id) async { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Deleting')), + ); + Team? team = await _teamService.deleteTeam(id!); + if (team != null) { + TeamsNotifier notifier = + Provider.of(context, listen: false); + notifier.remove(team); + + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Done'), + duration: Duration(seconds: 2), + ), + ); + Navigator.pop(context); + } else { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('An error occured.')), + ); + } + } + + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, notif, child) { + return Scaffold( + appBar: AppBar( + title: GestureDetector( + child: Image.asset( + 'assets/logo-branco2.png', + height: 100, + width: 100, + )), + ), + body: Container( + child: DefaultTabController( + length: 2, + child: Column(children: [ + TeamBanner(team: widget.team), + TabBar( + controller: _tabController, + labelColor: Colors.black, + tabs: [ + Tab( + text: 'Members', + ), + Tab(text: 'Meetings'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + DisplayMembers(members: _members), + DisplayMeeting(meetingsIds: widget.team.meetings), + ], + )) + ])), + ), + // TODO should only appear in Members tab? + floatingActionButton: buildSpeedDial(context), + ); + }); + } +} + +class SearchResultWidget extends StatelessWidget { + final Member? member; + const SearchResultWidget({Key? key, this.member}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) { + return UnknownScreen(); + })); + }, + child: Center( + child: ListTile( + leading: CircleAvatar( + foregroundImage: NetworkImage(getImageURL()), + backgroundImage: AssetImage( + 'assets/noImage.png', + ), + ), + title: Text(getName()), + ), + )); + } + + String getImageURL() { + if (this.member != null) { + return this.member!.image!; + } else { + //ERROR case + return ""; + } + } + + String getName() { + if (this.member != null) { + return this.member!.name; + } else { + //ERROR case + return ""; + } + } } class DisplayMeeting extends StatefulWidget { @@ -105,66 +516,68 @@ class _DisplayMeetingState extends State { @override Widget build(BuildContext context) { - List> _futureMeetings = widget.meetingsIds! - .map((m) => _meetingService.getMeeting(m)) - .toList(); + List> _futureMeetings = + widget.meetingsIds!.map((m) => _meetingService.getMeeting(m)).toList(); + + membersPage = false; return Scaffold( backgroundColor: Color.fromRGBO(186, 196, 242, 0.1), - body: (widget.meetingsIds == null) ? Container() : FutureBuilder( - future: Future.wait(_futureMeetings), - builder: (context, snapshot){ - if (snapshot.hasData) { - List meetings = snapshot.data as List; - - return ListView( - padding: EdgeInsets.symmetric(horizontal: 32), - physics: BouncingScrollPhysics(), - scrollDirection: Axis.vertical, - children: - meetings.map((e) => MeetingCard(meeting: e!)).toList()); - } else { - return Container( - child: Center( - child: Container( - height: 50, - width: 50, - child: CircularProgressIndicator(), - ), - ), - ); - } - }), - floatingActionButton: FloatingActionButton.extended( - onPressed: () {}, - label: const Text('Add Meetings'), - icon: const Icon(Icons.edit), - backgroundColor: Color(0xff5C7FF2), - ), + body: (widget.meetingsIds == null) + ? Container() + : FutureBuilder( + future: Future.wait(_futureMeetings), + builder: (context, snapshot) { + if (snapshot.hasData) { + List meetings = snapshot.data as List; + + return ListView( + padding: EdgeInsets.symmetric(horizontal: 32), + physics: BouncingScrollPhysics(), + scrollDirection: Axis.vertical, + children: meetings + .map((e) => MeetingCard(meeting: e!)) + .toList()); + } else { + return Container( + child: Center( + child: Container( + height: 50, + width: 50, + child: CircularProgressIndicator(), + ), + ), + ); + } + }), ); } } class DisplayMembers extends StatelessWidget { - final List members; + final List> members; const DisplayMembers({Key? key, required this.members}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Color.fromRGBO(186, 196, 242, 0.1), - body: ListView( - padding: EdgeInsets.symmetric(horizontal: 32), - physics: BouncingScrollPhysics(), - scrollDirection: Axis.vertical, - children: members.map((e) => ShowMember(member: e!)).toList()), - floatingActionButton: FloatingActionButton.extended( - onPressed: () {}, - label: const Text('Edit Members'), - icon: const Icon(Icons.edit), - backgroundColor: Color(0xff5C7FF2), - ), - ); + backgroundColor: Color.fromRGBO(186, 196, 242, 0.1), + body: FutureBuilder>( + future: Future.wait(members), + builder: (context, snapshot) { + if (snapshot.hasData) { + List membs = snapshot.data as List; + + return ListView( + padding: EdgeInsets.symmetric(horizontal: 32), + physics: BouncingScrollPhysics(), + scrollDirection: Axis.vertical, + children: + membs.map((e) => ShowMember(member: e!)).toList()); + } else { + return CircularProgressIndicator(); + } + })); } } @@ -179,7 +592,7 @@ class ShowMember extends StatelessWidget { Navigator.push( context, MaterialPageRoute( - builder: (context) => MemberScreen(member: member))); //TODO + builder: (context) => MemberScreen(member: member))); }, child: Card( color: Colors.white, diff --git a/frontend/lib/routes/teams/TeamsNotifier.dart b/frontend/lib/routes/teams/TeamsNotifier.dart new file mode 100644 index 00000000..08154f73 --- /dev/null +++ b/frontend/lib/routes/teams/TeamsNotifier.dart @@ -0,0 +1,26 @@ +import 'package:flutter/cupertino.dart'; +import 'package:frontend/models/team.dart'; + +class TeamsNotifier extends ChangeNotifier { + List teams; + + TeamsNotifier({required this.teams}); + + void add(Team s) { + teams.add(s); + notifyListeners(); + } + + void remove(Team t) { + teams.removeWhere((team) => t.id == team.id); + notifyListeners(); + } + + void edit(Team t) { + int index = teams.indexWhere((team) => t.id == team.id); + if (index != -1) { + teams[index] = t; + notifyListeners(); + } + } +} diff --git a/frontend/lib/routes/teams/TeamsTable.dart b/frontend/lib/routes/teams/TeamsTable.dart index 1127107c..cad522f1 100644 --- a/frontend/lib/routes/teams/TeamsTable.dart +++ b/frontend/lib/routes/teams/TeamsTable.dart @@ -1,15 +1,20 @@ import 'package:flutter/material.dart'; +import 'package:frontend/components/deckTheme.dart'; import 'package:frontend/components/eventNotifier.dart'; import 'package:frontend/main.dart'; import 'package:frontend/models/member.dart'; import 'package:frontend/components/ListViewCard.dart'; import 'package:frontend/models/team.dart'; import 'package:frontend/routes/teams/TeamScreen.dart'; +import 'package:frontend/routes/teams/TeamsNotifier.dart'; import 'package:frontend/services/memberService.dart'; import 'package:frontend/services/teamService.dart'; +import 'package:frontend/components/filterBarTeam.dart'; import 'package:provider/provider.dart'; import 'package:shimmer/shimmer.dart'; +const ALL = "All"; + class TeamTable extends StatefulWidget { TeamTable({Key? key}) : super(key: key); @@ -20,88 +25,199 @@ class TeamTable extends StatefulWidget { class _TeamTableState extends State with AutomaticKeepAliveClientMixin { final TeamService _teamService = TeamService(); - late Future> teams; + String filter = ""; + late List teams; + + late Future> members; bool get wantKeepAlive => true; @override void initState() { super.initState(); + filter = ALL; } @override Widget build(BuildContext context) { super.build(context); + return Consumer(builder: (context, notif, child) { + return Scaffold( + body: FutureBuilder( + future: Future.wait([ + _teamService.getTeams( + event: Provider.of(context).event.id) + ]), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.hasError) { + return showError(); + } else if (snapshot.hasData) { + TeamsNotifier notifier = Provider.of(context); + + List> dataTeam = + snapshot.data as List>; + + teams = dataTeam[0] as List; + + notifier.teams = teams; + + teams.sort((a, b) => a.name!.compareTo(b.name!)); + + return showTeams(); + } else { + return Center(child: CircularProgressIndicator()); + } + } else { + return Shimmer.fromColors( + baseColor: Colors.grey[400]!, + highlightColor: Colors.white, + child: ListView.builder( + itemCount: 5, + itemBuilder: (context, index) => TeamMemberRow.fake(), + addAutomaticKeepAlives: true, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics()), + ), + ); + } + }, + ), + floatingActionButtonLocation: FloatingActionButtonLocation.startFloat, + floatingActionButton: FloatingActionButton.extended( + onPressed: showCreateTeamDialog, + label: const Text('Create New Team'), + icon: const Icon(Icons.person_add), + backgroundColor: Provider.of(context).isDark + ? Colors.grey[500] + : Colors.indigo), + ); + }); + } + + onSelected(String value) { + setState(() { + filter = value; + }); + } + + buildTeamsList() { + List filteredTeams; + if (filter.toLowerCase() == ALL.toLowerCase()) + filteredTeams = teams; + else + filteredTeams = teams + .where((team) => team.name?.toLowerCase() == filter.toLowerCase()) + .toList(); + return ListView.builder( + itemCount: filteredTeams.length, + itemBuilder: (context, index) => + TeamMemberRow(team: filteredTeams[index]), + addAutomaticKeepAlives: true, + physics: const AlwaysScrollableScrollPhysics()); + } + + showError() { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('An error has occured. Please contact the admins'), + duration: Duration(seconds: 4), + ), + ); + return Center( + child: Icon( + Icons.error, + size: 200, + )); + } + + showTeams() { return NestedScrollView( floatHeaderSlivers: true, headerSliverBuilder: (context, innerBoxIsScrolled) => [ SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(8.0, 0, 0, 0), - ), + padding: const EdgeInsets.fromLTRB(8.0, 0, 0, 0), + child: FilterBarTeam( + currentFilter: filter, + teamFilters: getTeamsFilter(), + onSelected: (value) => onSelected(value), + )), ), ], - body: FutureBuilder( - future: Future.wait([ - _teamService.getTeams( - event: Provider.of(context).event.id) - ]), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - if (snapshot.hasError) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: - Text('An error has occured. Please contact the admins'), - duration: Duration(seconds: 4), - ), - ); - return Center( - child: Icon( - Icons.error, - size: 200, - )); - } + body: RefreshIndicator( + onRefresh: () { + return Future.delayed(Duration.zero, () { + setState(() {}); + }); + }, + child: buildTeamsList(), + ), + ); + } - List> data = snapshot.data as List>; + showCreateTeamDialog() { + String name = ""; + return showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text("Create New Team"), + content: TextField( + onChanged: (value) { + name = value; + }, + decoration: const InputDecoration( + hintText: "Insert the name of the new team"), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, "Cancel"), + child: const Text("Cancel"), + ), + TextButton( + onPressed: () => createTeam(name), + child: const Text("Create"), + ), + ], + ), + ); + } - List tms = data[0] as List; + void createTeam(String name) async { + Team? t = await _teamService.createTeam(name); + if (t != null) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); - tms.sort((a, b) => a.name!.compareTo(b.name!)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Done'), + duration: Duration(seconds: 2), + ), + ); - return RefreshIndicator( - onRefresh: () { - return Future.delayed(Duration.zero, () { - setState(() {}); - }); - }, - child: ListView.builder( - itemCount: tms.length, - itemBuilder: (context, index) => - TeamMemberRow(team: tms[index]), - addAutomaticKeepAlives: true, - physics: const AlwaysScrollableScrollPhysics(), - ), - ); - } else { - return Shimmer.fromColors( - baseColor: Colors.grey[400]!, - highlightColor: Colors.white, - child: ListView.builder( - itemCount: 5, - itemBuilder: (context, index) => TeamMemberRow.fake(), - addAutomaticKeepAlives: true, - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics()), - ), - ); - } - }, - ), - ); + TeamsNotifier notifier = + Provider.of(context, listen: false); + notifier.add(t); + } else { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('An error occured.')), + ); + + Navigator.pop(context); + } + Navigator.pop(context, "Create"); + } + + getTeamsFilter() { + List filters = + teams.map((team) => team.name).whereType().toList(); + filters.insert(0, ALL); + return filters; } } @@ -187,8 +303,8 @@ class TeamMemberRow extends StatelessWidget { Navigator.push( context, MaterialPageRoute( - builder: (context) => TeamScreen( - team: team, members: membs))); + builder: (context) => + TeamScreen(team: team))); }, child: Column( children: [ diff --git a/frontend/lib/services/teamService.dart b/frontend/lib/services/teamService.dart index 804d5107..e2dc29c0 100644 --- a/frontend/lib/services/teamService.dart +++ b/frontend/lib/services/teamService.dart @@ -97,9 +97,12 @@ class TeamService extends Service { } } - Future addTeamMember(String id, String member, String role) async { + Future addTeamMember( + {required String? id, + required String memberId, + required String role}) async { var body = { - "member": member, + "member": memberId, "role": role, }; @@ -118,14 +121,14 @@ class TeamService extends Service { } Future updateTeamMemberRole( - String id, String memberID, Member member, String role) async { + String id, String memberId, String role) async { var body = { - "member": member, + "member": memberId, "role": role, }; Response response = - await dio.put(baseURL + '/$id' + '/members' + '/$memberID', data: body); + await dio.put(baseURL + '/$id' + '/members' + '/$memberId', data: body); try { return Team.fromJson(json.decode(response.data!)); diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index bcb35ff6..3af70dc2 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -23,6 +23,7 @@ environment: dependencies: flutter: sdk: flutter + flutter_speed_dial: google_sign_in: ^5.0.7 http: ^0.13.3 file_picker: ^5.2.0+1 @@ -41,7 +42,7 @@ dependencies: collection: ^1.15.0 image: ^3.0.5 shimmer: ^2.0.0 - timeago: ^3.1.0 + timeago: ^3.1.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.3