Add groups with shared goals, objectives and chat; drop the water tracker
Groups let people keep each other honest: create one or join with a six-letter invite code. Each group has shared goals split into measurable objectives (km, sessions, words) that every member logs toward, a leaderboard of kept rate and streak with nudges, and a group chat where check-ins and nudges appear as cards beside the talk. The open chat polls for new messages. Reached from a new Groups tab (Training moves into the day rail), a Home "Your groups" rail that opens the chat, and a Group tile in the add sheet. Mock mode seeds two groups, a joinable one and members who reply. The water tracker is removed; it did not fit the app. Bottom sheets are now square app-wide via the theme. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
This commit is contained in:
10
frontend/lib/Grounded/see/group/ConnectGroupChat.dart
Normal file
10
frontend/lib/Grounded/see/group/ConnectGroupChat.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import '../../about/external/data/GroupMessage.dart';
|
||||
|
||||
abstract class ConnectGroupChat {
|
||||
void onMessagesLoaded(List<GroupMessage> messages);
|
||||
|
||||
void onMessageSent(GroupMessage message);
|
||||
|
||||
/// The send failed; the text goes back in the box so nothing is lost.
|
||||
void onSendFailed(String body);
|
||||
}
|
||||
12
frontend/lib/Grounded/see/group/ConnectGroupDetail.dart
Normal file
12
frontend/lib/Grounded/see/group/ConnectGroupDetail.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import '../../about/external/data/Group.dart';
|
||||
|
||||
abstract class ConnectGroupDetail {
|
||||
void onGroupLoaded(Group group);
|
||||
|
||||
/// Progress landed; the group comes back with the new totals.
|
||||
void onProgressLogged(Group group, String summary);
|
||||
|
||||
void onNudged(String who);
|
||||
|
||||
void onLeft();
|
||||
}
|
||||
6
frontend/lib/Grounded/see/group/ConnectGroupEditor.dart
Normal file
6
frontend/lib/Grounded/see/group/ConnectGroupEditor.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
import '../../about/external/data/Group.dart';
|
||||
|
||||
abstract class ConnectGroupEditor {
|
||||
/// Created or joined — either way you are now in it.
|
||||
void onGroupReady(Group group);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
abstract class ConnectGroupGoalEditor {
|
||||
void onGoalSaved();
|
||||
}
|
||||
5
frontend/lib/Grounded/see/group/ConnectGroups.dart
Normal file
5
frontend/lib/Grounded/see/group/ConnectGroups.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
import '../../about/external/data/Group.dart';
|
||||
|
||||
abstract class ConnectGroups {
|
||||
void onGroupsLoaded(List<Group> groups);
|
||||
}
|
||||
13
frontend/lib/Grounded/see/group/GroupChat.dart
Normal file
13
frontend/lib/Grounded/see/group/GroupChat.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/external/data/Group.dart';
|
||||
import 'GroupChatState.dart';
|
||||
|
||||
class GroupChat extends StatefulWidget {
|
||||
final Group group;
|
||||
|
||||
const GroupChat({super.key, required this.group});
|
||||
|
||||
@override
|
||||
State<GroupChat> createState() => GroupChatState();
|
||||
}
|
||||
464
frontend/lib/Grounded/see/group/GroupChatState.dart
Normal file
464
frontend/lib/Grounded/see/group/GroupChatState.dart
Normal file
@@ -0,0 +1,464 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/GroupMessage.dart';
|
||||
import '../../about/external/initial/GroupMessageRequest.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/AmbientBackground.dart';
|
||||
import '../../designs/GlassSurface.dart';
|
||||
import '../../designs/MemberAvatar.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import 'ConnectGroupChat.dart';
|
||||
import 'GroupChat.dart';
|
||||
import 'ViewGroupChat.dart';
|
||||
|
||||
class GroupChatState extends State<GroupChat> implements ConnectGroupChat {
|
||||
ViewGroupChat? _model;
|
||||
|
||||
List<GroupMessage> _messages = <GroupMessage>[];
|
||||
|
||||
final TextEditingController _input = TextEditingController();
|
||||
|
||||
Timer? _poll;
|
||||
|
||||
/// How often the open chat looks for new messages.
|
||||
static const Duration _pollEvery = Duration(seconds: 4);
|
||||
|
||||
static const List<String> _quickReplies = <String>[
|
||||
"Done for today",
|
||||
"On my way",
|
||||
"Need a push",
|
||||
"Who's in tomorrow?",
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_poll?.cancel();
|
||||
_input.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewGroupChat>.reactive(
|
||||
viewModelBuilder: () => ViewGroupChat(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {
|
||||
_model?.loadMessages(widget.group.id);
|
||||
_poll = Timer.periodic(_pollEvery, _onPoll);
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
void _onPoll(Timer timer) {
|
||||
_model?.refresh(widget.group.id);
|
||||
}
|
||||
|
||||
void _onSend(String value) {
|
||||
final String body = value.trim();
|
||||
if (body.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
_input.clear();
|
||||
_model?.send(GroupMessageRequest(groupId: widget.group.id, body: body));
|
||||
}
|
||||
|
||||
void _onSendPressed() {
|
||||
_onSend(_input.text);
|
||||
}
|
||||
|
||||
void _onQuickReply(String reply) {
|
||||
_onSend(reply);
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
final List<GroupMessage> newestFirst = _messages.reversed.toList();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorAmbientMid,
|
||||
body: AmbientBackground(
|
||||
child: Column(
|
||||
children: [
|
||||
_topBar(),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
itemCount: newestFirst.length,
|
||||
itemBuilder: (context, index) {
|
||||
final GroupMessage message = newestFirst[index];
|
||||
final GroupMessage? older = index + 1 < newestFirst.length
|
||||
? newestFirst[index + 1]
|
||||
: null;
|
||||
final bool newDay = older == null ||
|
||||
!_sameDay(older.sentAt, message.sentAt);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (newDay) _dayLabel(message.sentAt),
|
||||
_message(message, older),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_composer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _topBar() {
|
||||
final Color accent = goalColor(widget.group.colourHex);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[
|
||||
Color.lerp(accent, colorHeaderBottom, 0.55)!,
|
||||
colorHeaderBottom,
|
||||
],
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16, MediaQuery.of(context).padding.top + 10, 16, 14),
|
||||
child: Row(
|
||||
children: [
|
||||
glassCircle(CupertinoIcons.arrow_left, _onBack,
|
||||
size: 46, onDark: true),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(widget.group.name, 19, TextType.Medium,
|
||||
color: colorWhite,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 3),
|
||||
text(
|
||||
widget.group.members
|
||||
.map((member) => member.me ? "you" : member.name)
|
||||
.join(", "),
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorHeaderMuted,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
avatarStack(
|
||||
widget.group.members.map((member) => member.name).toList(), 30,
|
||||
max: 3, border: colorHeaderBottom),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dayLabel(DateTime? moment) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
color: colorWhite.withValues(alpha: 0.7),
|
||||
child: text(_dayName(moment), 11, TextType.Medium,
|
||||
color: colorInkSoft),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Text as bubbles, check-ins and nudges as cards in the thread, system
|
||||
/// notes as a quiet centred line.
|
||||
Widget _message(GroupMessage message, GroupMessage? older) {
|
||||
if (message.isSystem) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: text(
|
||||
"${message.mine ? "You" : message.authorName} ${message.body}",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorInkSoft,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (message.isCheckIn || message.isNudge) {
|
||||
return _eventCard(message);
|
||||
}
|
||||
|
||||
// Consecutive lines from one person share a single name and portrait.
|
||||
final bool grouped = older != null &&
|
||||
older.authorId == message.authorId &&
|
||||
!older.isSystem &&
|
||||
!older.isCheckIn &&
|
||||
!older.isNudge &&
|
||||
_sameDay(older.sentAt, message.sentAt);
|
||||
|
||||
if (message.mine) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: grouped ? 4 : 12, left: 64),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 11, 14, 10),
|
||||
color: colorNight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(message.body, 15, TextType.Regular,
|
||||
color: colorWhite, height: 1.4),
|
||||
const SizedBox(height: 4),
|
||||
text(_time(message.sentAt), 10, TextType.Regular,
|
||||
color: colorHeaderMuted),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final Color tone = _authorColour(message.authorName);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: grouped ? 4 : 12, right: 56),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
grouped
|
||||
? const SizedBox(width: 34)
|
||||
: memberAvatar(message.authorName, 34),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite,
|
||||
border: Border.all(color: colorGlassBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!grouped) ...[
|
||||
text(message.authorName, 12, TextType.Medium, color: tone),
|
||||
const SizedBox(height: 3),
|
||||
],
|
||||
text(message.body, 15, TextType.Regular,
|
||||
color: colorInk, height: 1.4),
|
||||
const SizedBox(height: 4),
|
||||
text(_time(message.sentAt), 10, TextType.Regular,
|
||||
color: colorInkSoft),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A check-in or a nudge, set apart from the talk so the work is visible.
|
||||
Widget _eventCard(GroupMessage message) {
|
||||
final bool checkIn = message.isCheckIn;
|
||||
final String who = message.mine ? "You" : message.authorName;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: checkIn ? colorStandingGoodBg : colorOverdueWarmEnd,
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: checkIn ? colorPositive : colorBarStandard,
|
||||
width: 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
memberAvatar(message.authorName, 34),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
checkIn
|
||||
? CupertinoIcons.checkmark_seal_fill
|
||||
: CupertinoIcons.hand_point_right_fill,
|
||||
size: 13,
|
||||
color: checkIn ? colorPositive : colorWarning,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
text(checkIn ? "CHECK-IN" : "NUDGE", 10, TextType.Bold,
|
||||
color: checkIn ? colorPositive : colorWarning,
|
||||
letterSpacing: 1.0),
|
||||
const Spacer(),
|
||||
text(_time(message.sentAt), 10, TextType.Regular,
|
||||
color: colorInkSoft),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
text("$who ${message.body}", 14, TextType.Regular,
|
||||
color: colorInk, height: 1.4),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _composer() {
|
||||
return Container(
|
||||
color: colorWhite,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
0, 10, 0, MediaQuery.of(context).padding.bottom + 10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 38,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _quickReplies.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) => GestureDetector(
|
||||
onTap: () => _onQuickReply(_quickReplies[index]),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
alignment: Alignment.center,
|
||||
color: colorAmbientMid,
|
||||
child: text(_quickReplies[index], 13, TextType.Regular,
|
||||
color: colorInk),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 10, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: quickAddField(_input, "Message the group", _onSend),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: _onSendPressed,
|
||||
child: Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
color: colorNight,
|
||||
child: Icon(CupertinoIcons.paperplane_fill,
|
||||
size: 20, color: colorLime),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
Color _authorColour(String name) {
|
||||
return goalAccents[sceneSeed(name) % goalAccents.length];
|
||||
}
|
||||
|
||||
bool _sameDay(DateTime? a, DateTime? b) {
|
||||
if (a == null || b == null) return false;
|
||||
final DateTime x = a.toLocal();
|
||||
final DateTime y = b.toLocal();
|
||||
return x.year == y.year && x.month == y.month && x.day == y.day;
|
||||
}
|
||||
|
||||
String _dayName(DateTime? moment) {
|
||||
if (moment == null) return "";
|
||||
final DateTime now = DateTime.now();
|
||||
if (_sameDay(moment, now)) return "Today";
|
||||
if (_sameDay(moment, now.subtract(const Duration(days: 1)))) {
|
||||
return "Yesterday";
|
||||
}
|
||||
return formatDate(moment.toLocal());
|
||||
}
|
||||
|
||||
String _time(DateTime? moment) {
|
||||
if (moment == null) return "";
|
||||
final DateTime local = moment.toLocal();
|
||||
return "${local.hour.toString().padLeft(2, "0")}:${local.minute.toString().padLeft(2, "0")}";
|
||||
}
|
||||
|
||||
// ── ConnectGroupChat ──────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onMessagesLoaded(List<GroupMessage> messages) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_messages = messages;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onMessageSent(GroupMessage message) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_messages = <GroupMessage>[..._messages, message];
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onSendFailed(String body) {
|
||||
if (!mounted) return;
|
||||
_input.text = body;
|
||||
}
|
||||
}
|
||||
13
frontend/lib/Grounded/see/group/GroupDetail.dart
Normal file
13
frontend/lib/Grounded/see/group/GroupDetail.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/external/data/Group.dart';
|
||||
import 'GroupDetailState.dart';
|
||||
|
||||
class GroupDetail extends StatefulWidget {
|
||||
final Group group;
|
||||
|
||||
const GroupDetail({super.key, required this.group});
|
||||
|
||||
@override
|
||||
State<GroupDetail> createState() => GroupDetailState();
|
||||
}
|
||||
708
frontend/lib/Grounded/see/group/GroupDetailState.dart
Normal file
708
frontend/lib/Grounded/see/group/GroupDetailState.dart
Normal file
@@ -0,0 +1,708 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Group.dart';
|
||||
import '../../about/external/data/GroupGoal.dart';
|
||||
import '../../about/external/data/GroupMember.dart';
|
||||
import '../../about/external/data/GroupObjective.dart';
|
||||
import '../../about/external/initial/NudgeRequest.dart';
|
||||
import '../../about/external/initial/ObjectiveProgressRequest.dart';
|
||||
import '../../about/internal/application/NotificationType.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../configs/Navigator.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/MemberAvatar.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import 'ConnectGroupDetail.dart';
|
||||
import 'GroupChat.dart';
|
||||
import 'GroupDetail.dart';
|
||||
import 'GroupGoalEditor.dart';
|
||||
import 'ViewGroupDetail.dart';
|
||||
|
||||
class GroupDetailState extends State<GroupDetail>
|
||||
implements ConnectGroupDetail {
|
||||
ViewGroupDetail? _model;
|
||||
|
||||
late Group _group;
|
||||
|
||||
/// Members nudged from this screen, so the button reads "Nudged" after.
|
||||
final Set<String> _nudged = <String>{};
|
||||
|
||||
final TextEditingController _amount = TextEditingController();
|
||||
|
||||
final TextEditingController _note = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_group = widget.group;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amount.dispose();
|
||||
_note.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewGroupDetail>.reactive(
|
||||
viewModelBuilder: () => ViewGroupDetail(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {
|
||||
_model?.loadGroup(widget.group.id);
|
||||
}
|
||||
|
||||
Color get _accent => goalColor(_group.colourHex);
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
void _onOpenChat() async {
|
||||
await GroundedNavigation()
|
||||
.navigateToPageWithData(GroupChat(group: _group), context);
|
||||
_model?.loadGroup(_group.id, quiet: true);
|
||||
}
|
||||
|
||||
void _onAddGoal() async {
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(GroupGoalEditor(group: _group), context);
|
||||
|
||||
if (result == true) {
|
||||
_model?.loadGroup(_group.id, quiet: true);
|
||||
}
|
||||
}
|
||||
|
||||
void _onCopyCode() async {
|
||||
await Clipboard.setData(ClipboardData(text: _group.inviteCode));
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.success,
|
||||
"Code copied",
|
||||
"Send ${_group.inviteCode} to whoever should join. They enter it under Groups → Join with a code.",
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
void _onNudge(GroupMember member) {
|
||||
_model?.nudge(
|
||||
NudgeRequest(groupId: _group.id, memberId: member.id), member.name);
|
||||
}
|
||||
|
||||
void _onNudgeEveryone() {
|
||||
_model?.nudge(NudgeRequest(groupId: _group.id), "everyone");
|
||||
}
|
||||
|
||||
void _onLog(GroupGoal goal, GroupObjective objective) {
|
||||
_amount.text = "";
|
||||
_note.text = "";
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colorWhite,
|
||||
builder: (sheetContext) => _logSheet(sheetContext, goal, objective),
|
||||
);
|
||||
}
|
||||
|
||||
void _onQuickAmount(double value) {
|
||||
_amount.text = formatAmount(value).replaceAll(",", "");
|
||||
}
|
||||
|
||||
void _onSubmitLog(BuildContext sheetContext, GroupGoal goal,
|
||||
GroupObjective objective) {
|
||||
final double amount = double.tryParse(_amount.text.trim()) ?? 0;
|
||||
if (amount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.pop(sheetContext);
|
||||
|
||||
_model?.logProgress(
|
||||
ObjectiveProgressRequest(
|
||||
groupId: _group.id,
|
||||
goalId: goal.id,
|
||||
objectiveId: objective.id,
|
||||
amount: amount,
|
||||
note: _note.text.trim(),
|
||||
),
|
||||
"${formatAmount(amount)} ${objective.unit} on ${objective.title}",
|
||||
);
|
||||
}
|
||||
|
||||
void _onLeave() {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.warning,
|
||||
"Leave ${_group.name}?",
|
||||
"You stop seeing the shared goals and the chat. What you logged stays in the group's totals.",
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
action: "Leave",
|
||||
positiveAction: _onConfirmLeave,
|
||||
);
|
||||
}
|
||||
|
||||
void _onConfirmLeave() {
|
||||
Navigator.pop(context);
|
||||
_model?.leave(_group.id);
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
final List<GroupMember> ranked = List<GroupMember>.from(_group.members)
|
||||
..sort((a, b) => b.keptRate.compareTo(a.keptRate));
|
||||
|
||||
return Sheet(
|
||||
eyebrow: "${_group.members.length} members",
|
||||
title: _group.name,
|
||||
onBack: _onBack,
|
||||
scene: goalScene(_accent),
|
||||
sceneSeed: sceneSeed(_group.name),
|
||||
action: chromeAction(CupertinoIcons.chat_bubble_2_fill, _onOpenChat),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_group.description.isNotEmpty) ...[
|
||||
text(_group.description, 15, TextType.Regular,
|
||||
color: colorGrey2, height: 1.55),
|
||||
const SizedBox(height: 22),
|
||||
],
|
||||
_statsRow(),
|
||||
const SizedBox(height: 20),
|
||||
_chatCard(),
|
||||
const SizedBox(height: 30),
|
||||
sectionBreak(
|
||||
"Shared goals",
|
||||
caption: "${_group.goals.length} on the go",
|
||||
trailing: textButton("+ Add", _onAddGoal,
|
||||
color: colorInk, textSize: 14, type: TextType.Medium),
|
||||
),
|
||||
if (_group.goals.isEmpty)
|
||||
emptyState(
|
||||
CupertinoIcons.flag,
|
||||
"Nothing shared yet",
|
||||
"Add a goal and split it into objectives everyone can log toward, like kilometres, pages or sessions.",
|
||||
)
|
||||
else
|
||||
..._group.goals.map(_goalCard),
|
||||
const SizedBox(height: 30),
|
||||
sectionBreak("Leaderboard",
|
||||
caption: "kept, last 7 days",
|
||||
trailing: textButton("Nudge all", _onNudgeEveryone,
|
||||
color: colorInk, textSize: 14, type: TextType.Medium)),
|
||||
for (int index = 0; index < ranked.length; index++)
|
||||
_memberRow(index + 1, ranked[index]),
|
||||
const SizedBox(height: 26),
|
||||
Center(
|
||||
child: textButton("Leave group", _onLeave,
|
||||
color: colorDestructive, textSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statsRow() {
|
||||
final double average = _group.members.isEmpty
|
||||
? 0
|
||||
: _group.members
|
||||
.map((member) => member.keptRate)
|
||||
.reduce((a, b) => a + b) /
|
||||
_group.members.length;
|
||||
|
||||
return card(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 12, 18),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: labelled("Together", "${(_group.progress * 100).round()}%",
|
||||
valueSize: 26, valueType: TextType.Light),
|
||||
),
|
||||
Expanded(
|
||||
child: labelled("Kept avg", "${(average * 100).round()}%",
|
||||
valueSize: 26, valueType: TextType.Light),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: _onCopyCode,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
color: colorAmbientMid,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text("INVITE", 9, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.0),
|
||||
const SizedBox(width: 6),
|
||||
Icon(CupertinoIcons.doc_on_doc,
|
||||
size: 12, color: colorGrey2),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
text(_group.inviteCode, 18, TextType.Medium,
|
||||
color: colorInk, letterSpacing: 1.5),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The way into the chat: the last line said and how much is unread.
|
||||
Widget _chatCard() {
|
||||
return GestureDetector(
|
||||
onTap: _onOpenChat,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 16, 18),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[colorNightLift, colorNight],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Color(0x332A3A30),
|
||||
blurRadius: 24,
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(CupertinoIcons.chat_bubble_2_fill,
|
||||
size: 16, color: colorLime),
|
||||
const SizedBox(width: 8),
|
||||
text("Group chat", 14, TextType.Medium,
|
||||
color: colorLime),
|
||||
if (_group.unread > 0) ...[
|
||||
const SizedBox(width: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 7, vertical: 3),
|
||||
color: colorLime,
|
||||
child: text("${_group.unread} new", 10,
|
||||
TextType.Medium,
|
||||
color: colorNight),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
text(
|
||||
_group.lastMessage.isEmpty
|
||||
? "Say hello. Check-ins and nudges show up here too."
|
||||
: "${_group.lastAuthor}: ${_group.lastMessage}",
|
||||
15,
|
||||
TextType.Regular,
|
||||
color: colorWhite,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
height: 1.45,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
color: colorWhite.withValues(alpha: 0.12),
|
||||
child: Icon(CupertinoIcons.arrow_right,
|
||||
size: 20, color: colorWhite),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A shared goal: its overall bar, then each objective with the group's
|
||||
/// total, your share, and a button to log more.
|
||||
Widget _goalCard(GroupGoal goal) {
|
||||
final Color tone =
|
||||
goal.colourHex.isEmpty ? _accent : goalColor(goal.colourHex);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
child: card(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(height: 6, color: tone),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: text(goal.title, 19, TextType.Medium,
|
||||
color: colorInk),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
text("${(goal.progress * 100).round()}%", 22,
|
||||
TextType.Light,
|
||||
color: colorInk),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
text(
|
||||
[
|
||||
"${goal.completeObjectives}/${goal.objectives.length} objectives met",
|
||||
goal.targetDate == null
|
||||
? "no deadline"
|
||||
: targetLabel(goal.targetDate),
|
||||
].join(" · "),
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
meter(goal.progress,
|
||||
fill: tone, track: tone.withValues(alpha: 0.14), height: 6),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (GroupObjective objective in goal.objectives)
|
||||
_objectiveRow(goal, objective, tone),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _objectiveRow(GroupGoal goal, GroupObjective objective, Color tone) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 14, 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: colorBorder)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (objective.complete) ...[
|
||||
Icon(CupertinoIcons.checkmark_seal_fill,
|
||||
size: 15, color: colorPositive),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Flexible(
|
||||
child: text(objective.title, 15, TextType.Medium,
|
||||
color: colorInk,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
text(
|
||||
"${formatAmount(objective.current)} / ${formatAmount(objective.target)} ${objective.unit}",
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
meter(objective.progress,
|
||||
fill: tone, track: tone.withValues(alpha: 0.12), height: 4),
|
||||
const SizedBox(height: 6),
|
||||
text("You: ${formatAmount(objective.mine)} ${objective.unit}",
|
||||
12, TextType.Medium,
|
||||
color: tone),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
GestureDetector(
|
||||
onTap: () => _onLog(goal, objective),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
color: colorNight,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(CupertinoIcons.add, size: 16, color: colorLime),
|
||||
const SizedBox(width: 6),
|
||||
text("Log", 14, TextType.Medium, color: colorWhite),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _memberRow(int rank, GroupMember member) {
|
||||
final bool nudged = _nudged.contains(member.id);
|
||||
final bool quiet = member.lastCheckIn == null ||
|
||||
DateTime.now().difference(member.lastCheckIn!).inHours >= 24;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: card(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 12, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: text("$rank", 16, TextType.Medium,
|
||||
color: rank == 1 ? colorInk : colorGrey2),
|
||||
),
|
||||
memberAvatar(member.name, 46),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: text(
|
||||
member.me ? "${member.name} (you)" : member.name,
|
||||
16,
|
||||
TextType.Medium,
|
||||
color: colorInk,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
if (member.owner) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(CupertinoIcons.star_fill,
|
||||
size: 12, color: colorBarStandard),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(CupertinoIcons.flame_fill,
|
||||
size: 12, color: colorBarNonNegotiable),
|
||||
const SizedBox(width: 3),
|
||||
text("${member.streak}d", 12, TextType.Regular,
|
||||
color: colorGrey2),
|
||||
const SizedBox(width: 10),
|
||||
text(
|
||||
quiet
|
||||
? "quiet ${member.lastCheckIn == null ? "" : _ago(member.lastCheckIn!)}"
|
||||
: "checked in ${_ago(member.lastCheckIn!)}",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: quiet ? colorStandingGrounded : colorGrey2,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
text("${(member.keptRate * 100).round()}%", 20, TextType.Light,
|
||||
color: colorInk),
|
||||
if (!member.me) ...[
|
||||
const SizedBox(width: 10),
|
||||
GestureDetector(
|
||||
onTap: nudged ? null : () => _onNudge(member),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
color: nudged ? colorAmbientMid : colorNight,
|
||||
child: Icon(
|
||||
nudged
|
||||
? CupertinoIcons.checkmark
|
||||
: CupertinoIcons.hand_point_right_fill,
|
||||
size: 18,
|
||||
color: nudged ? colorGrey2 : colorLime,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Log progress: an amount (with one-tap presets that suit the unit) and
|
||||
/// an optional note that rides along into the chat.
|
||||
Widget _logSheet(BuildContext sheetContext, GroupGoal goal,
|
||||
GroupObjective objective) {
|
||||
final List<double> presets = _presets(objective.unit);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 16, 20,
|
||||
MediaQuery.of(sheetContext).viewInsets.bottom + 28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Center(child: Container(width: 40, height: 4, color: colorGrabber)),
|
||||
const SizedBox(height: 18),
|
||||
text("LOG PROGRESS", 10, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.2),
|
||||
const SizedBox(height: 6),
|
||||
text(objective.title, 24, TextType.Medium, color: colorInk),
|
||||
const SizedBox(height: 4),
|
||||
text(
|
||||
"${formatAmount(objective.current)} of ${formatAmount(objective.target)} ${objective.unit} so far",
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: colorGrey2),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
children: [
|
||||
for (double preset in presets) ...[
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => _onQuickAmount(preset),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
alignment: Alignment.center,
|
||||
color: colorAmbientMid,
|
||||
child: text("+${formatAmount(preset)}", 15,
|
||||
TextType.Medium,
|
||||
color: colorInk),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (preset != presets.last) const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
inputField(
|
||||
"Amount (${objective.unit})",
|
||||
_amount,
|
||||
hint: "How much?",
|
||||
keyboard: const TextInputType.numberWithOptions(decimal: true),
|
||||
formatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.allow(RegExp(r"[0-9.]")),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
inputField("Note", _note, hint: "Optional: posted with the check-in"),
|
||||
const SizedBox(height: 22),
|
||||
roundedCornerButton("Log it",
|
||||
() => _onSubmitLog(sheetContext, goal, objective),
|
||||
icon: CupertinoIcons.checkmark),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<double> _presets(String unit) {
|
||||
switch (unit.toLowerCase()) {
|
||||
case "steps":
|
||||
return <double>[1000, 5000, 10000];
|
||||
case "words":
|
||||
return <double>[250, 500, 1000];
|
||||
case "hours":
|
||||
return <double>[0.5, 1, 2];
|
||||
case "km":
|
||||
return <double>[3, 5, 10];
|
||||
default:
|
||||
return <double>[1, 2, 5];
|
||||
}
|
||||
}
|
||||
|
||||
String _ago(DateTime moment) {
|
||||
final Duration gap = DateTime.now().difference(moment);
|
||||
if (gap.inMinutes < 60) return "${gap.inMinutes.clamp(1, 59)}m ago";
|
||||
if (gap.inHours < 24) return "${gap.inHours}h ago";
|
||||
return "${gap.inDays}d ago";
|
||||
}
|
||||
|
||||
// ── ConnectGroupDetail ────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onGroupLoaded(Group group) {
|
||||
setState(() {
|
||||
_group = group;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onProgressLogged(Group group, String summary) {
|
||||
setState(() {
|
||||
_group = group;
|
||||
});
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.success,
|
||||
"Logged",
|
||||
"$summary. The group sees it in the chat.",
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onNudged(String who) {
|
||||
setState(() {
|
||||
for (GroupMember member in _group.members) {
|
||||
if (who == "everyone" ? !member.me : member.name == who) {
|
||||
_nudged.add(member.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onLeft() {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
}
|
||||
12
frontend/lib/Grounded/see/group/GroupEditor.dart
Normal file
12
frontend/lib/Grounded/see/group/GroupEditor.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'GroupEditorState.dart';
|
||||
|
||||
class GroupEditor extends StatefulWidget {
|
||||
final bool joining;
|
||||
|
||||
const GroupEditor({super.key, this.joining = false});
|
||||
|
||||
@override
|
||||
State<GroupEditor> createState() => GroupEditorState();
|
||||
}
|
||||
293
frontend/lib/Grounded/see/group/GroupEditorState.dart
Normal file
293
frontend/lib/Grounded/see/group/GroupEditorState.dart
Normal file
@@ -0,0 +1,293 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Group.dart';
|
||||
import '../../about/external/initial/GroupRequest.dart';
|
||||
import '../../about/external/initial/JoinGroupRequest.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/MemberAvatar.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/Validators.dart';
|
||||
import 'ConnectGroupEditor.dart';
|
||||
import 'GroupEditor.dart';
|
||||
import 'ViewGroupEditor.dart';
|
||||
|
||||
class GroupEditorState extends State<GroupEditor>
|
||||
implements ConnectGroupEditor {
|
||||
ViewGroupEditor? _model;
|
||||
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
|
||||
final TextEditingController _name = TextEditingController();
|
||||
|
||||
final TextEditingController _description = TextEditingController();
|
||||
|
||||
final TextEditingController _code = TextEditingController();
|
||||
|
||||
Color _accent = goalAccents[1];
|
||||
|
||||
bool _joining = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_joining = widget.joining;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
_description.dispose();
|
||||
_code.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewGroupEditor>.reactive(
|
||||
viewModelBuilder: () => ViewGroupEditor(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context, false);
|
||||
}
|
||||
|
||||
void _onModeSelected(String mode) {
|
||||
setState(() {
|
||||
_joining = mode == "Join";
|
||||
});
|
||||
}
|
||||
|
||||
void _onNameChanged(String value) {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onAccentSelected(Color colour) {
|
||||
setState(() {
|
||||
_accent = colour;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSubmit() {
|
||||
if (_formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_joining) {
|
||||
_model?.join(JoinGroupRequest(inviteCode: _code.text.trim().toUpperCase()));
|
||||
} else {
|
||||
_model?.create(GroupRequest(
|
||||
name: _name.text.trim(),
|
||||
description: _description.text.trim(),
|
||||
colourHex: colorToHex(_accent),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
String? _validateCode(String? value) {
|
||||
final String code = (value ?? "").trim();
|
||||
if (code.length != 6) {
|
||||
return "Invite codes are six characters.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
return Sheet(
|
||||
eyebrow: _joining ? "Join" : "New",
|
||||
title: "Group",
|
||||
onBack: _onBack,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
displayTitle(_joining
|
||||
? "Got a code?"
|
||||
: "Who's keeping\nyou honest?"),
|
||||
const SizedBox(height: 22),
|
||||
segmentedSelector<String>(
|
||||
options: const <String>["Create", "Join"],
|
||||
selected: _joining ? "Join" : "Create",
|
||||
label: _modeLabel,
|
||||
onSelected: _onModeSelected,
|
||||
activeColor: colorNight,
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
if (_joining) ..._joinFields() else ..._createFields(),
|
||||
const SizedBox(height: 32),
|
||||
roundedCornerButton(
|
||||
_joining ? "Join group" : "Create group",
|
||||
_onSubmit,
|
||||
icon: _joining ? CupertinoIcons.arrow_right : CupertinoIcons.add,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _modeLabel(String mode) => mode;
|
||||
|
||||
List<Widget> _createFields() {
|
||||
return <Widget>[
|
||||
_preview(),
|
||||
const SizedBox(height: 26),
|
||||
inputField(
|
||||
"Group name",
|
||||
_name,
|
||||
hint: "Morning Crew, Thesis Sprint…",
|
||||
validator: Validators.title,
|
||||
onChanged: _onNameChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
inputField(
|
||||
"What it's for",
|
||||
_description,
|
||||
hint: "Optional: the promise you're all making",
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
sectionBreak("Colour", caption: "the group's banner"),
|
||||
_swatches(),
|
||||
const SizedBox(height: 14),
|
||||
text(
|
||||
"You'll get a six-letter invite code to send to the others once it's created.",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _joinFields() {
|
||||
return <Widget>[
|
||||
inputField(
|
||||
"Invite code",
|
||||
_code,
|
||||
hint: "6 characters, like MRN7AM",
|
||||
validator: _validateCode,
|
||||
icon: CupertinoIcons.ticket,
|
||||
formatters: <TextInputFormatter>[
|
||||
LengthLimitingTextInputFormatter(6),
|
||||
FilteringTextInputFormatter.allow(RegExp(r"[A-Za-z0-9]")),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
text(
|
||||
"Ask whoever started the group: the code is on the group's page. Joining shows you their shared goals and the chat straight away.",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// The banner as it will look, so the colour is chosen by seeing it.
|
||||
Widget _preview() {
|
||||
final String name =
|
||||
_name.text.trim().isEmpty ? "Your group" : _name.text.trim();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[_accent, Color.lerp(_accent, colorBlack, 0.45)!],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(CupertinoIcons.person_3_fill,
|
||||
size: 16, color: colorWhite.withValues(alpha: 0.7)),
|
||||
const SizedBox(width: 8),
|
||||
text("1 MEMBER", 9, TextType.Bold,
|
||||
color: colorWhite.withValues(alpha: 0.6),
|
||||
letterSpacing: 1.2),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
text(name, 26, TextType.Light,
|
||||
color: colorWhite, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 16),
|
||||
avatarStack(const <String>["Alex"], 34),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _swatches() {
|
||||
return Wrap(
|
||||
spacing: 9,
|
||||
runSpacing: 9,
|
||||
children: goalAccents.map((colour) {
|
||||
final bool selected = colour.toARGB32() == _accent.toARGB32();
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onAccentSelected(colour),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colour,
|
||||
border: Border.all(
|
||||
color: selected ? colorWhite : Colors.transparent,
|
||||
width: 3,
|
||||
),
|
||||
boxShadow: selected
|
||||
? [BoxShadow(color: colour, spreadRadius: 1.5)]
|
||||
: null,
|
||||
),
|
||||
child: selected
|
||||
? Icon(Icons.check_rounded, size: 18, color: colorWhite)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectGroupEditor ────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onGroupReady(Group group) {
|
||||
Navigator.pop(context, group);
|
||||
}
|
||||
}
|
||||
13
frontend/lib/Grounded/see/group/GroupGoalEditor.dart
Normal file
13
frontend/lib/Grounded/see/group/GroupGoalEditor.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../about/external/data/Group.dart';
|
||||
import 'GroupGoalEditorState.dart';
|
||||
|
||||
class GroupGoalEditor extends StatefulWidget {
|
||||
final Group group;
|
||||
|
||||
const GroupGoalEditor({super.key, required this.group});
|
||||
|
||||
@override
|
||||
State<GroupGoalEditor> createState() => GroupGoalEditorState();
|
||||
}
|
||||
317
frontend/lib/Grounded/see/group/GroupGoalEditorState.dart
Normal file
317
frontend/lib/Grounded/see/group/GroupGoalEditorState.dart
Normal file
@@ -0,0 +1,317 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/initial/GroupGoalRequest.dart';
|
||||
import '../../about/external/initial/ObjectiveDraft.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import '../../utils/Validators.dart';
|
||||
import 'ConnectGroupGoalEditor.dart';
|
||||
import 'GroupGoalEditor.dart';
|
||||
import 'ViewGroupGoalEditor.dart';
|
||||
|
||||
/// The three fields of one objective while it is being typed.
|
||||
typedef _ObjectiveFields = ({
|
||||
TextEditingController title,
|
||||
TextEditingController target,
|
||||
TextEditingController unit,
|
||||
});
|
||||
|
||||
class GroupGoalEditorState extends State<GroupGoalEditor>
|
||||
implements ConnectGroupGoalEditor {
|
||||
ViewGroupGoalEditor? _model;
|
||||
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
|
||||
final TextEditingController _title = TextEditingController();
|
||||
|
||||
final TextEditingController _description = TextEditingController();
|
||||
|
||||
final List<_ObjectiveFields> _objectives = <_ObjectiveFields>[];
|
||||
|
||||
DateTime? _targetDate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_objectives.add(_blankObjective());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
_description.dispose();
|
||||
for (_ObjectiveFields fields in _objectives) {
|
||||
fields.title.dispose();
|
||||
fields.target.dispose();
|
||||
fields.unit.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewGroupGoalEditor>.reactive(
|
||||
viewModelBuilder: () => ViewGroupGoalEditor(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {}
|
||||
|
||||
_ObjectiveFields _blankObjective() {
|
||||
return (
|
||||
title: TextEditingController(),
|
||||
target: TextEditingController(),
|
||||
unit: TextEditingController(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context, false);
|
||||
}
|
||||
|
||||
void _onAddObjective() {
|
||||
setState(() {
|
||||
_objectives.add(_blankObjective());
|
||||
});
|
||||
}
|
||||
|
||||
void _onRemoveObjective(_ObjectiveFields fields) {
|
||||
setState(() {
|
||||
_objectives.remove(fields);
|
||||
});
|
||||
fields.title.dispose();
|
||||
fields.target.dispose();
|
||||
fields.unit.dispose();
|
||||
}
|
||||
|
||||
void _onPickTarget() async {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _targetDate ?? now.add(const Duration(days: 30)),
|
||||
firstDate: now,
|
||||
lastDate: now.add(const Duration(days: 365 * 3)),
|
||||
);
|
||||
|
||||
if (picked == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_targetDate = picked;
|
||||
});
|
||||
}
|
||||
|
||||
void _onClearTarget() {
|
||||
setState(() {
|
||||
_targetDate = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSave() {
|
||||
if (_formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
_model?.save(GroupGoalRequest(
|
||||
groupId: widget.group.id,
|
||||
title: _title.text.trim(),
|
||||
description: _description.text.trim(),
|
||||
targetDate: _targetDate?.toIso8601String() ?? "",
|
||||
colourHex: widget.group.colourHex,
|
||||
objectives: _objectives
|
||||
.map((fields) => ObjectiveDraft(
|
||||
title: fields.title.text.trim(),
|
||||
unit: fields.unit.text.trim(),
|
||||
target: double.tryParse(fields.target.text.trim()) ?? 0,
|
||||
))
|
||||
.toList(),
|
||||
));
|
||||
}
|
||||
|
||||
String? _validateTarget(String? value) {
|
||||
final double? target = double.tryParse((value ?? "").trim());
|
||||
if (target == null || target <= 0) {
|
||||
return "A number above 0.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateUnit(String? value) {
|
||||
return Validators.required(value, "Unit");
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
return Sheet(
|
||||
eyebrow: widget.group.name,
|
||||
title: "Shared goal",
|
||||
onBack: _onBack,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
displayTitle("What are you\nall going for?"),
|
||||
const SizedBox(height: 24),
|
||||
inputField(
|
||||
"Goal",
|
||||
_title,
|
||||
hint: "Run 200 km this month, finish the drafts…",
|
||||
validator: Validators.title,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
inputField(
|
||||
"Why it matters",
|
||||
_description,
|
||||
hint: "Optional",
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Objectives",
|
||||
caption: "what everyone logs toward"),
|
||||
text(
|
||||
"Break the goal into numbers people can add to: kilometres, sessions, pages, hours. Each member logs their share and the totals add up.",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.5,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
for (int index = 0; index < _objectives.length; index++)
|
||||
_objectiveEditor(index, _objectives[index]),
|
||||
outlinedActionButton("Add objective", _onAddObjective,
|
||||
icon: CupertinoIcons.add),
|
||||
const SizedBox(height: 28),
|
||||
sectionBreak("Deadline", caption: "optional"),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: selectField(
|
||||
"Target date",
|
||||
_targetDate == null ? "" : formatDate(_targetDate),
|
||||
_onPickTarget,
|
||||
hint: "No deadline",
|
||||
icon: CupertinoIcons.calendar,
|
||||
),
|
||||
),
|
||||
if (_targetDate != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
iconButton(
|
||||
Icon(CupertinoIcons.xmark, size: 15, color: colorGrey2),
|
||||
_onClearTarget,
|
||||
bordered: true,
|
||||
background: colorWhite,
|
||||
size: 50,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
roundedCornerButton("Share with the group", _onSave,
|
||||
icon: CupertinoIcons.person_3_fill),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _objectiveEditor(int index, _ObjectiveFields fields) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: card(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
text("OBJECTIVE ${index + 1}", 10, TextType.Bold,
|
||||
color: colorGrey2, letterSpacing: 1.0),
|
||||
const Spacer(),
|
||||
if (_objectives.length > 1)
|
||||
iconButton(
|
||||
Icon(CupertinoIcons.xmark, size: 15, color: colorGrey2),
|
||||
() => _onRemoveObjective(fields),
|
||||
size: 36,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
inputField("Name", fields.title,
|
||||
hint: "Distance run", validator: Validators.title),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: inputField(
|
||||
"Target",
|
||||
fields.target,
|
||||
hint: "200",
|
||||
validator: _validateTarget,
|
||||
keyboard: const TextInputType.numberWithOptions(
|
||||
decimal: true),
|
||||
formatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.allow(RegExp(r"[0-9.]")),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: inputField("Unit", fields.unit,
|
||||
hint: "km", validator: _validateUnit),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectGroupGoalEditor ────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onGoalSaved() {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
}
|
||||
10
frontend/lib/Grounded/see/group/Groups.dart
Normal file
10
frontend/lib/Grounded/see/group/Groups.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'GroupsState.dart';
|
||||
|
||||
class Groups extends StatefulWidget {
|
||||
const Groups({super.key});
|
||||
|
||||
@override
|
||||
State<Groups> createState() => GroupsState();
|
||||
}
|
||||
241
frontend/lib/Grounded/see/group/GroupsState.dart
Normal file
241
frontend/lib/Grounded/see/group/GroupsState.dart
Normal file
@@ -0,0 +1,241 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Group.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../configs/Navigator.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/GlassSurface.dart';
|
||||
import '../../designs/MemberAvatar.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
import '../../designs/buttons/Buttons.dart';
|
||||
import '../../designs/scene/SceneArt.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import 'ConnectGroups.dart';
|
||||
import 'GroupDetail.dart';
|
||||
import 'GroupEditor.dart';
|
||||
import 'Groups.dart';
|
||||
import 'ViewGroups.dart';
|
||||
|
||||
class GroupsState extends State<Groups> implements ConnectGroups {
|
||||
ViewGroups? _model;
|
||||
|
||||
List<Group> _groups = <Group>[];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewGroups>.reactive(
|
||||
viewModelBuilder: () => ViewGroups(context, this),
|
||||
onViewModelReady: (viewModel) {
|
||||
_model = viewModel;
|
||||
_initiate();
|
||||
},
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Responsive(
|
||||
mobile: _mobileView(constraints),
|
||||
tablet: _mobileView(constraints),
|
||||
desktop: _mobileView(constraints),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initiate() {
|
||||
_model?.loadGroups();
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onBack() {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
void _onOpenGroup(Group group) async {
|
||||
await GroundedNavigation()
|
||||
.navigateToPageWithData(GroupDetail(group: group), context);
|
||||
_model?.loadGroups();
|
||||
}
|
||||
|
||||
void _onNewGroup() {
|
||||
_openEditor(false);
|
||||
}
|
||||
|
||||
void _onJoinGroup() {
|
||||
_openEditor(true);
|
||||
}
|
||||
|
||||
/// Created or joined, you land straight in the group.
|
||||
void _openEditor(bool joining) async {
|
||||
final result = await GroundedNavigation()
|
||||
.navigateToPageWithData(GroupEditor(joining: joining), context);
|
||||
|
||||
if (result is Group) {
|
||||
_model?.loadGroups();
|
||||
_onOpenGroup(result);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _mobileView(BoxConstraints constraints) {
|
||||
return Sheet(
|
||||
eyebrow: "Grounded",
|
||||
title: "Groups",
|
||||
onBack: _onBack,
|
||||
action: chromeAction(CupertinoIcons.add, _onNewGroup),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
displayTitle("Keep each other\nhonest."),
|
||||
const SizedBox(height: 14),
|
||||
text(
|
||||
"Share goals with people who will notice when you slip. Everyone logs toward the same objectives, and the chat sees every check-in.",
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: colorGrey2,
|
||||
height: 1.55,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
if (_groups.isEmpty)
|
||||
emptyState(
|
||||
CupertinoIcons.person_3,
|
||||
"No groups yet",
|
||||
"Start one and send the code to a friend, or join with a code someone sent you.",
|
||||
)
|
||||
else
|
||||
..._groups.map(_groupCard),
|
||||
const SizedBox(height: 24),
|
||||
roundedCornerButton("New group", _onNewGroup,
|
||||
icon: CupertinoIcons.add),
|
||||
const SizedBox(height: 10),
|
||||
outlinedActionButton("Join with a code", _onJoinGroup,
|
||||
icon: CupertinoIcons.ticket),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The group as a painted card: who is in it, how the shared goals are
|
||||
/// going, and the latest line from the chat.
|
||||
Widget _groupCard(Group group) {
|
||||
final Color accent = goalColor(group.colourHex);
|
||||
final Color soft = colorWhite.withValues(alpha: 0.78);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onOpenGroup(group),
|
||||
child: Container(
|
||||
height: 250,
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
decoration: const BoxDecoration(
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Color(0x262A3A30),
|
||||
blurRadius: 24,
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
SceneArt(
|
||||
palette: goalScene(accent),
|
||||
seed: sceneSeed(group.name),
|
||||
figure: CupertinoIcons.person_3_fill,
|
||||
figureScale: 0.42,
|
||||
figureAlignment: const Alignment(0.7, -0.4),
|
||||
shade: true,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
GlassSurface(
|
||||
onDark: true,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
child: text(
|
||||
"${group.members.length} members · ${group.goals.length} shared goal${group.goals.length == 1 ? "" : "s"}",
|
||||
11,
|
||||
TextType.Medium,
|
||||
color: colorWhite,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (group.unread > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
color: colorLime,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(CupertinoIcons.chat_bubble_fill,
|
||||
size: 12, color: colorNight),
|
||||
const SizedBox(width: 6),
|
||||
text("${group.unread} new", 11, TextType.Medium,
|
||||
color: colorNight),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
text(group.name, 24, TextType.Medium,
|
||||
color: colorWhite,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
if (group.lastMessage.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
text("${group.lastAuthor}: ${group.lastMessage}", 13,
|
||||
TextType.Regular,
|
||||
color: soft,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
meter(group.progress,
|
||||
fill: colorLime,
|
||||
track: colorWhite.withValues(alpha: 0.25),
|
||||
height: 6),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
avatarStack(
|
||||
group.members.map((member) => member.name).toList(),
|
||||
32),
|
||||
const Spacer(),
|
||||
text("${(group.progress * 100).round()}%", 22,
|
||||
TextType.Light,
|
||||
color: colorWhite),
|
||||
const SizedBox(width: 6),
|
||||
text("together", 12, TextType.Regular, color: soft),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectGroups ─────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onGroupsLoaded(List<Group> groups) {
|
||||
setState(() {
|
||||
_groups = groups;
|
||||
});
|
||||
}
|
||||
}
|
||||
53
frontend/lib/Grounded/see/group/ViewGroupChat.dart
Normal file
53
frontend/lib/Grounded/see/group/ViewGroupChat.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import '../../about/external/data/GroupMessage.dart';
|
||||
import '../../about/external/initial/GroupMessageRequest.dart';
|
||||
import '../../about/external/initial/IdRequest.dart';
|
||||
import '../../utils/ObjectConvertors.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectGroupChat.dart';
|
||||
|
||||
class ViewGroupChat extends ParentViewModel {
|
||||
ConnectGroupChat connection;
|
||||
|
||||
ViewGroupChat(super.context, this.connection);
|
||||
|
||||
void loadMessages(String groupId) async {
|
||||
if (!await hasNetwork(() => loadMessages(groupId))) return;
|
||||
|
||||
showLoading("Opening the chat");
|
||||
|
||||
try {
|
||||
final response =
|
||||
await getDataManager().getGroupMessages(IdRequest(id: groupId));
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onMessagesLoaded(getGroupMessageList(response.data));
|
||||
} catch (e) {
|
||||
handleError(e, () => loadMessages(groupId), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// The background refresh while the chat is open. Silent either way — a
|
||||
/// missed poll is picked up by the next one.
|
||||
void refresh(String groupId) async {
|
||||
try {
|
||||
final response =
|
||||
await getDataManager().getGroupMessages(IdRequest(id: groupId));
|
||||
|
||||
connection.onMessagesLoaded(getGroupMessageList(response.data));
|
||||
} catch (e) {
|
||||
// Next tick tries again.
|
||||
}
|
||||
}
|
||||
|
||||
void send(GroupMessageRequest request) async {
|
||||
try {
|
||||
final response = await getDataManager().sendGroupMessage(request);
|
||||
|
||||
connection.onMessageSent(GroupMessage.fromJson(response.data));
|
||||
} catch (e) {
|
||||
connection.onSendFailed(request.body);
|
||||
handleError(e, () => send(request), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
77
frontend/lib/Grounded/see/group/ViewGroupDetail.dart
Normal file
77
frontend/lib/Grounded/see/group/ViewGroupDetail.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import '../../about/external/data/Group.dart';
|
||||
import '../../about/external/initial/IdRequest.dart';
|
||||
import '../../about/external/initial/NudgeRequest.dart';
|
||||
import '../../about/external/initial/ObjectiveProgressRequest.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectGroupDetail.dart';
|
||||
|
||||
class ViewGroupDetail extends ParentViewModel {
|
||||
ConnectGroupDetail connection;
|
||||
|
||||
ViewGroupDetail(super.context, this.connection);
|
||||
|
||||
/// [quiet] refreshes behind the screen without the loading overlay, for
|
||||
/// coming back from the chat or the goal editor.
|
||||
void loadGroup(String groupId, {bool quiet = false}) async {
|
||||
if (!await hasNetwork(() => loadGroup(groupId, quiet: quiet))) return;
|
||||
|
||||
if (!quiet) showLoading("Loading the group");
|
||||
|
||||
try {
|
||||
final response =
|
||||
await getDataManager().getGroupEntry(IdRequest(id: groupId));
|
||||
|
||||
if (!quiet) closeLoading();
|
||||
|
||||
connection.onGroupLoaded(Group.fromJson(response.data));
|
||||
} catch (e) {
|
||||
handleError(e, () => loadGroup(groupId, quiet: quiet),
|
||||
() => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
void logProgress(ObjectiveProgressRequest request, String summary) async {
|
||||
if (!await hasNetwork(() => logProgress(request, summary))) return;
|
||||
|
||||
showLoading("Logging");
|
||||
|
||||
try {
|
||||
final response = await getDataManager().logObjectiveProgress(request);
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onProgressLogged(Group.fromJson(response.data), summary);
|
||||
} catch (e) {
|
||||
handleError(e, () => logProgress(request, summary),
|
||||
() => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
void nudge(NudgeRequest request, String who) async {
|
||||
if (!await hasNetwork(() => nudge(request, who))) return;
|
||||
|
||||
try {
|
||||
await getDataManager().nudgeGroupMember(request);
|
||||
|
||||
connection.onNudged(who);
|
||||
} catch (e) {
|
||||
handleError(e, () => nudge(request, who), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
void leave(String groupId) async {
|
||||
if (!await hasNetwork(() => leave(groupId))) return;
|
||||
|
||||
showLoading("Leaving");
|
||||
|
||||
try {
|
||||
await getDataManager().leaveGroupEntry(IdRequest(id: groupId));
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onLeft();
|
||||
} catch (e) {
|
||||
handleError(e, () => leave(groupId), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
43
frontend/lib/Grounded/see/group/ViewGroupEditor.dart
Normal file
43
frontend/lib/Grounded/see/group/ViewGroupEditor.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import '../../about/external/data/Group.dart';
|
||||
import '../../about/external/initial/GroupRequest.dart';
|
||||
import '../../about/external/initial/JoinGroupRequest.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectGroupEditor.dart';
|
||||
|
||||
class ViewGroupEditor extends ParentViewModel {
|
||||
ConnectGroupEditor connection;
|
||||
|
||||
ViewGroupEditor(super.context, this.connection);
|
||||
|
||||
void create(GroupRequest request) async {
|
||||
if (!await hasNetwork(() => create(request))) return;
|
||||
|
||||
showLoading("Creating the group");
|
||||
|
||||
try {
|
||||
final response = await getDataManager().saveGroupEntry(request);
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onGroupReady(Group.fromJson(response.data));
|
||||
} catch (e) {
|
||||
handleError(e, () => create(request), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
void join(JoinGroupRequest request) async {
|
||||
if (!await hasNetwork(() => join(request))) return;
|
||||
|
||||
showLoading("Joining");
|
||||
|
||||
try {
|
||||
final response = await getDataManager().joinGroupEntry(request);
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onGroupReady(Group.fromJson(response.data));
|
||||
} catch (e) {
|
||||
handleError(e, () => join(request), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
25
frontend/lib/Grounded/see/group/ViewGroupGoalEditor.dart
Normal file
25
frontend/lib/Grounded/see/group/ViewGroupGoalEditor.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import '../../about/external/initial/GroupGoalRequest.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectGroupGoalEditor.dart';
|
||||
|
||||
class ViewGroupGoalEditor extends ParentViewModel {
|
||||
ConnectGroupGoalEditor connection;
|
||||
|
||||
ViewGroupGoalEditor(super.context, this.connection);
|
||||
|
||||
void save(GroupGoalRequest request) async {
|
||||
if (!await hasNetwork(() => save(request))) return;
|
||||
|
||||
showLoading("Saving the shared goal");
|
||||
|
||||
try {
|
||||
await getDataManager().saveGroupGoalEntry(request);
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onGoalSaved();
|
||||
} catch (e) {
|
||||
handleError(e, () => save(request), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
34
frontend/lib/Grounded/see/group/ViewGroups.dart
Normal file
34
frontend/lib/Grounded/see/group/ViewGroups.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import '../../about/external/data/pages/request/HistoryRequest.dart';
|
||||
import '../../about/external/data/pages/request/PageAndSort.dart';
|
||||
import '../../about/external/data/pages/request/Pageable.dart';
|
||||
import '../../about/external/data/pages/request/Sort.dart';
|
||||
import '../../utils/ObjectConvertors.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectGroups.dart';
|
||||
|
||||
class ViewGroups extends ParentViewModel {
|
||||
ConnectGroups connection;
|
||||
|
||||
ViewGroups(super.context, this.connection);
|
||||
|
||||
void loadGroups() async {
|
||||
if (!await hasNetwork(() => loadGroups())) return;
|
||||
|
||||
showLoading("Loading your groups");
|
||||
|
||||
try {
|
||||
final response = await getDataManager().getMyGroups(HistoryRequest(
|
||||
query: PageAndSort(
|
||||
sort: Sort('desc', 'lastMessageAt'),
|
||||
page: Pageable(0, 0, 50, 0),
|
||||
),
|
||||
));
|
||||
|
||||
closeLoading();
|
||||
|
||||
connection.onGroupsLoaded(getGroupList(response.data));
|
||||
} catch (e) {
|
||||
handleError(e, () => loadGroups(), () => dismissError(), "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/data/ExcuseCluster.dart';
|
||||
import '../../about/external/data/Group.dart';
|
||||
import '../../about/external/data/ReportCard.dart';
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
import '../../about/internal/application/Standing.dart';
|
||||
@@ -20,8 +21,8 @@ abstract class ConnectHome {
|
||||
/// view. Null when it could not be loaded.
|
||||
void onWeekLoaded(ReportCard? week);
|
||||
|
||||
/// Water drunk today, in ml.
|
||||
void onWaterLoaded(int ml);
|
||||
/// Your groups, for the rail on Home.
|
||||
void onGroupsLoaded(List<Group> groups);
|
||||
|
||||
/// The quick to-do list, read from the device.
|
||||
void onTodosLoaded(List<TodoItem> todos);
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/data/ExcuseCluster.dart';
|
||||
import '../../about/external/data/Group.dart';
|
||||
import '../../about/external/data/ReportCard.dart';
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
import '../../about/internal/application/CommitmentClass.dart';
|
||||
@@ -25,6 +26,7 @@ import '../../designs/Checkbox.dart';
|
||||
import '../../designs/Component.dart';
|
||||
import '../../designs/FloatingNav.dart';
|
||||
import '../../designs/GlassSurface.dart';
|
||||
import '../../designs/MemberAvatar.dart';
|
||||
import '../../designs/ProgressRing.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/Shell.dart';
|
||||
@@ -40,6 +42,9 @@ import '../../utils/ToneEngine.dart';
|
||||
import '../commitment/NewCommitment.dart';
|
||||
import '../excuse/ExcuseReport.dart';
|
||||
import '../goal/Goals.dart';
|
||||
import '../group/GroupChat.dart';
|
||||
import '../group/GroupEditor.dart';
|
||||
import '../group/Groups.dart';
|
||||
import '../overdue/OverdueQueue.dart';
|
||||
import '../reportcard/ReportCardScreen.dart';
|
||||
import '../routine/RoutineRunner.dart';
|
||||
@@ -63,6 +68,8 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
|
||||
List<TodoItem> _todos = <TodoItem>[];
|
||||
|
||||
List<Group> _groups = <Group>[];
|
||||
|
||||
final TextEditingController _todoInput = TextEditingController();
|
||||
|
||||
final FocusNode _todoFocus = FocusNode();
|
||||
@@ -73,11 +80,6 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
|
||||
bool _weekly = false;
|
||||
|
||||
int _waterMl = 0;
|
||||
|
||||
static const int _waterTarget = 2000;
|
||||
|
||||
static const int _waterStep = 250;
|
||||
|
||||
Standing _standing = Standing.Good;
|
||||
|
||||
@@ -194,6 +196,24 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
}
|
||||
}
|
||||
|
||||
void _onOpenGroups() async {
|
||||
await GroundedNavigation().navigateToPageWithData(const Groups(), context);
|
||||
_model?.loadGroups();
|
||||
}
|
||||
|
||||
void _onOpenGroupChat(Group group) async {
|
||||
await GroundedNavigation()
|
||||
.navigateToPageWithData(GroupChat(group: group), context);
|
||||
_model?.loadGroups();
|
||||
}
|
||||
|
||||
void _onAddPickGroup() async {
|
||||
_onCloseAddSheet();
|
||||
await GroundedNavigation()
|
||||
.navigateToPageWithData(const GroupEditor(), context);
|
||||
_model?.loadGroups();
|
||||
}
|
||||
|
||||
void _onOpenTraining() {
|
||||
GroundedNavigation()
|
||||
.navigateToPage(NavigatorType.justOpen, const Training(), context);
|
||||
@@ -249,20 +269,6 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
});
|
||||
}
|
||||
|
||||
void _onAddWater() {
|
||||
_onSetWaterDrops((_waterMl ~/ _waterStep) + 1);
|
||||
}
|
||||
|
||||
/// Tapping the last filled drop empties it again, so a mis-tap is undoable.
|
||||
void _onSetWaterDrops(int drops) {
|
||||
final int current = _waterMl ~/ _waterStep;
|
||||
final int next = drops == current ? drops - 1 : drops;
|
||||
setState(() {
|
||||
_waterMl = (next * _waterStep).clamp(0, _waterTarget);
|
||||
});
|
||||
_model?.saveWater(_waterMl);
|
||||
}
|
||||
|
||||
void _onOpenAddSheet() {
|
||||
showGeneralDialog(
|
||||
context: context,
|
||||
@@ -398,10 +404,6 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!_distressed) ...[
|
||||
_waterCard(),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
if (_distressed)
|
||||
_frosted(_distressBody())
|
||||
else if (queueIsHome)
|
||||
@@ -436,16 +438,16 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
label: "Goals",
|
||||
onTap: _onOpenGoals
|
||||
),
|
||||
(
|
||||
icon: CupertinoIcons.person_3_fill,
|
||||
label: "Groups",
|
||||
onTap: _onOpenGroups
|
||||
),
|
||||
(
|
||||
icon: CupertinoIcons.chart_bar_alt_fill,
|
||||
label: "Insights",
|
||||
onTap: _onOpenReportCard
|
||||
),
|
||||
(
|
||||
icon: CupertinoIcons.flame_fill,
|
||||
label: "Training",
|
||||
onTap: _onOpenTraining
|
||||
),
|
||||
],
|
||||
).animate().fadeIn(duration: 400.ms, delay: 250.ms).slideY(
|
||||
begin: 0.4,
|
||||
@@ -945,72 +947,6 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Water ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Water against the daily target, as drops you tap to fill.
|
||||
Widget _waterCard() {
|
||||
final int filled = (_waterMl / _waterStep).floor();
|
||||
|
||||
return GlassSurface(
|
||||
tint: colorGlassStrong,
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 16, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text("Water", 15, TextType.Regular, color: colorInkSoft),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
text(_thousands(_waterMl), 34, TextType.Medium,
|
||||
color: colorInk, letterSpacing: -1),
|
||||
const SizedBox(width: 4),
|
||||
text("/${_thousands(_waterTarget)} ml", 16,
|
||||
TextType.Regular,
|
||||
color: colorInkSoft),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
glassCircle(CupertinoIcons.add, _onAddWater, size: 52),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
for (int index = 0; index < _waterTarget ~/ _waterStep; index++)
|
||||
GestureDetector(
|
||||
onTap: () => _onSetWaterDrops(index + 1),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 240),
|
||||
curve: Curves.easeOutBack,
|
||||
scale: index < filled ? 1 : 0.9,
|
||||
child: Icon(
|
||||
Icons.water_drop,
|
||||
size: 38,
|
||||
color: index < filled ? colorWater : colorWaterEmpty,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Add sheet ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Everything you can add, over the blurred day — a close button floating
|
||||
@@ -1053,7 +989,7 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40, height: 4, color: colorWaterEmpty),
|
||||
width: 40, height: 4, color: colorGrabber),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
text("Add to today", 24, TextType.Medium,
|
||||
@@ -1078,12 +1014,16 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _addWideTile(kindIcon(RoutineKind.MustDo),
|
||||
"Must-do chain", _onAddPickMustDo)),
|
||||
child: _addTile(kindIcon(RoutineKind.MustDo),
|
||||
"Must-do", _onAddPickMustDo)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _addWideTile(CupertinoIcons.flag,
|
||||
child: _addTile(CupertinoIcons.flag,
|
||||
"Goal", _onAddPickGoal)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _addTile(CupertinoIcons.person_3,
|
||||
"Group", _onAddPickGroup)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
@@ -1275,6 +1215,13 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
const SizedBox(height: 28),
|
||||
_todoSection(),
|
||||
const SizedBox(height: 28),
|
||||
if (_groups.isNotEmpty) ...[
|
||||
sectionBreak("Your groups",
|
||||
caption: _groupsCaption(),
|
||||
trailing: _viewAll("All groups", _onOpenGroups)),
|
||||
_groupRail(),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
if (_upNext.isNotEmpty) ...[
|
||||
sectionBreak("Up next",
|
||||
caption: "${_upNext.length} routines",
|
||||
@@ -1354,8 +1301,8 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
() => _onOpenKind(RoutineKind.MustDo)),
|
||||
_category(goalScene(goalAccents[4]), CupertinoIcons.flag_fill,
|
||||
"Goals", 13, _onOpenGoals),
|
||||
_category(goalScene(goalAccents[2]),
|
||||
CupertinoIcons.chart_bar_alt_fill, "Report", 21, _onOpenReportCard),
|
||||
_category(goalScene(goalAccents[2]), CupertinoIcons.flame_fill,
|
||||
"Training", 21, _onOpenTraining),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1533,6 +1480,125 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
);
|
||||
}
|
||||
|
||||
String _groupsCaption() {
|
||||
int unread = 0;
|
||||
for (Group group in _groups) {
|
||||
unread = unread + group.unread;
|
||||
}
|
||||
return unread == 0 ? "${_groups.length} groups" : "$unread new messages";
|
||||
}
|
||||
|
||||
/// Each group as a tile: its banner, who is in it, and the latest line.
|
||||
/// Tapping goes straight into the chat.
|
||||
Widget _groupRail() {
|
||||
return SizedBox(
|
||||
height: 214,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
clipBehavior: Clip.none,
|
||||
itemCount: _groups.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) => _groupTile(_groups[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _groupTile(Group group) {
|
||||
final Color accent = goalColor(group.colourHex);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onOpenGroupChat(group),
|
||||
child: Container(
|
||||
width: 250,
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x142A3A30),
|
||||
blurRadius: 20,
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 96,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
SceneArt(
|
||||
palette: goalScene(accent),
|
||||
seed: sceneSeed(group.name),
|
||||
figure: CupertinoIcons.person_3_fill,
|
||||
figureScale: 0.5,
|
||||
figureAlignment: const Alignment(0.75, 0),
|
||||
shade: true,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (group.unread > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 4),
|
||||
color: colorLime,
|
||||
child: text("${group.unread} new", 10,
|
||||
TextType.Medium,
|
||||
color: colorNight),
|
||||
),
|
||||
const Spacer(),
|
||||
text(group.name, 18, TextType.Medium,
|
||||
color: colorWhite,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(
|
||||
group.lastMessage.isEmpty
|
||||
? "No messages yet"
|
||||
: "${group.lastAuthor}: ${group.lastMessage}",
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: colorInk,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
height: 1.4,
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
avatarStack(
|
||||
group.members.map((member) => member.name).toList(),
|
||||
26),
|
||||
const Spacer(),
|
||||
Icon(CupertinoIcons.chat_bubble_2_fill,
|
||||
size: 16, color: colorInkSoft),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The quick to-do list: things to remember that are not promises. No
|
||||
/// window, no debt. Open items first, done ones struck through below.
|
||||
Widget _todoSection() {
|
||||
@@ -2078,9 +2144,9 @@ class HomeState extends State<Home> implements ConnectHome {
|
||||
}
|
||||
|
||||
@override
|
||||
void onWaterLoaded(int ml) {
|
||||
void onGroupsLoaded(List<Group> groups) {
|
||||
setState(() {
|
||||
_waterMl = ml;
|
||||
_groups = groups;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import '../../about/external/data/Commitment.dart';
|
||||
import '../../about/external/data/ExcuseCluster.dart';
|
||||
import '../../about/external/data/Group.dart';
|
||||
import '../../about/external/data/ReportCard.dart';
|
||||
import '../../about/external/data/RoutineChain.dart';
|
||||
import '../../about/external/data/pages/request/CommitmentsRequest.dart';
|
||||
@@ -35,10 +36,10 @@ class ViewHome extends ParentViewModel {
|
||||
|
||||
loadTodos();
|
||||
|
||||
loadWater();
|
||||
|
||||
loadWeek();
|
||||
|
||||
loadGroups();
|
||||
|
||||
loadPlan();
|
||||
}
|
||||
|
||||
@@ -61,12 +62,20 @@ class ViewHome extends ParentViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
void loadWater() async {
|
||||
connection.onWaterLoaded(await getDataManager().getWaterToday());
|
||||
}
|
||||
/// The groups rail. Quiet on failure: the rail just stays hidden.
|
||||
void loadGroups() async {
|
||||
try {
|
||||
final response = await getDataManager().getMyGroups(HistoryRequest(
|
||||
query: PageAndSort(
|
||||
sort: Sort('desc', 'lastMessageAt'),
|
||||
page: Pageable(0, 0, 20, 0),
|
||||
),
|
||||
));
|
||||
|
||||
void saveWater(int ml) async {
|
||||
await getDataManager().setWaterToday(ml);
|
||||
connection.onGroupsLoaded(getGroupList(response.data));
|
||||
} catch (e) {
|
||||
connection.onGroupsLoaded(<Group>[]);
|
||||
}
|
||||
}
|
||||
|
||||
void loadTodos() async {
|
||||
|
||||
Reference in New Issue
Block a user