diff --git a/apps/tasks/lib/components/assignee_select.dart b/apps/tasks/lib/components/assignee_select.dart index 02e4794f..3e314f90 100644 --- a/apps/tasks/lib/components/assignee_select.dart +++ b/apps/tasks/lib/components/assignee_select.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:helpwave_localization/localization.dart'; import 'package:helpwave_service/user.dart'; -import 'package:helpwave_theme/constants.dart'; import 'package:helpwave_widget/bottom_sheets.dart'; import 'package:helpwave_widget/content_selection.dart'; @@ -22,31 +21,32 @@ class AssigneeSelectBottomSheet extends StatelessWidget { @override Widget build(BuildContext context) { return BottomSheetBase( - titleText: context.localization!.assignee, + header: BottomSheetHeader( + titleText: context.localization!.assignee, + ), onClosing: () => {}, - builder: (context) => Padding( - padding: const EdgeInsets.symmetric(vertical: paddingMedium), - child: Column( + builder: (context) => Column( children: [ TextButton( child: Text(context.localization!.remove), onPressed: () => onChanged(null), ), const SizedBox(height: 10), - ListSelect( - items: users, - onSelect: onChanged, - builder: (context, user, select) => ListTile( - onTap: select, - leading: CircleAvatar( - foregroundColor: Colors.blue, backgroundImage: NetworkImage(user.profileUrl.toString())), - title: Text(user.nickName, - style: TextStyle(decoration: user.id == selectedId ? TextDecoration.underline : null)), + SingleChildScrollView( + child: ListSelect( + items: users, + onSelect: onChanged, + builder: (context, user, select) => ListTile( + onTap: select, + leading: CircleAvatar( + foregroundColor: Colors.blue, backgroundImage: NetworkImage(user.profileUrl.toString())), + title: Text(user.nickName, + style: TextStyle(decoration: user.id == selectedId ? TextDecoration.underline : null)), + ), ), ), ], ), - ), ); } } diff --git a/apps/tasks/lib/components/patient_bottom_sheet.dart b/apps/tasks/lib/components/patient_bottom_sheet.dart index 431773a4..446882d2 100644 --- a/apps/tasks/lib/components/patient_bottom_sheet.dart +++ b/apps/tasks/lib/components/patient_bottom_sheet.dart @@ -52,29 +52,31 @@ class _PatientBottomSheetState extends State { ), ], child: BottomSheetBase( - title: Consumer(builder: (context, patientController, _) { - if (patientController.state == LoadingState.loaded || patientController.isCreating) { - return ClickableTextEdit( - initialValue: patientController.patient.name, - onUpdated: patientController.changeName, - textAlign: TextAlign.center, - textStyle: TextStyle( - color: Theme.of(context).colorScheme.secondary, - fontWeight: FontWeight.bold, - fontSize: iconSizeTiny, - fontFamily: "SpaceGrotesk", - overflow: TextOverflow.ellipsis, - ), - ); - } else { - return const PulsingContainer(width: 30); - } - }), + header: BottomSheetHeader( + title: Consumer(builder: (context, patientController, _) { + if (patientController.state == LoadingState.loaded || patientController.isCreating) { + return ClickableTextEdit( + initialValue: patientController.patient.name, + onUpdated: patientController.changeName, + textAlign: TextAlign.center, + textStyle: TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontWeight: FontWeight.bold, + fontSize: iconSizeTiny, + fontFamily: "SpaceGrotesk", + overflow: TextOverflow.ellipsis, + ), + ); + } else { + return const PulsingContainer(width: 30); + } + }), + ), onClosing: () { // TODO handle this }, bottomWidget: Padding( - padding: const EdgeInsets.only(top: paddingMedium), + padding: const EdgeInsets.only(top: paddingSmall), child: Consumer(builder: (context, patientController, _) { return LoadingAndErrorWidget( state: patientController.state, @@ -145,129 +147,131 @@ class _PatientBottomSheetState extends State { )); }), ), - builder: (BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Consumer(builder: (context, patientController, _) { - return LoadingFutureBuilder( - data: loadRoomsWithBeds(patientController.patient.id), - // TODO use a better loading widget - loadingWidget: const SizedBox(), - thenWidgetBuilder: (context, beds) { - if (beds.isEmpty) { - return Text( - context.localization!.noFreeBeds, - style: TextStyle(color: Theme.of(context).disabledColor, fontWeight: FontWeight.bold), - ); - } - return DropdownButtonHideUnderline( - child: DropdownButton( - iconEnabledColor: Theme.of(context).colorScheme.secondary.withOpacity(0.6), - padding: EdgeInsets.zero, - isDense: true, - hint: Text( - context.localization!.assignBed, - style: TextStyle(color: Theme.of(context).colorScheme.secondary.withOpacity(0.6)), + builder: (BuildContext context) => SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Consumer(builder: (context, patientController, _) { + return LoadingFutureBuilder( + data: loadRoomsWithBeds(patientController.patient.id), + // TODO use a better loading widget + loadingWidget: const SizedBox(), + thenWidgetBuilder: (context, beds) { + if (beds.isEmpty) { + return Text( + context.localization!.noFreeBeds, + style: TextStyle(color: Theme.of(context).disabledColor, fontWeight: FontWeight.bold), + ); + } + return DropdownButtonHideUnderline( + child: DropdownButton( + iconEnabledColor: Theme.of(context).colorScheme.secondary.withOpacity(0.6), + padding: EdgeInsets.zero, + isDense: true, + hint: Text( + context.localization!.assignBed, + style: TextStyle(color: Theme.of(context).colorScheme.secondary.withOpacity(0.6)), + ), + value: beds.where((beds) => beds.bed.id == patientController.patient.bed?.id).firstOrNull, + items: beds + .map((roomWithBed) => DropdownMenuItem( + value: roomWithBed, + child: Text( + "${roomWithBed.room.name} - ${roomWithBed.bed.name}", + style: TextStyle(color: Theme.of(context).colorScheme.primary.withOpacity(0.6)), + ), + )) + .toList(), + onChanged: (RoomWithBedFlat? value) { + // TODO later unassign here + if (value == null) { + return; + } + patientController.assignToBed(value.room, value.bed); + }, ), - value: beds.where((beds) => beds.bed.id == patientController.patient.bed?.id).firstOrNull, - items: beds - .map((roomWithBed) => DropdownMenuItem( - value: roomWithBed, - child: Text( - "${roomWithBed.room.name} - ${roomWithBed.bed.name}", - style: TextStyle(color: Theme.of(context).colorScheme.primary.withOpacity(0.6)), - ), - )) - .toList(), - onChanged: (RoomWithBedFlat? value) { - // TODO later unassign here - if (value == null) { - return; - } - patientController.assignToBed(value.room, value.bed); - }, - ), - ); - }, - ); - }), - ), - Text( - context.localization!.notes, - style: const TextStyle(fontSize: fontSizeBig, fontWeight: FontWeight.bold), - ), - const SizedBox(height: distanceSmall), - Consumer( - builder: (context, patientController, _) => - patientController.state == LoadingState.loaded || patientController.isCreating - ? TextFormFieldWithTimer( - initialValue: patientController.patient.notes, - maxLines: 6, - onUpdate: patientController.changeNotes, - ) - : TextFormField(maxLines: 6), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: paddingMedium), - child: Consumer(builder: (context, patientController, _) { - Patient patient = patientController.patient; - return AddList( - maxHeight: width * 0.5, - items: [ - ...patient.unscheduledTasks, - ...patient.inProgressTasks, - ...patient.doneTasks, - ], - itemBuilder: (_, index, taskList) { - if (index == 0) { - return TaskExpansionTile( - tasks: patient.unscheduledTasks - .map((task) => TaskWithPatient.fromTaskAndPatient( - task: task, - patient: patient, - )) - .toList(), - title: context.localization!.upcoming, - color: upcomingColor, ); - } - if (index == 2) { + }, + ); + }), + ), + Text( + context.localization!.notes, + style: const TextStyle(fontSize: fontSizeBig, fontWeight: FontWeight.bold), + ), + const SizedBox(height: distanceSmall), + Consumer( + builder: (context, patientController, _) => + patientController.state == LoadingState.loaded || patientController.isCreating + ? TextFormFieldWithTimer( + initialValue: patientController.patient.notes, + maxLines: 6, + onUpdate: patientController.changeNotes, + ) + : TextFormField(maxLines: 6), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: paddingMedium), + child: Consumer(builder: (context, patientController, _) { + Patient patient = patientController.patient; + return AddList( + maxHeight: width * 0.5, + items: [ + ...patient.unscheduledTasks, + ...patient.inProgressTasks, + ...patient.doneTasks, + ], + itemBuilder: (_, index, taskList) { + if (index == 0) { + return TaskExpansionTile( + tasks: patient.unscheduledTasks + .map((task) => TaskWithPatient.fromTaskAndPatient( + task: task, + patient: patient, + )) + .toList(), + title: context.localization!.upcoming, + color: upcomingColor, + ); + } + if (index == 2) { + return TaskExpansionTile( + tasks: patient.doneTasks + .map((task) => TaskWithPatient.fromTaskAndPatient( + task: task, + patient: patient, + )) + .toList(), + title: context.localization!.inProgress, + color: inProgressColor, + ); + } return TaskExpansionTile( - tasks: patient.doneTasks + tasks: patient.inProgressTasks .map((task) => TaskWithPatient.fromTaskAndPatient( task: task, patient: patient, )) .toList(), - title: context.localization!.inProgress, - color: inProgressColor, + title: context.localization!.done, + color: doneColor, ); - } - return TaskExpansionTile( - tasks: patient.inProgressTasks - .map((task) => TaskWithPatient.fromTaskAndPatient( - task: task, - patient: patient, - )) - .toList(), - title: context.localization!.done, - color: doneColor, - ); - }, - title: Text( - context.localization!.tasks, - style: const TextStyle(fontSize: fontSizeBig, fontWeight: FontWeight.bold), - ), - // TODO use return value to add it to task list or force a refetch - onAdd: () => context.pushModal( - context: context, - builder: (context) => TaskBottomSheet(task: Task.empty(patient.id), patient: patient), - ), - ); - }), - ), - ], + }, + title: Text( + context.localization!.tasks, + style: const TextStyle(fontSize: fontSizeBig, fontWeight: FontWeight.bold), + ), + // TODO use return value to add it to task list or force a refetch + onAdd: () => context.pushModal( + context: context, + builder: (context) => TaskBottomSheet(task: Task.empty(patient.id), patient: patient), + ), + ); + }), + ), + ], + ), ), ), ); diff --git a/apps/tasks/lib/components/task_bottom_sheet.dart b/apps/tasks/lib/components/task_bottom_sheet.dart index 7f2590d4..271facb8 100644 --- a/apps/tasks/lib/components/task_bottom_sheet.dart +++ b/apps/tasks/lib/components/task_bottom_sheet.dart @@ -116,11 +116,11 @@ class _TaskBottomSheetState extends State { return ChangeNotifierProvider( create: (context) => TaskController(TaskWithPatient.fromTaskAndPatient(task: widget.task, patient: widget.patient)), - child: SingleChildScrollView( - child: BottomSheetBase( - onClosing: () async { - // TODO do saving or something when the dialog is closed - }, + child: BottomSheetBase( + onClosing: () async { + // TODO do saving or something when the dialog is closed + }, + header: BottomSheetHeader( title: Consumer( builder: (context, taskController, child) => ClickableTextEdit( initialValue: taskController.task.name, @@ -135,252 +135,247 @@ class _TaskBottomSheetState extends State { ), ), ), - bottomWidget: Flexible( - child: Consumer( - builder: (context, taskController, child) => taskController.isCreating - ? Padding( - padding: const EdgeInsets.only(top: paddingSmall), - child: Align( - alignment: Alignment.topRight, - child: TextButton( - style: buttonStyleBig, - onPressed: taskController.isReadyForCreate - ? () { - taskController.create().then((value) { - if (value) { - Navigator.pop(context); - } - }); + ), + bottomWidget: Consumer( + builder: (context, taskController, child) => taskController.isCreating + ? Padding( + padding: const EdgeInsets.only(top: paddingSmall), + child: Align( + alignment: Alignment.topRight, + child: TextButton( + style: buttonStyleBig, + onPressed: taskController.isReadyForCreate + ? () { + taskController.create().then((value) { + if (value) { + Navigator.pop(context); } - : null, - child: Text(context.localization!.create), - ), - ), - ) - : const SizedBox(), - ), - ), - builder: (context) => Container( - constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.8), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Consumer(builder: - // TODO move this to its own component - (context, taskController, __) { - return LoadingAndErrorWidget.pulsing( - state: taskController.state, - child: !taskController.isCreating - ? Text(taskController.patient.name) - : LoadingFutureBuilder( - data: PatientService().getPatientList(), - loadingWidget: const PulsingContainer(), - thenWidgetBuilder: (context, patientList) { - List patients = patientList.active + patientList.unassigned; - return DropdownButton( - underline: const SizedBox(), - iconEnabledColor: Theme.of(context).colorScheme.secondary.withOpacity(0.6), - // removes the default underline - padding: EdgeInsets.zero, - hint: Text( - context.localization!.selectPatient, - style: TextStyle(color: Theme.of(context).colorScheme.secondary.withOpacity(0.6)), - ), - isDense: true, - items: patients - .map((patient) => DropdownMenuItem(value: patient, child: Text(patient.name))) - .toList(), - value: taskController.patient.isCreating ? null : taskController.patient, - onChanged: (patient) => - taskController.changePatient(patient ?? PatientMinimal.empty()), - ); - }), - ); - }), + }); + } + : null, + child: Text(context.localization!.create), + ), ), - const SizedBox(height: distanceMedium), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Consumer(builder: (context, taskController, __) { - return _SheetListTile( - icon: Icons.person, - label: context.localization!.assignedTo, - onTap: () => context.pushModal( - context: context, - builder: (BuildContext context) => AssigneeSelectBottomSheet( - users: OrganizationService() - .getMembersByOrganization(CurrentWardService().currentWard!.organizationId), - onChanged: (User? assignee) { - taskController.changeAssignee(assignee); - Navigator.pop(context); - }, - selectedId: taskController.task.assigneeId, - ), - ), - valueWidget: taskController.task.hasAssignee - ? LoadingAndErrorWidget.pulsing( - state: taskController.assignee != null ? LoadingState.loaded : LoadingState.loading, - child: Text( - // Never the case that we display the empty String, but the text is computed - // before being displayed - taskController.assignee?.name ?? "", - style: editableValueTextStyle(context), - ), - ) - : Text( - context.localization!.unassigned, - style: editableValueTextStyle(context), + ) + : const SizedBox(), + ), + builder: (context) => Flexible( + child: ListView( + children: [ + Center( + child: Consumer(builder: + // TODO move this to its own component + (context, taskController, __) { + return LoadingAndErrorWidget.pulsing( + state: taskController.state, + child: !taskController.isCreating + ? Text(taskController.patient.name) + : LoadingFutureBuilder( + data: PatientService().getPatientList(), + loadingWidget: const PulsingContainer(), + thenWidgetBuilder: (context, patientList) { + List patients = patientList.active + patientList.unassigned; + return DropdownButton( + underline: const SizedBox(), + iconEnabledColor: Theme.of(context).colorScheme.secondary.withOpacity(0.6), + // removes the default underline + padding: EdgeInsets.zero, + hint: Text( + context.localization!.selectPatient, + style: TextStyle(color: Theme.of(context).colorScheme.secondary.withOpacity(0.6)), ), - ); - }), - Consumer( - builder: (context, taskController, __) => LoadingAndErrorWidget.pulsing( - state: taskController.state, - child: _SheetListTile( - icon: Icons.access_time, - label: context.localization!.due, - // TODO localization and date formatting here - valueWidget: Builder(builder: (context) { - DateTime? dueDate = taskController.task.dueDate; - if (dueDate != null) { - String date = - "${dueDate.day.toString().padLeft(2, "0")}.${dueDate.month.toString().padLeft(2, "0")}.${dueDate.year.toString().padLeft(4, "0")}"; - String time = - "${dueDate.hour.toString().padLeft(2, "0")}:${dueDate.minute.toString().padLeft(2, "0")}"; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(time, style: editableValueTextStyle(context)), - Text(date), - ], - ); - } - return Text(context.localization!.none); + isDense: true, + items: patients + .map((patient) => DropdownMenuItem(value: patient, child: Text(patient.name))) + .toList(), + value: taskController.patient.isCreating ? null : taskController.patient, + onChanged: (patient) => taskController.changePatient(patient ?? PatientMinimal.empty()), + ); }), - onTap: () => showDatePicker( - context: context, - initialDate: taskController.task.dueDate ?? DateTime.now(), - firstDate: DateTime(1960), - lastDate: DateTime.now().add(const Duration(days: 365 * 5)), - builder: (context, child) { - // Overwrite the Theme - ThemeData pickerTheme = - Theme.of(context).copyWith(textButtonTheme: const TextButtonThemeData()); - return Theme(data: pickerTheme, child: child ?? const SizedBox()); - }, - ).then((date) async { - await showTimePicker( - context: context, - initialTime: TimeOfDay.fromDateTime(taskController.task.dueDate ?? DateTime.now()), - builder: (context, child) { - ThemeData originalTheme = Theme.of(context); - - // Temporarily set a default theme for the picker - ThemeData pickerTheme = ThemeData.fallback().copyWith( - colorScheme: originalTheme.colorScheme, - ); - return Theme(data: pickerTheme, child: child ?? const SizedBox()); - }, - ).then((time) { - if (date == null && time == null) { - return; - } - date ??= taskController.task.dueDate; - if (date == null) { - return; - } - if (time != null) { - date = DateTime( - date!.year, - date!.month, - date!.day, - time.hour, - time.minute, - ); - } - taskController.changeDueDate(date); - }); - }), - ), + ); + }), + ), + const SizedBox(height: distanceMedium), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Consumer(builder: (context, taskController, __) { + return _SheetListTile( + icon: Icons.person, + label: context.localization!.assignedTo, + onTap: () => context.pushModal( + context: context, + builder: (BuildContext context) => AssigneeSelectBottomSheet( + users: OrganizationService() + .getMembersByOrganization(CurrentWardService().currentWard!.organizationId), + onChanged: (User? assignee) { + taskController.changeAssignee(assignee); + Navigator.pop(context); + }, + selectedId: taskController.task.assigneeId, ), ), - ], - ), - const SizedBox(height: distanceSmall), + valueWidget: taskController.task.hasAssignee + ? LoadingAndErrorWidget.pulsing( + state: taskController.assignee != null ? LoadingState.loaded : LoadingState.loading, + child: Text( + // Never the case that we display the empty String, but the text is computed + // before being displayed + taskController.assignee?.name ?? "", + style: editableValueTextStyle(context), + ), + ) + : Text( + context.localization!.unassigned, + style: editableValueTextStyle(context), + ), + ); + }), Consumer( - builder: (_, taskController, __) => LoadingAndErrorWidget.pulsing( + builder: (context, taskController, __) => LoadingAndErrorWidget.pulsing( state: taskController.state, child: _SheetListTile( - icon: Icons.lock, - label: context.localization!.visibility, - valueWidget: VisibilitySelect( - isPublicVisible: taskController.task.isPublicVisible, - onChanged: taskController.changeIsPublic, - isCreating: taskController.isCreating, - textStyle: editableValueTextStyle(context), - ), + icon: Icons.access_time, + label: context.localization!.due, + // TODO localization and date formatting here + valueWidget: Builder(builder: (context) { + DateTime? dueDate = taskController.task.dueDate; + if (dueDate != null) { + String date = + "${dueDate.day.toString().padLeft(2, "0")}.${dueDate.month.toString().padLeft(2, "0")}.${dueDate.year.toString().padLeft(4, "0")}"; + String time = + "${dueDate.hour.toString().padLeft(2, "0")}:${dueDate.minute.toString().padLeft(2, "0")}"; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(time, style: editableValueTextStyle(context)), + Text(date), + ], + ); + } + return Text(context.localization!.none); + }), + onTap: () => showDatePicker( + context: context, + initialDate: taskController.task.dueDate ?? DateTime.now(), + firstDate: DateTime(1960), + lastDate: DateTime.now().add(const Duration(days: 365 * 5)), + builder: (context, child) { + // Overwrite the Theme + ThemeData pickerTheme = + Theme.of(context).copyWith(textButtonTheme: const TextButtonThemeData()); + return Theme(data: pickerTheme, child: child ?? const SizedBox()); + }, + ).then((date) async { + await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(taskController.task.dueDate ?? DateTime.now()), + builder: (context, child) { + ThemeData originalTheme = Theme.of(context); + + // Temporarily set a default theme for the picker + ThemeData pickerTheme = ThemeData.fallback().copyWith( + colorScheme: originalTheme.colorScheme, + ); + return Theme(data: pickerTheme, child: child ?? const SizedBox()); + }, + ).then((time) { + if (date == null && time == null) { + return; + } + date ??= taskController.task.dueDate; + if (date == null) { + return; + } + if (time != null) { + date = DateTime( + date!.year, + date!.month, + date!.day, + time.hour, + time.minute, + ); + } + taskController.changeDueDate(date); + }); + }), ), ), ), - const SizedBox(height: distanceMedium), - Text( - context.localization!.notes, - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ], + ), + const SizedBox(height: distanceSmall), + Consumer( + builder: (_, taskController, __) => LoadingAndErrorWidget.pulsing( + state: taskController.state, + child: _SheetListTile( + icon: Icons.lock, + label: context.localization!.visibility, + valueWidget: VisibilitySelect( + isPublicVisible: taskController.task.isPublicVisible, + onChanged: taskController.changeIsPublic, + isCreating: taskController.isCreating, + textStyle: editableValueTextStyle(context), + ), ), - const SizedBox(height: distanceTiny), - Consumer( - builder: (_, taskController, __) => LoadingAndErrorWidget( - state: taskController.state, - loadingWidget: PulsingContainer( - width: MediaQuery.of(context).size.width, - height: 25 * 6, // 25px per line - ), - errorWidget: PulsingContainer( - width: MediaQuery.of(context).size.width, - height: 25 * 6, - // 25px per line - maxOpacity: 1, - minOpacity: 1, - color: negativeColor, - ), - child: TextFormFieldWithTimer( - initialValue: taskController.task.notes, - onUpdate: taskController.changeNotes, - maxLines: 6, - decoration: InputDecoration( - contentPadding: const EdgeInsets.all(paddingMedium), - border: const OutlineInputBorder( - borderSide: BorderSide( - width: 1.0, - ), - ), - hintText: context.localization!.yourNotes, + ), + ), + const SizedBox(height: distanceMedium), + Text( + context.localization!.notes, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: distanceTiny), + Consumer( + builder: (_, taskController, __) => LoadingAndErrorWidget( + state: taskController.state, + loadingWidget: PulsingContainer( + width: MediaQuery.of(context).size.width, + height: 25 * 6, // 25px per line + ), + errorWidget: PulsingContainer( + width: MediaQuery.of(context).size.width, + height: 25 * 6, + // 25px per line + maxOpacity: 1, + minOpacity: 1, + color: negativeColor, + ), + child: TextFormFieldWithTimer( + initialValue: taskController.task.notes, + onUpdate: taskController.changeNotes, + maxLines: 6, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(paddingMedium), + border: const OutlineInputBorder( + borderSide: BorderSide( + width: 1.0, ), ), + hintText: context.localization!.yourNotes, ), ), - const SizedBox(height: distanceBig), - // TODO add callback here for task creation to update the Task accordingly - Consumer( - builder: (_, taskController, __) => LoadingAndErrorWidget.pulsing( - state: taskController.state, - child: SubtaskList( - taskId: taskController.task.id, - subtasks: taskController.task.subtasks, - onChange: (subtasks) { - if (taskController.task.isCreating) { - taskController.task.subtasks = subtasks; - } - }, - ), - ), + ), + ), + const SizedBox(height: distanceBig), + // TODO add callback here for task creation to update the Task accordingly + Consumer( + builder: (_, taskController, __) => LoadingAndErrorWidget.pulsing( + state: taskController.state, + child: SubtaskList( + taskId: taskController.task.id, + subtasks: taskController.task.subtasks, + onChange: (subtasks) { + if (taskController.task.isCreating) { + taskController.task.subtasks = subtasks; + } + }, ), - ], - )), + ), + ), + ], + ), ), ), ); diff --git a/apps/tasks/lib/components/user_bottom_sheet.dart b/apps/tasks/lib/components/user_bottom_sheet.dart index 4b18600a..4823ac6f 100644 --- a/apps/tasks/lib/components/user_bottom_sheet.dart +++ b/apps/tasks/lib/components/user_bottom_sheet.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:helpwave_localization/localization.dart'; +import 'package:helpwave_service/user.dart'; import 'package:helpwave_theme/constants.dart'; import 'package:helpwave_widget/bottom_sheets.dart'; import 'package:helpwave_widget/loading.dart'; @@ -8,50 +9,42 @@ import 'package:provider/provider.dart'; import 'package:helpwave_service/tasks.dart'; import 'package:helpwave_service/auth.dart'; import 'package:tasks/screens/login_screen.dart'; +import 'package:helpwave_widget/widgets.dart'; +import 'package:tasks/screens/settings_screen.dart'; -/// A [BottomSheet] for showing the [User]s information -class UserBottomSheet extends StatefulWidget { - const UserBottomSheet({super.key}); +class UserBottomSheetPageBuilder with BottomSheetPageBuilder { @override - State createState() => _UserBottomSheetState(); -} + BottomSheetHeader? headerBuilder(BuildContext context, NestedBottomSheetNavigationController controller) { + return BottomSheetHeader( + trailing: BottomSheetAction( + icon: Icons.settings, + onPressed: () => controller.push(SettingsBottomSheetPageBuilder()), + ), + ); + } -class _UserBottomSheetState extends State { @override - Widget build(BuildContext context) { + Widget build(BuildContext context, NestedBottomSheetNavigationController controller) { final double width = MediaQuery.of(context).size.width; - return BottomSheetBase( - onClosing: () {}, - titleText: context.localization!.profile, - builder: (BuildContext ctx) => Column( + return Flexible( + child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Padding( padding: const EdgeInsets.all(paddingSmall).copyWith(top: paddingMedium), - child: CircleAvatar( - radius: iconSizeMedium, - child: Container( - decoration: const BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment(0.8, 1), - colors: [ - Color(0xff1f005c), - Color(0xff5b0060), - Color(0xff870160), - Color(0xffac255e), - Color(0xffca485c), - Color(0xffe16b5c), - Color(0xfff39060), - Color(0xffffb56b), - ], - tileMode: TileMode.mirror, - ), - ), - ), + child: LoadingFutureBuilder( + data: UserService().getSelf(), + thenWidgetBuilder: (context, data) { + return CircleAvatar( + backgroundColor: Colors.transparent, + radius: iconSizeMedium, + foregroundImage: NetworkImage(data.profileUrl.toString()), + ); + }, + loadingWidget: const FallbackAvatar(size: iconSizeMedium), + errorWidget: const FallbackAvatar(size: iconSizeMedium), ), ), Consumer(builder: (context, userSessionController, _) { @@ -60,7 +53,6 @@ class _UserBottomSheetState extends State { style: const TextStyle(fontSize: fontSizeBig), ); }), - // TODO consider a loading widget here Consumer( builder: (context, currentWardController, __) => Text( currentWardController.currentWard?.organizationName ?? context.localization!.loading, @@ -147,6 +139,7 @@ class _UserBottomSheetState extends State { }); }), ), + const Spacer(), Padding( padding: const EdgeInsets.only(bottom: distanceMedium), child: Consumer(builder: (context, currentWardService, _) { diff --git a/apps/tasks/lib/components/user_header.dart b/apps/tasks/lib/components/user_header.dart index b66cac3a..5ea59d78 100644 --- a/apps/tasks/lib/components/user_header.dart +++ b/apps/tasks/lib/components/user_header.dart @@ -1,126 +1,78 @@ import 'package:flutter/material.dart'; import 'package:helpwave_service/auth.dart'; import 'package:helpwave_service/tasks.dart'; +import 'package:helpwave_service/user.dart'; import 'package:helpwave_theme/constants.dart'; +import 'package:helpwave_theme/util.dart'; import 'package:helpwave_widget/bottom_sheets.dart'; +import 'package:helpwave_widget/loading.dart'; +import 'package:helpwave_widget/widgets.dart'; import 'package:provider/provider.dart'; import 'package:tasks/components/user_bottom_sheet.dart'; -import 'package:tasks/screens/settings_screen.dart'; /// A [AppBar] for displaying the current [User], [Organization] and [Ward] class UserHeader extends StatelessWidget implements PreferredSizeWidget { - // TODO fetch users by with grpc const UserHeader({Key? key}) : super(key: key); @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(paddingSmall), - child: AppBar( - centerTitle: false, - titleSpacing: paddingSmall, - leadingWidth: iconSizeSmall, - leading: GestureDetector( - onTap: () { - context.pushModal( - context: context, - builder: (context) => const UserBottomSheet(), - ); - }, - child: CircleAvatar( - backgroundColor: Colors.transparent, - radius: iconSizeSmall / 2, - child: Container( + return Container( + color: context.theme.appBarTheme.backgroundColor, + child: Consumer(builder: (context, userSession, __) { + return SafeArea( + child: ListTile( + leading: SizedBox( width: iconSizeSmall, height: iconSizeSmall, - decoration: const BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment(0.8, 1), - colors: [ - Color(0xff1f005c), - Color(0xff5b0060), - Color(0xff870160), - Color(0xffac255e), - Color(0xffca485c), - Color(0xffe16b5c), - Color(0xfff39060), - Color(0xffffb56b), - ], - tileMode: TileMode.mirror, - ), - ), - ), - ), - ), - title: GestureDetector( - onTap: () { - context.pushModal( - context: context, - builder: (context) => const UserBottomSheet(), - ); - }, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // TODO get information somewhere - Consumer( - builder: (context, userSession, __) { - return Text( - userSession.identity!.name, - style: const TextStyle(fontSize: 16), + child: LoadingFutureBuilder( + data: UserService().getSelf(), + thenWidgetBuilder: (context, data) { + return CircleAvatar( + backgroundColor: Colors.transparent, + radius: iconSizeSmall / 2, + foregroundImage: NetworkImage(data.profileUrl.toString()), ); - } + }, + loadingWidget: const FallbackAvatar(), + errorWidget: const FallbackAvatar(), ), - - // TODO maybe show something for loading - Consumer( - builder: (context, currentWardController, __) => Row( - children: [ - Text( - "${currentWardController.currentWard?.organization.shortName ?? ""} - ", - style: const TextStyle( - color: Colors.grey, - fontSize: 14, - ), + ), + onTap: () { + context.pushModal( + context: context, + builder: (context) => NestedBottomSheetNavigator(initialPageBuilder: UserBottomSheetPageBuilder()), + ); + }, + title: Text( + userSession.identity!.name, + style: const TextStyle(fontSize: 16), + ), + subtitle: Consumer( + builder: (context, currentWardController, __) => Row( + children: [ + Text( + "${currentWardController.currentWard?.organization.shortName ?? ""} - ", + style: TextStyle( + color: Theme.of(context).colorScheme.onBackground.withOpacity(0.7), + fontSize: 14, ), - Text( - currentWardController.currentWard?.wardName ?? "", - overflow: TextOverflow.fade, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), + ), + Text( + currentWardController.currentWard?.wardName ?? "", + overflow: TextOverflow.fade, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, ), - ], - ), + ), + ], ), - ], + ), + trailing: const Icon(Icons.expand_more), ), - ), - actions: [ - IconButton( - padding: EdgeInsets.zero, - splashRadius: iconSizeSmall / 2, - constraints: const BoxConstraints(maxWidth: iconSizeSmall, maxHeight: iconSizeSmall), - iconSize: iconSizeSmall, - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const SettingsScreen(), - ), - ); - }, - icon: const Icon(Icons.settings), - ) - ], - ), + ); + }), ); - - } @override diff --git a/apps/tasks/lib/components/visibility_select.dart b/apps/tasks/lib/components/visibility_select.dart index 2c458b20..2ae8ca04 100644 --- a/apps/tasks/lib/components/visibility_select.dart +++ b/apps/tasks/lib/components/visibility_select.dart @@ -25,9 +25,11 @@ class _VisibilityBottomSheet extends StatelessWidget { top: paddingMedium, bottom: paddingBig, ), - titleText: context.localization!.visibility, + header: BottomSheetHeader( + titleText: context.localization!.visibility, + ), builder: (context) { - return Column( + return ListView( children: [ const SizedBox(height: distanceSmall), GestureDetector( diff --git a/apps/tasks/lib/main.dart b/apps/tasks/lib/main.dart index 65b37680..0350f9c2 100644 --- a/apps/tasks/lib/main.dart +++ b/apps/tasks/lib/main.dart @@ -57,9 +57,7 @@ class MyApp extends StatelessWidget { ], supportedLocales: getSupportedLocals(), locale: Locale(languageNotifier.language), - home: const Scaffold( - body: SafeArea(child: LoginScreen()), - ), + home: const LoginScreen(), ); }), ); diff --git a/apps/tasks/lib/screens/main_screen.dart b/apps/tasks/lib/screens/main_screen.dart index 60797a42..d39b2564 100644 --- a/apps/tasks/lib/screens/main_screen.dart +++ b/apps/tasks/lib/screens/main_screen.dart @@ -45,7 +45,7 @@ class _MainScreenState extends State { } return Scaffold( appBar: const UserHeader(), - body: [const MyTasksScreen(), const SizedBox(), const PatientScreen()][index], + body: SafeArea(child: [const MyTasksScreen(), const SizedBox(), const PatientScreen()][index]), floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, floatingActionButton: PopInAndOutAnimator( visible: isShowingActionButton, diff --git a/apps/tasks/lib/screens/settings_screen.dart b/apps/tasks/lib/screens/settings_screen.dart index e943d53b..9f31e582 100644 --- a/apps/tasks/lib/screens/settings_screen.dart +++ b/apps/tasks/lib/screens/settings_screen.dart @@ -4,18 +4,14 @@ import 'package:helpwave_localization/localization_model.dart'; import 'package:helpwave_service/auth.dart'; import 'package:helpwave_theme/constants.dart'; import 'package:helpwave_theme/theme.dart'; +import 'package:helpwave_widget/bottom_sheets.dart'; import 'package:provider/provider.dart'; import 'package:tasks/screens/login_screen.dart'; /// Screen for settings and other app options -class SettingsScreen extends StatefulWidget { +class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); - @override - State createState() => _SettingsScreenState(); -} - -class _SettingsScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( @@ -147,3 +143,129 @@ class _SettingsScreenState extends State { ); } } + +class SettingsBottomSheetPageBuilder with BottomSheetPageBuilder { + @override + Widget build(BuildContext context, NestedBottomSheetNavigationController controller) { + return Flexible( + child: ListView( + children: ListTile.divideTiles( + context: context, + tiles: [ + ListTile( + leading: const Icon(Icons.brightness_medium), + title: Text(context.localization!.darkMode), + trailing: Consumer( + builder: (_, ThemeModel themeNotifier, __) { + return PopupMenuButton( + initialValue: themeNotifier.themeMode, + position: PopupMenuPosition.under, + itemBuilder: (context) => [ + PopupMenuItem(value: ThemeMode.dark, child: Text(context.localization!.darkMode)), + PopupMenuItem(value: ThemeMode.light, child: Text(context.localization!.lightMode)), + PopupMenuItem(value: ThemeMode.system, child: Text(context.localization!.system)), + ], + onSelected: (value) { + if (value == ThemeMode.system) { + themeNotifier.isDark = null; + } else { + themeNotifier.isDark = value == ThemeMode.dark; + } + }, + child: Material( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + { + ThemeMode.dark: context.localization!.darkMode, + ThemeMode.light: context.localization!.lightMode, + ThemeMode.system: context.localization!.system, + }[themeNotifier.themeMode]!, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + )), + const SizedBox( + width: distanceTiny, + ), + const Icon( + Icons.expand_more_rounded, + size: iconSizeTiny, + ), + ], + ), + ), + ); + }, + ), + ), + Consumer( + builder: (context, languageModel, child) { + return ListTile( + leading: const Icon(Icons.language), + title: Text(context.localization!.language), + trailing: PopupMenuButton( + position: PopupMenuPosition.under, + initialValue: languageModel.local, + onSelected: (value) { + languageModel.setLanguage(value); + }, + itemBuilder: (BuildContext context) => getSupportedLocalsWithName() + .map((local) => PopupMenuItem( + value: local.local, + child: Text( + local.name, + ), + )) + .toList(), + child: Material( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(languageModel.name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + )), + const SizedBox( + width: distanceTiny, + ), + const Icon( + Icons.expand_more_rounded, + size: iconSizeTiny, + ), + ], + ), + ), + ), + ); + }, + ), + ListTile( + leading: const Icon(Icons.info_outline), + title: Text(context.localization!.licenses), + trailing: const Icon(Icons.arrow_forward), + onTap: () => {showLicensePage(context: context)}, + ), + Consumer( + builder: (context, currentWardService, _) { + return ListTile( + leading: const Icon(Icons.logout), + title: Text(context.localization!.logout), + onTap: () { + UserSessionService().logout(); + currentWardService.clear(); + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const LoginScreen()), + ); + }, + ); + }, + ), + ], + ).toList(), + ), + ); + } +} diff --git a/packages/helpwave_service/lib/src/api/user/services/user_service.dart b/packages/helpwave_service/lib/src/api/user/services/user_service.dart index 943bba4a..16680bdd 100644 --- a/packages/helpwave_service/lib/src/api/user/services/user_service.dart +++ b/packages/helpwave_service/lib/src/api/user/services/user_service.dart @@ -32,4 +32,24 @@ class UserService { profileUrl: Uri.parse(response.avatarUrl), ); } + + Future getSelf() async { + ReadSelfRequest request = ReadSelfRequest(); + ReadSelfResponse response = await userService.readSelf( + request, + options: CallOptions( + metadata: UserAPIServiceClients().getMetaData( + organizationId: AuthenticationUtility.fallbackOrganizationId, + ), + ), + ); + + return User( + id: response.id, + name: response.name, + nickName: response.nickname, + email: "no-email", // TODO replace this + profileUrl: Uri.parse(response.avatarUrl), + ); + } } diff --git a/packages/helpwave_theme/lib/src/constants.dart b/packages/helpwave_theme/lib/src/constants.dart index 6d0e95c0..291df3aa 100644 --- a/packages/helpwave_theme/lib/src/constants.dart +++ b/packages/helpwave_theme/lib/src/constants.dart @@ -130,6 +130,8 @@ const chipTheme = ChipThemeData( pressElevation: 4, ); +const AppBarTheme sharedAppBarTheme = AppBarTheme(centerTitle: true); + /// TextStyles TextStyle editableValueTextStyle(BuildContext context) => TextStyle(color: Theme.of(context).colorScheme.secondary, fontSize: 16, fontWeight: FontWeight.bold); diff --git a/packages/helpwave_theme/lib/src/theme/dark_theme.dart b/packages/helpwave_theme/lib/src/theme/dark_theme.dart index 5002ff66..8953e56f 100644 --- a/packages/helpwave_theme/lib/src/theme/dark_theme.dart +++ b/packages/helpwave_theme/lib/src/theme/dark_theme.dart @@ -81,4 +81,10 @@ ThemeData darkTheme = makeTheme( // additional brightness: Brightness.dark, + + // flutter themes + appBarTheme: sharedAppBarTheme.copyWith( + backgroundColor: const Color.fromARGB(255, 15, 15, 15), + foregroundColor: Colors.white, + ) ); diff --git a/packages/helpwave_theme/lib/src/theme/light_theme.dart b/packages/helpwave_theme/lib/src/theme/light_theme.dart index 72e708cf..881e600d 100644 --- a/packages/helpwave_theme/lib/src/theme/light_theme.dart +++ b/packages/helpwave_theme/lib/src/theme/light_theme.dart @@ -10,7 +10,7 @@ const onSecondaryColor = Color.fromARGB(255, 255, 255, 255); const tertiary = Color.fromARGB(255, 180, 180, 180); const onTertiary = Color.fromARGB(255, 0, 0, 0); -const backgroundColor = Color.fromARGB(255, 230, 230, 230); +const backgroundColor = Color.fromARGB(255, 242, 242, 242); const onBackgroundColor = Color.fromARGB(255, 0, 0, 0); const surface = Color.fromARGB(255, 220, 220, 220); @@ -81,4 +81,10 @@ ThemeData lightTheme = makeTheme( // additional brightness: Brightness.dark, + + // flutter themes + appBarTheme: sharedAppBarTheme.copyWith( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + ) ); diff --git a/packages/helpwave_theme/lib/src/theme/theme.dart b/packages/helpwave_theme/lib/src/theme/theme.dart index a9a27fd7..6c559af3 100644 --- a/packages/helpwave_theme/lib/src/theme/theme.dart +++ b/packages/helpwave_theme/lib/src/theme/theme.dart @@ -48,6 +48,9 @@ ThemeData makeTheme({ // additional parameters required Brightness brightness, + + // Flutter Themes + AppBarTheme appBarTheme = sharedAppBarTheme, }) { return ThemeData( useMaterial3: true, @@ -104,12 +107,7 @@ ThemeData makeTheme({ listTileTheme: ListTileThemeData( iconColor: focusedColor, ), - appBarTheme: AppBarTheme( - centerTitle: true, - foregroundColor: primaryColor, - backgroundColor: Colors.transparent, - shadowColor: Colors.transparent, - ), + appBarTheme: appBarTheme, elevatedButtonTheme: ElevatedButtonThemeData( style: buttonStyleSmall.copyWith( backgroundColor: resolveByStates( diff --git a/packages/helpwave_widget/lib/bottom_sheets.dart b/packages/helpwave_widget/lib/bottom_sheets.dart index bd339f47..4625700e 100644 --- a/packages/helpwave_widget/lib/bottom_sheets.dart +++ b/packages/helpwave_widget/lib/bottom_sheets.dart @@ -1 +1 @@ -export 'package:helpwave_widget/src/bottom_sheets/bottom_sheet_base.dart'; +export 'package:helpwave_widget/src/bottom_sheets/index.dart'; diff --git a/packages/helpwave_widget/lib/src/bottom_sheets/bottom_sheet_base.dart b/packages/helpwave_widget/lib/src/bottom_sheets/bottom_sheet_base.dart index 67e6e62d..451e5735 100644 --- a/packages/helpwave_widget/lib/src/bottom_sheets/bottom_sheet_base.dart +++ b/packages/helpwave_widget/lib/src/bottom_sheets/bottom_sheet_base.dart @@ -6,13 +6,14 @@ extension PushModalContextExtension on BuildContext { required BuildContext context, required Widget Function(BuildContext context) builder, Duration animationDuration = const Duration(milliseconds: 500), + Duration reverseDuration = const Duration(milliseconds: 250), }) async { T? value = await Navigator.of(context).push( PageRouteBuilder( - barrierColor: Colors.black.withOpacity(0.3), + barrierColor: Colors.black.withOpacity(0.4), barrierDismissible: true, transitionDuration: animationDuration, - reverseTransitionDuration: animationDuration, + reverseTransitionDuration: reverseDuration, opaque: false, // Set to false to make the route semi-transparent pageBuilder: (BuildContext context, _, __) { @@ -92,7 +93,7 @@ class _ModalWrapperState extends State<_ModalWrapper> with TickerProviderStateMi if (newOffset < 0) { newOffset = 0; } - + offset.value = newOffset; touchPositionY = details.globalPosition.dy; @@ -111,18 +112,142 @@ class _ModalWrapperState extends State<_ModalWrapper> with TickerProviderStateMi child: Align( alignment: Alignment.bottomCenter, child: ValueListenableBuilder( - valueListenable: offset, - child: Material( - color: Colors.transparent, - child: widget.builder(context), - ), - builder: (context, offsetValue, child) { - return Transform.translate( + valueListenable: offset, + child: Material( + color: Colors.transparent, + child: widget.builder(context), + ), + builder: (context, offsetValue, child) { + return Transform.translate( offset: Offset(0, offsetValue), - child: child, - ); - }, + child: child, + ); + }, + ), + ), + ); + } +} + +class BottomSheetAction { + final IconData icon; + final Function() onPressed; + + BottomSheetAction({required this.icon, required this.onPressed}); +} + +class BottomSheetHeader extends StatelessWidget { + /// A [BottomSheetAction] leading before the [title] + /// + /// Defaults to a closing button + final BottomSheetAction? leading; + + /// A [BottomSheetAction] trailing after the [title] + final BottomSheetAction? trailing; + + /// The title of the [BottomSheetHeader] displayed in the center + /// + /// Overwrites [titleText] + final Widget? title; + + /// The title text of the [BottomSheetHeader] displayed in the center + /// + /// Overwritten by [title] + final String? titleText; + + /// Whether the drag handler widget should be shown + final bool isShowingDragHandler; + + /// An additional padding for the [BottomSheetHeader] + /// + /// Be aware that the [BottomSheetBase] **already provides a padding** to all sides. + /// + /// You most likely want to change the bottom padding to create spacing between [BottomSheetHeader] and content of + /// the [BottomSheetBase]. + final EdgeInsets padding; + + const BottomSheetHeader({ + super.key, + this.leading, + this.trailing, + this.title, + this.titleText, + this.isShowingDragHandler = false, + this.padding = const EdgeInsets.only(bottom: paddingSmall), + }); + + @override + Widget build(BuildContext context) { + BottomSheetAction usedLeading = + leading ?? BottomSheetAction(icon: Icons.close_rounded, onPressed: () => Navigator.maybePop(context)); + + const double iconSize = iconSizeSmall; + + return Padding( + padding: padding, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Visibility( + visible: isShowingDragHandler, + child: Padding( + padding: const EdgeInsets.only(bottom: paddingSmall), + child: Center( + child: Container( + height: 5, + width: 30, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onBackground.withOpacity(0.8), + borderRadius: BorderRadius.circular(5), + ), + ), + ), + ), ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: iconSize, + height: iconSize, + child: IconButton( + padding: EdgeInsets.zero, + iconSize: iconSize, + onPressed: usedLeading.onPressed, + icon: Icon(usedLeading.icon), + ), + ), + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: paddingSmall), + child: title ?? + Text( + titleText ?? "", + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: iconSizeTiny, + fontFamily: "SpaceGrotesk", + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + SizedBox( + width: iconSize, + height: iconSize, + child: trailing != null + ? IconButton( + padding: EdgeInsets.zero, + iconSize: iconSize, + onPressed: trailing!.onPressed, + icon: Icon(trailing!.icon), + ) + : null, + ), + ], + ), + ], ), ); } @@ -136,32 +261,27 @@ class BottomSheetBase extends StatefulWidget { /// The builder function call to build the content of the [BottomSheetBase] final Widget Function(BuildContext context) builder; - /// The title of the titlebar - /// - /// Overwrites [titleText] - final Widget? title; - - /// The title text of the titlebar + /// A header [Widget] above the builder content /// - /// Overwritten by [title] - final String titleText; + /// Defaults to the [BottomSheetHeader] + final Widget header; /// The bottom [Widget] below the [builder] - /// - /// Overwrites [titleText] final Widget? bottomWidget; /// The [Padding] of the builder [Widget] final EdgeInsetsGeometry padding; + final MainAxisSize mainAxisSize; + const BottomSheetBase({ super.key, required this.onClosing, required this.builder, - this.title, - this.titleText = "", this.padding = const EdgeInsets.all(paddingMedium), this.bottomWidget, + this.header = const BottomSheetHeader(), + this.mainAxisSize = MainAxisSize.min, }); @override @@ -171,69 +291,26 @@ class BottomSheetBase extends StatefulWidget { class _BottomSheetBase extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { - return BottomSheet( - animationController: BottomSheet.createAnimationController(this), - enableDrag: false, - onClosing: widget.onClosing, - builder: (context) { - return Padding( - padding: widget.padding, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: paddingSmall), - child: Center( - child: Container( - height: 5, - width: 30, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onBackground.withOpacity(0.8), - borderRadius: BorderRadius.circular(5), - ), - ), - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: iconSizeTiny, - height: iconSizeTiny, - child: IconButton( - padding: EdgeInsets.zero, - iconSize: iconSizeTiny, - onPressed: () => Navigator.maybePop(context), - icon: const Icon(Icons.close_rounded), - ), - ), - Flexible( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: paddingSmall), - child: widget.title ?? - Text( - widget.titleText, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: iconSizeTiny, - fontFamily: "SpaceGrotesk", - overflow: TextOverflow.ellipsis, - ), - ), - ), - ), - const SizedBox(width: iconSizeTiny), - ], - ), - SingleChildScrollView( - child: widget.builder(context), - ), - widget.bottomWidget ?? const SizedBox(), - ], - ), - ); - }, + return SafeArea( + child: BottomSheet( + constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.85), + animationController: BottomSheet.createAnimationController(this), + enableDrag: false, + onClosing: widget.onClosing, + builder: (context) { + return Padding( + padding: widget.padding, + child: Column( + mainAxisSize: widget.mainAxisSize, + children: [ + widget.header, + widget.builder(context), + widget.bottomWidget ?? const SizedBox(), + ], + ), + ); + }, + ), ); } } diff --git a/packages/helpwave_widget/lib/src/bottom_sheets/index.dart b/packages/helpwave_widget/lib/src/bottom_sheets/index.dart new file mode 100644 index 00000000..e1423f66 --- /dev/null +++ b/packages/helpwave_widget/lib/src/bottom_sheets/index.dart @@ -0,0 +1,2 @@ +export 'bottom_sheet_base.dart'; +export 'nested_bottom_sheet_navigation.dart'; diff --git a/packages/helpwave_widget/lib/src/bottom_sheets/nested_bottom_sheet_navigation.dart b/packages/helpwave_widget/lib/src/bottom_sheets/nested_bottom_sheet_navigation.dart new file mode 100644 index 00000000..af15be7e --- /dev/null +++ b/packages/helpwave_widget/lib/src/bottom_sheets/nested_bottom_sheet_navigation.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:helpwave_widget/src/bottom_sheets/bottom_sheet_base.dart'; + +mixin BottomSheetPageBuilder { + /// The [BottomSheetHeader] of the [BottomSheetPageBuilder] + /// + /// The leading icon will always be ignored to + BottomSheetHeader? headerBuilder(BuildContext context, NestedBottomSheetNavigationController controller) { + return null; + } + + /// The [BottomSheetBase] bottom widget to display for the [BottomSheetPageBuilder] + Widget? bottomWidgetBuilder(BuildContext context, NestedBottomSheetNavigationController controller) { + return null; + } + + /// The builder function call to build the content of the [BottomSheetBase] + Widget build(BuildContext context, NestedBottomSheetNavigationController controller); +} + +class NestedBottomSheetNavigationController extends ChangeNotifier { + List stack = []; + + BottomSheetPageBuilder get currentPage => stack.last; + + bool get canPop => stack.length > 1; + + bool get isInitialPage => stack.length == 1; + + NestedBottomSheetNavigationController({ + required BottomSheetPageBuilder initialPageBuilder, + }) { + stack.add(initialPageBuilder); + } + + void push(BottomSheetPageBuilder page) { + stack.add(page); + notifyListeners(); + } + + void pop() { + stack.removeLast(); + notifyListeners(); + } +} + +class NestedBottomSheetNavigator extends StatelessWidget { + final BottomSheetPageBuilder initialPageBuilder; + + const NestedBottomSheetNavigator({super.key, required this.initialPageBuilder}); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (context) => NestedBottomSheetNavigationController( + initialPageBuilder: initialPageBuilder, + ), + child: Consumer( + builder: (context, navigationController, child) { + BottomSheetPageBuilder page = navigationController.currentPage; + + BottomSheetHeader? computedHeader = page.headerBuilder(context, navigationController); + + BottomSheetHeader header = BottomSheetHeader( + title: computedHeader?.title, + titleText: computedHeader?.titleText, + isShowingDragHandler: computedHeader?.isShowingDragHandler ?? false, + trailing: computedHeader?.trailing, + leading: BottomSheetAction( + icon: navigationController.isInitialPage ? Icons.close : Icons.chevron_left_rounded, + onPressed: () { + if (navigationController.canPop) { + navigationController.pop(); + } else { + Navigator.pop(context); + } + }, + ), + ); + + return PopScope( + // If the navigation controller cannot pop to another bottom sheet, a normal pop is correct + canPop: !navigationController.canPop, + onPopInvoked: (didPop) { + if(navigationController.canPop){ + navigationController.pop(); + } + }, + child: BottomSheetBase( + onClosing: () {}, + builder: (context) => page.build(context, navigationController), + header: header, + bottomWidget: page.bottomWidgetBuilder(context, navigationController), + mainAxisSize: MainAxisSize.max, + ), + ); + }, + ), + ); + } +} diff --git a/packages/helpwave_widget/lib/src/widgets/fallback_avatar.dart b/packages/helpwave_widget/lib/src/widgets/fallback_avatar.dart new file mode 100644 index 00000000..678fcd1e --- /dev/null +++ b/packages/helpwave_widget/lib/src/widgets/fallback_avatar.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:helpwave_theme/constants.dart'; + +/// A [CircleAvatar] with a dummy image that can be sized as needed +class FallbackAvatar extends StatelessWidget{ + /// The size of the [CircleAvatar] + final double size; + + const FallbackAvatar({super.key, this.size = iconSizeSmall}); + + @override + Widget build(BuildContext context) { + return CircleAvatar( + backgroundColor: Colors.transparent, + radius: iconSizeSmall / 2, + child: Container( + width: iconSizeSmall, + height: iconSizeSmall, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment(0.8, 1), + colors: [ + Color(0xff1f005c), + Color(0xff5b0060), + Color(0xff870160), + Color(0xffac255e), + Color(0xffca485c), + Color(0xffe16b5c), + Color(0xfff39060), + Color(0xffffb56b), + ], + tileMode: TileMode.mirror, + ), + ), + ), + ); + } +} diff --git a/packages/helpwave_widget/lib/src/widgets/index.dart b/packages/helpwave_widget/lib/src/widgets/index.dart new file mode 100644 index 00000000..5cbf43ab --- /dev/null +++ b/packages/helpwave_widget/lib/src/widgets/index.dart @@ -0,0 +1,2 @@ +export 'fallback_avatar.dart'; +export 'list_tile_card.dart'; diff --git a/packages/helpwave_widget/lib/src/widgets/widgets.dart b/packages/helpwave_widget/lib/src/widgets/list_tile_card.dart similarity index 100% rename from packages/helpwave_widget/lib/src/widgets/widgets.dart rename to packages/helpwave_widget/lib/src/widgets/list_tile_card.dart diff --git a/packages/helpwave_widget/lib/widgets.dart b/packages/helpwave_widget/lib/widgets.dart index df63dd05..8dfcb136 100644 --- a/packages/helpwave_widget/lib/widgets.dart +++ b/packages/helpwave_widget/lib/widgets.dart @@ -1 +1 @@ -export 'package:helpwave_widget/src/widgets/widgets.dart'; +export 'package:helpwave_widget/src/widgets/index.dart'; diff --git a/packages/helpwave_widget/pubspec.yaml b/packages/helpwave_widget/pubspec.yaml index bf60e6d9..22e6edf3 100644 --- a/packages/helpwave_widget/pubspec.yaml +++ b/packages/helpwave_widget/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: path: "../helpwave_localization" helpwave_util: path: "../helpwave_util" + provider: ^6.0.3 dev_dependencies: flutter_test: