mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
chore: Merge branch 'main' into develop
This commit is contained in:
@ -77,10 +77,7 @@ class CellController<T, D> extends Equatable {
|
||||
_cellListener?.start(
|
||||
onCellChanged: (result) {
|
||||
result.fold(
|
||||
(_) {
|
||||
_cellCache.remove(_cacheKey);
|
||||
_loadData();
|
||||
},
|
||||
(_) => _loadData(),
|
||||
(err) => Log.error(err),
|
||||
);
|
||||
},
|
||||
@ -174,8 +171,8 @@ class CellController<T, D> extends Equatable {
|
||||
|
||||
void _loadData() {
|
||||
_saveDataOperation?.cancel();
|
||||
|
||||
_loadDataOperation?.cancel();
|
||||
|
||||
_loadDataOperation = Timer(const Duration(milliseconds: 10), () {
|
||||
_cellDataLoader.loadData().then((data) {
|
||||
if (data != null) {
|
||||
@ -183,7 +180,6 @@ class CellController<T, D> extends Equatable {
|
||||
} else {
|
||||
_cellCache.remove(_cacheKey);
|
||||
}
|
||||
|
||||
_cellDataNotifier?.value = data;
|
||||
});
|
||||
});
|
||||
|
@ -55,7 +55,7 @@ class CellControllerBuilder {
|
||||
case FieldType.Number:
|
||||
final cellDataLoader = CellDataLoader(
|
||||
cellId: _cellId,
|
||||
parser: StringCellDataParser(),
|
||||
parser: NumberCellDataParser(),
|
||||
reloadOnFieldChanged: true,
|
||||
);
|
||||
return NumberCellController(
|
||||
|
@ -27,7 +27,12 @@ class CellDataLoader<T> {
|
||||
(result) => result.fold(
|
||||
(CellPB cell) {
|
||||
try {
|
||||
return parser.parserData(cell.data);
|
||||
// Return null the data of the cell is empty.
|
||||
if (cell.data.isEmpty) {
|
||||
return null;
|
||||
} else {
|
||||
return parser.parserData(cell.data);
|
||||
}
|
||||
} catch (e, s) {
|
||||
Log.error('$parser parser cellData failed, $e');
|
||||
Log.error('Stack trace \n $s');
|
||||
@ -51,6 +56,13 @@ class StringCellDataParser implements CellDataParser<String> {
|
||||
}
|
||||
}
|
||||
|
||||
class NumberCellDataParser implements CellDataParser<String> {
|
||||
@override
|
||||
String? parserData(List<int> data) {
|
||||
return utf8.decode(data);
|
||||
}
|
||||
}
|
||||
|
||||
class DateCellDataParser implements CellDataParser<DateCellDataPB> {
|
||||
@override
|
||||
DateCellDataPB? parserData(List<int> data) {
|
||||
|
@ -45,7 +45,12 @@ class DateCellDataPersistence implements CellDataPersistence<DateCellData> {
|
||||
Future<Option<FlowyError>> save(DateCellData data) {
|
||||
var payload = DateChangesetPB.create()..cellPath = _makeCellPath(cellId);
|
||||
|
||||
final date = (data.date.millisecondsSinceEpoch ~/ 1000).toString();
|
||||
// This is a bit of a hack. This converts the data.date which is in
|
||||
// UTC to Local but actually changes the timestamp instead of just
|
||||
// changing the isUtc flag
|
||||
final dateTime = DateTime(data.date.year, data.date.month, data.date.day);
|
||||
|
||||
final date = (dateTime.millisecondsSinceEpoch ~/ 1000).toString();
|
||||
payload.date = date;
|
||||
payload.isUtc = data.date.isUtc;
|
||||
payload.includeTime = data.includeTime;
|
||||
|
@ -116,7 +116,7 @@ class DatabaseController {
|
||||
}
|
||||
}
|
||||
|
||||
void addListener({
|
||||
void setListener({
|
||||
DatabaseCallbacks? onDatabaseChanged,
|
||||
LayoutCallbacks? onLayoutChanged,
|
||||
GroupCallbacks? onGroupChanged,
|
||||
@ -212,6 +212,11 @@ class DatabaseController {
|
||||
await _databaseViewBackendSvc.closeView();
|
||||
await fieldController.dispose();
|
||||
await groupListener.stop();
|
||||
await _viewCache.dispose();
|
||||
_databaseCallbacks = null;
|
||||
_groupCallbacks = null;
|
||||
_layoutCallbacks = null;
|
||||
_calendarLayoutCallbacks = null;
|
||||
}
|
||||
|
||||
Future<void> _loadGroups() async {
|
||||
@ -252,7 +257,7 @@ class DatabaseController {
|
||||
_databaseCallbacks?.onRowsCreated?.call(ids);
|
||||
},
|
||||
);
|
||||
_viewCache.addListener(callbacks);
|
||||
_viewCache.setListener(callbacks);
|
||||
}
|
||||
|
||||
void _listenOnFieldsChanged() {
|
||||
@ -337,9 +342,10 @@ class RowDataBuilder {
|
||||
_cellDataByFieldId[fieldInfo.field.id] = num.toString();
|
||||
}
|
||||
|
||||
/// The date should use the UTC timezone. Becuase the backend uses UTC timezone to format the time string.
|
||||
void insertDate(FieldInfo fieldInfo, DateTime date) {
|
||||
assert(fieldInfo.fieldType == FieldType.DateTime);
|
||||
final timestamp = (date.millisecondsSinceEpoch ~/ 1000);
|
||||
final timestamp = (date.toUtc().millisecondsSinceEpoch ~/ 1000);
|
||||
_cellDataByFieldId[fieldInfo.field.id] = timestamp.toString();
|
||||
}
|
||||
|
||||
|
@ -112,9 +112,10 @@ class DatabaseViewCache {
|
||||
Future<void> dispose() async {
|
||||
await _databaseViewListener.stop();
|
||||
await _rowCache.dispose();
|
||||
_callbacks = null;
|
||||
}
|
||||
|
||||
void addListener(DatabaseViewCallbacks callbacks) {
|
||||
void setListener(DatabaseViewCallbacks callbacks) {
|
||||
_callbacks = callbacks;
|
||||
}
|
||||
}
|
||||
|
@ -237,7 +237,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
|
||||
},
|
||||
);
|
||||
|
||||
_databaseController.addListener(
|
||||
_databaseController.setListener(
|
||||
onDatabaseChanged: onDatabaseChanged,
|
||||
onGroupChanged: onGroupChanged,
|
||||
);
|
||||
|
@ -78,7 +78,7 @@ class BoardContent extends StatefulWidget {
|
||||
|
||||
class _BoardContentState extends State<BoardContent> {
|
||||
late AppFlowyBoardScrollController scrollManager;
|
||||
final cardConfiguration = CardConfiguration<String>();
|
||||
final renderHook = RowCardRenderHook<String>();
|
||||
|
||||
final config = const AppFlowyBoardConfig(
|
||||
groupBackgroundColor: Color(0xffF7F8FC),
|
||||
@ -87,7 +87,7 @@ class _BoardContentState extends State<BoardContent> {
|
||||
@override
|
||||
void initState() {
|
||||
scrollManager = AppFlowyBoardScrollController();
|
||||
cardConfiguration.addSelectOptionHook((options, groupId) {
|
||||
renderHook.addSelectOptionHook((options, groupId, _) {
|
||||
// The cell should hide if the option id is equal to the groupId.
|
||||
final isInGroup =
|
||||
options.where((element) => element.id == groupId).isNotEmpty;
|
||||
@ -254,15 +254,15 @@ class _BoardContentState extends State<BoardContent> {
|
||||
key: ValueKey(groupItemId),
|
||||
margin: config.cardPadding,
|
||||
decoration: _makeBoxDecoration(context),
|
||||
child: Card<String>(
|
||||
child: RowCard<String>(
|
||||
row: rowPB,
|
||||
viewId: viewId,
|
||||
rowCache: rowCache,
|
||||
cardData: groupData.group.groupId,
|
||||
fieldId: groupItem.fieldInfo.id,
|
||||
groupingFieldId: groupItem.fieldInfo.id,
|
||||
isEditing: isEditing,
|
||||
cellBuilder: cellBuilder,
|
||||
configuration: cardConfiguration,
|
||||
renderHook: renderHook,
|
||||
openCard: (context) => _openCard(
|
||||
viewId,
|
||||
fieldController,
|
||||
|
@ -47,7 +47,13 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
|
||||
emit(state.copyWith(database: Some(database)));
|
||||
},
|
||||
didLoadAllEvents: (events) {
|
||||
emit(state.copyWith(initialEvents: events, allEvents: events));
|
||||
final calenderEvents = _calendarEventDataFromEventPBs(events);
|
||||
emit(
|
||||
state.copyWith(
|
||||
initialEvents: calenderEvents,
|
||||
allEvents: calenderEvents,
|
||||
),
|
||||
);
|
||||
},
|
||||
didReceiveNewLayoutField: (CalendarLayoutSettingPB layoutSettings) {
|
||||
_loadAllEvents();
|
||||
@ -56,6 +62,11 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
|
||||
createEvent: (DateTime date, String title) async {
|
||||
await _createEvent(date, title);
|
||||
},
|
||||
didCreateEvent: (CalendarEventData<CalendarDayEvent> event) {
|
||||
emit(
|
||||
state.copyWith(editEvent: event),
|
||||
);
|
||||
},
|
||||
updateCalendarLayoutSetting:
|
||||
(CalendarLayoutSettingPB layoutSetting) async {
|
||||
await _updateCalendarLayoutSetting(layoutSetting);
|
||||
@ -63,7 +74,7 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
|
||||
didUpdateEvent: (CalendarEventData<CalendarDayEvent> eventData) {
|
||||
var allEvents = [...state.allEvents];
|
||||
final index = allEvents.indexWhere(
|
||||
(element) => element.event!.cellId == eventData.event!.cellId,
|
||||
(element) => element.event!.eventId == eventData.event!.eventId,
|
||||
);
|
||||
if (index != -1) {
|
||||
allEvents[index] = eventData;
|
||||
@ -71,22 +82,13 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
allEvents: allEvents,
|
||||
updateEvent: eventData,
|
||||
),
|
||||
);
|
||||
},
|
||||
didReceiveNewEvent: (CalendarEventData<CalendarDayEvent> event) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
allEvents: [...state.allEvents, event],
|
||||
newEvent: event,
|
||||
),
|
||||
);
|
||||
},
|
||||
didDeleteEvents: (List<RowId> deletedRowIds) {
|
||||
var events = [...state.allEvents];
|
||||
events.retainWhere(
|
||||
(element) => !deletedRowIds.contains(element.event!.cellId.rowId),
|
||||
(element) => !deletedRowIds.contains(element.event!.eventId),
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -95,11 +97,25 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
didReceiveEvent: (CalendarEventData<CalendarDayEvent> event) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
allEvents: [...state.allEvents, event],
|
||||
newEvent: event,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _databaseController.dispose();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
FieldInfo? _getCalendarFieldInfo(String fieldId) {
|
||||
final fieldInfos = _databaseController.fieldController.fieldInfos;
|
||||
final index = fieldInfos.indexWhere(
|
||||
@ -143,17 +159,27 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
|
||||
final dateField = _getCalendarFieldInfo(settings.fieldId);
|
||||
final titleField = _getTitleFieldInfo();
|
||||
if (dateField != null && titleField != null) {
|
||||
final result = await _databaseController.createRow(
|
||||
final newRow = await _databaseController.createRow(
|
||||
withCells: (builder) {
|
||||
builder.insertDate(dateField, date);
|
||||
builder.insertText(titleField, title);
|
||||
},
|
||||
).then(
|
||||
(result) => result.fold(
|
||||
(newRow) => newRow,
|
||||
(err) {
|
||||
Log.error(err);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return result.fold(
|
||||
(newRow) {},
|
||||
(err) => Log.error(err),
|
||||
);
|
||||
if (newRow != null) {
|
||||
final event = await _loadEvent(newRow.id);
|
||||
if (event != null && !isClosed) {
|
||||
add(CalendarEvent.didCreateEvent(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -187,15 +213,7 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
|
||||
result.fold(
|
||||
(events) {
|
||||
if (!isClosed) {
|
||||
final calendarEvents = <CalendarEventData<CalendarDayEvent>>[];
|
||||
for (final eventPB in events.items) {
|
||||
final calendarEvent = _calendarEventDataFromEventPB(eventPB);
|
||||
if (calendarEvent != null) {
|
||||
calendarEvents.add(calendarEvent);
|
||||
}
|
||||
}
|
||||
|
||||
add(CalendarEvent.didLoadAllEvents(calendarEvents));
|
||||
add(CalendarEvent.didLoadAllEvents(events.items));
|
||||
}
|
||||
},
|
||||
(r) => Log.error(r),
|
||||
@ -203,22 +221,32 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
|
||||
});
|
||||
}
|
||||
|
||||
List<CalendarEventData<CalendarDayEvent>> _calendarEventDataFromEventPBs(
|
||||
List<CalendarEventPB> eventPBs,
|
||||
) {
|
||||
final calendarEvents = <CalendarEventData<CalendarDayEvent>>[];
|
||||
for (final eventPB in eventPBs) {
|
||||
final event = _calendarEventDataFromEventPB(eventPB);
|
||||
if (event != null) {
|
||||
calendarEvents.add(event);
|
||||
}
|
||||
}
|
||||
return calendarEvents;
|
||||
}
|
||||
|
||||
CalendarEventData<CalendarDayEvent>? _calendarEventDataFromEventPB(
|
||||
CalendarEventPB eventPB,
|
||||
) {
|
||||
final fieldInfo = fieldInfoByFieldId[eventPB.titleFieldId];
|
||||
final fieldInfo = fieldInfoByFieldId[eventPB.dateFieldId];
|
||||
if (fieldInfo != null) {
|
||||
final cellId = CellIdentifier(
|
||||
viewId: viewId,
|
||||
rowId: eventPB.rowId,
|
||||
fieldInfo: fieldInfo,
|
||||
);
|
||||
|
||||
final eventData = CalendarDayEvent(
|
||||
event: eventPB,
|
||||
cellId: cellId,
|
||||
eventId: eventPB.rowId,
|
||||
dateFieldId: eventPB.dateFieldId,
|
||||
);
|
||||
|
||||
// The timestamp is using UTC in the backend, so we need to convert it
|
||||
// to local time.
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(
|
||||
eventPB.timestamp.toInt() * 1000,
|
||||
);
|
||||
@ -243,25 +271,29 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
|
||||
for (var fieldInfo in fieldInfos) fieldInfo.field.id: fieldInfo
|
||||
};
|
||||
},
|
||||
onRowsChanged: ((onRowsChanged, rowByRowId, reason) {}),
|
||||
onRowsCreated: ((ids) async {
|
||||
for (final id in ids) {
|
||||
onRowsCreated: ((rowIds) async {
|
||||
for (final id in rowIds) {
|
||||
final event = await _loadEvent(id);
|
||||
if (event != null && !isClosed) {
|
||||
add(CalendarEvent.didReceiveNewEvent(event));
|
||||
add(CalendarEvent.didReceiveEvent(event));
|
||||
}
|
||||
}
|
||||
}),
|
||||
onRowsDeleted: (ids) {
|
||||
onRowsDeleted: (rowIds) {
|
||||
if (isClosed) return;
|
||||
add(CalendarEvent.didDeleteEvents(ids));
|
||||
add(CalendarEvent.didDeleteEvents(rowIds));
|
||||
},
|
||||
onRowsUpdated: (ids) async {
|
||||
onRowsUpdated: (rowIds) async {
|
||||
if (isClosed) return;
|
||||
for (final id in ids) {
|
||||
for (final id in rowIds) {
|
||||
final event = await _loadEvent(id);
|
||||
if (event != null) {
|
||||
add(CalendarEvent.didUpdateEvent(event));
|
||||
if (event != null && isEventDayChanged(event)) {
|
||||
if (isEventDayChanged(event)) {
|
||||
add(CalendarEvent.didDeleteEvents([id]));
|
||||
add(CalendarEvent.didReceiveEvent(event));
|
||||
} else {
|
||||
add(CalendarEvent.didUpdateEvent(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -276,7 +308,7 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
|
||||
onCalendarLayoutChanged: _didReceiveNewLayoutField,
|
||||
);
|
||||
|
||||
_databaseController.addListener(
|
||||
_databaseController.setListener(
|
||||
onDatabaseChanged: onDatabaseChanged,
|
||||
onLayoutChanged: onLayoutChanged,
|
||||
onCalendarLayoutChanged: onCalendarLayoutFieldChanged,
|
||||
@ -296,6 +328,19 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
|
||||
add(CalendarEvent.didReceiveNewLayoutField(layoutSetting.calendar));
|
||||
}
|
||||
}
|
||||
|
||||
bool isEventDayChanged(
|
||||
CalendarEventData<CalendarDayEvent> event,
|
||||
) {
|
||||
final index = state.allEvents.indexWhere(
|
||||
(element) => element.event!.eventId == event.event!.eventId,
|
||||
);
|
||||
if (index != -1) {
|
||||
return state.allEvents[index].date.day != event.date.day;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typedef Events = List<CalendarEventData<CalendarDayEvent>>;
|
||||
@ -310,7 +355,7 @@ class CalendarEvent with _$CalendarEvent {
|
||||
) = _ReceiveCalendarSettings;
|
||||
|
||||
// Called after loading all the current evnets
|
||||
const factory CalendarEvent.didLoadAllEvents(Events events) =
|
||||
const factory CalendarEvent.didLoadAllEvents(List<CalendarEventPB> events) =
|
||||
_ReceiveCalendarEvents;
|
||||
|
||||
// Called when specific event was updated
|
||||
@ -319,10 +364,15 @@ class CalendarEvent with _$CalendarEvent {
|
||||
) = _DidUpdateEvent;
|
||||
|
||||
// Called after creating a new event
|
||||
const factory CalendarEvent.didReceiveNewEvent(
|
||||
const factory CalendarEvent.didCreateEvent(
|
||||
CalendarEventData<CalendarDayEvent> event,
|
||||
) = _DidReceiveNewEvent;
|
||||
|
||||
// Called when receive a new event
|
||||
const factory CalendarEvent.didReceiveEvent(
|
||||
CalendarEventData<CalendarDayEvent> event,
|
||||
) = _DidReceiveEvent;
|
||||
|
||||
// Called when deleting events
|
||||
const factory CalendarEvent.didDeleteEvents(List<RowId> rowIds) =
|
||||
_DidDeleteEvents;
|
||||
@ -348,11 +398,13 @@ class CalendarEvent with _$CalendarEvent {
|
||||
class CalendarState with _$CalendarState {
|
||||
const factory CalendarState({
|
||||
required Option<DatabasePB> database,
|
||||
// events by row id
|
||||
required Events allEvents,
|
||||
required Events initialEvents,
|
||||
CalendarEventData<CalendarDayEvent>? editEvent,
|
||||
CalendarEventData<CalendarDayEvent>? newEvent,
|
||||
required List<RowId> deleteEventIds,
|
||||
CalendarEventData<CalendarDayEvent>? updateEvent,
|
||||
required List<String> deleteEventIds,
|
||||
required Option<CalendarLayoutSettingPB> settings,
|
||||
required DatabaseLoadingState loadingState,
|
||||
required Option<FlowyError> noneOrError,
|
||||
@ -389,8 +441,12 @@ class CalendarEditingRow {
|
||||
|
||||
class CalendarDayEvent {
|
||||
final CalendarEventPB event;
|
||||
final CellIdentifier cellId;
|
||||
final String dateFieldId;
|
||||
final String eventId;
|
||||
|
||||
RowId get eventId => cellId.rowId;
|
||||
CalendarDayEvent({required this.cellId, required this.event});
|
||||
CalendarDayEvent({
|
||||
required this.dateFieldId,
|
||||
required this.eventId,
|
||||
required this.event,
|
||||
});
|
||||
}
|
||||
|
@ -1,7 +1,10 @@
|
||||
import 'package:appflowy/plugins/database_view/application/row/row_cache.dart';
|
||||
import 'package:appflowy/plugins/database_view/application/row/row_data_controller.dart';
|
||||
import 'package:appflowy/plugins/database_view/widgets/card/card.dart';
|
||||
import 'package:appflowy/plugins/database_view/widgets/card/card_cell_builder.dart';
|
||||
import 'package:appflowy/plugins/database_view/widgets/card/cells/text_card_cell.dart';
|
||||
import 'package:appflowy/plugins/database_view/widgets/card/cells/card_cell.dart';
|
||||
import 'package:appflowy/plugins/database_view/widgets/card/cells/number_card_cell.dart';
|
||||
import 'package:appflowy/plugins/database_view/widgets/card/cells/url_card_cell.dart';
|
||||
import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart';
|
||||
import 'package:appflowy/plugins/database_view/widgets/row/row_detail.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
|
||||
@ -10,11 +13,11 @@ import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../grid/presentation/layout/sizes.dart';
|
||||
import '../../widgets/row/cells/select_option_cell/extension.dart';
|
||||
import '../application/calendar_bloc.dart';
|
||||
|
||||
class CalendarDayCard extends StatelessWidget {
|
||||
@ -23,11 +26,10 @@ class CalendarDayCard extends StatelessWidget {
|
||||
final bool isInMonth;
|
||||
final DateTime date;
|
||||
final RowCache _rowCache;
|
||||
final CardCellBuilder _cellBuilder;
|
||||
final List<CalendarDayEvent> events;
|
||||
final void Function(DateTime) onCreateEvent;
|
||||
|
||||
CalendarDayCard({
|
||||
const CalendarDayCard({
|
||||
required this.viewId,
|
||||
required this.isToday,
|
||||
required this.isInMonth,
|
||||
@ -37,7 +39,6 @@ class CalendarDayCard extends StatelessWidget {
|
||||
required this.events,
|
||||
Key? key,
|
||||
}) : _rowCache = rowCache,
|
||||
_cellBuilder = CardCellBuilder(rowCache.cellCache),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
@ -49,65 +50,183 @@ class CalendarDayCard extends StatelessWidget {
|
||||
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) => _CardEnterNotifier(),
|
||||
builder: ((context, child) {
|
||||
final children = events.map((event) {
|
||||
return _DayEventCell(
|
||||
event: event,
|
||||
viewId: viewId,
|
||||
onClick: () => _showRowDetailPage(event, context),
|
||||
child: _cellBuilder.buildCell(
|
||||
cellId: event.cellId,
|
||||
styles: {FieldType.RichText: TextCardCellStyle(10)},
|
||||
builder: (context, child) {
|
||||
Widget? multipleCards;
|
||||
if (events.isNotEmpty) {
|
||||
multipleCards = Flexible(
|
||||
child: ListView.separated(
|
||||
itemBuilder: (BuildContext context, int index) =>
|
||||
_buildCard(context, events[index]),
|
||||
itemCount: events.length,
|
||||
padding: const EdgeInsets.fromLTRB(8.0, 0, 8.0, 8.0),
|
||||
separatorBuilder: (BuildContext context, int index) =>
|
||||
VSpace(GridSize.typeOptionSeparatorHeight),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
final child = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: _Header(
|
||||
date: date,
|
||||
isInMonth: isInMonth,
|
||||
isToday: isToday,
|
||||
onCreate: () => onCreateEvent(date),
|
||||
),
|
||||
_Header(
|
||||
date: date,
|
||||
isInMonth: isInMonth,
|
||||
isToday: isToday,
|
||||
onCreate: () => onCreateEvent(date),
|
||||
),
|
||||
|
||||
// Add a separator between the header and the content.
|
||||
VSpace(GridSize.typeOptionSeparatorHeight),
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return children[index];
|
||||
},
|
||||
itemCount: children.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
separatorBuilder: (BuildContext context, int index) =>
|
||||
VSpace(GridSize.typeOptionSeparatorHeight),
|
||||
),
|
||||
),
|
||||
|
||||
// Use SizedBox instead of ListView if there are no cards.
|
||||
multipleCards ?? const SizedBox(),
|
||||
],
|
||||
);
|
||||
|
||||
return Container(
|
||||
color: backgroundColor,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (p) => notifyEnter(context, true),
|
||||
onExit: (p) => notifyEnter(context, false),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: child,
|
||||
child: GestureDetector(
|
||||
onDoubleTap: () => onCreateEvent(date),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.basic,
|
||||
onEnter: (p) => notifyEnter(context, true),
|
||||
onExit: (p) => notifyEnter(context, false),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
GestureDetector _buildCard(BuildContext context, CalendarDayEvent event) {
|
||||
final styles = <FieldType, CardCellStyle>{
|
||||
FieldType.Number: NumberCardCellStyle(10),
|
||||
FieldType.URL: URLCardCellStyle(10),
|
||||
};
|
||||
|
||||
final cellBuilder = CardCellBuilder<String>(
|
||||
_rowCache.cellCache,
|
||||
styles: styles,
|
||||
);
|
||||
|
||||
final rowInfo = _rowCache.getRow(event.eventId);
|
||||
final renderHook = RowCardRenderHook<String>();
|
||||
renderHook.addTextCellHook((cellData, primaryFieldId, _) {
|
||||
if (cellData.isEmpty) {
|
||||
return const SizedBox();
|
||||
}
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FlowyText.medium(
|
||||
cellData,
|
||||
textAlign: TextAlign.left,
|
||||
fontSize: 11,
|
||||
maxLines: null, // Enable multiple lines
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
renderHook.addDateCellHook((cellData, cardData, _) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 3,
|
||||
child: FlowyText.regular(
|
||||
cellData.date,
|
||||
fontSize: 10,
|
||||
color: Theme.of(context).hintColor,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: FlowyText.regular(
|
||||
cellData.time,
|
||||
fontSize: 10,
|
||||
color: Theme.of(context).hintColor,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
renderHook.addSelectOptionHook((selectedOptions, cardData, _) {
|
||||
final children = selectedOptions.map(
|
||||
(option) {
|
||||
return SelectOptionTag.fromOption(
|
||||
context: context,
|
||||
option: option,
|
||||
);
|
||||
},
|
||||
).toList();
|
||||
|
||||
return IntrinsicHeight(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: SizedBox.expand(
|
||||
child: Wrap(spacing: 4, runSpacing: 4, children: children),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// renderHook.addDateFieldHook((cellData, cardData) {
|
||||
|
||||
final card = RowCard<String>(
|
||||
// Add the key here to make sure the card is rebuilt when the cells
|
||||
// in this row are updated.
|
||||
key: ValueKey(event.eventId),
|
||||
row: rowInfo!.rowPB,
|
||||
viewId: viewId,
|
||||
rowCache: _rowCache,
|
||||
cardData: event.dateFieldId,
|
||||
isEditing: false,
|
||||
cellBuilder: cellBuilder,
|
||||
openCard: (context) => _showRowDetailPage(event, context),
|
||||
styleConfiguration: const RowCardStyleConfiguration(
|
||||
showAccessory: false,
|
||||
cellPadding: EdgeInsets.zero,
|
||||
),
|
||||
renderHook: renderHook,
|
||||
onStartEditing: () {},
|
||||
onEndEditing: () {},
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _showRowDetailPage(event, context),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.fromBorderSide(
|
||||
BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
borderRadius: Corners.s6Border,
|
||||
),
|
||||
child: card,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showRowDetailPage(CalendarDayEvent event, BuildContext context) {
|
||||
final dataController = RowController(
|
||||
rowId: event.cellId.rowId,
|
||||
rowId: event.eventId,
|
||||
viewId: viewId,
|
||||
rowCache: _rowCache,
|
||||
);
|
||||
@ -133,42 +252,6 @@ class CalendarDayCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _DayEventCell extends StatelessWidget {
|
||||
final String viewId;
|
||||
final CalendarDayEvent event;
|
||||
final VoidCallback onClick;
|
||||
final Widget child;
|
||||
const _DayEventCell({
|
||||
required this.viewId,
|
||||
required this.event,
|
||||
required this.onClick,
|
||||
required this.child,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyHover(
|
||||
child: GestureDetector(
|
||||
onTap: onClick,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.fromBorderSide(
|
||||
BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
borderRadius: Corners.s6Border,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
final bool isToday;
|
||||
final bool isInMonth;
|
||||
@ -191,12 +274,16 @@ class _Header extends StatelessWidget {
|
||||
isInMonth: isInMonth,
|
||||
date: date,
|
||||
);
|
||||
return Row(
|
||||
children: [
|
||||
if (notifier.onEnter) _NewEventButton(onClick: onCreate),
|
||||
const Spacer(),
|
||||
badge,
|
||||
],
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (notifier.onEnter) _NewEventButton(onClick: onCreate),
|
||||
const Spacer(),
|
||||
badge,
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -215,10 +302,8 @@ class _NewEventButton extends StatelessWidget {
|
||||
return FlowyIconButton(
|
||||
onPressed: onClick,
|
||||
iconPadding: EdgeInsets.zero,
|
||||
icon: svgWidget(
|
||||
"home/add",
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
icon: const FlowySvg(name: "home/add"),
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
width: 22,
|
||||
);
|
||||
}
|
||||
@ -237,31 +322,38 @@ class _DayBadge extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color dayTextColor = Theme.of(context).colorScheme.onSurface;
|
||||
String dayString = date.day == 1
|
||||
? DateFormat('MMM d', context.locale.toLanguageTag()).format(date)
|
||||
: date.day.toString();
|
||||
Color dayTextColor = Theme.of(context).colorScheme.onBackground;
|
||||
String monthString =
|
||||
DateFormat("MMM ", context.locale.toLanguageTag()).format(date);
|
||||
String dayString = date.day.toString();
|
||||
|
||||
if (isToday) {
|
||||
dayTextColor = Theme.of(context).colorScheme.onPrimary;
|
||||
}
|
||||
if (!isInMonth) {
|
||||
dayTextColor = Theme.of(context).disabledColor;
|
||||
}
|
||||
if (isToday) {
|
||||
dayTextColor = Theme.of(context).colorScheme.onPrimary;
|
||||
}
|
||||
|
||||
Widget day = Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isToday ? Theme.of(context).colorScheme.primary : null,
|
||||
borderRadius: Corners.s6Border,
|
||||
),
|
||||
padding: GridSize.typeOptionContentInsets,
|
||||
child: FlowyText.medium(
|
||||
dayString,
|
||||
color: dayTextColor,
|
||||
),
|
||||
return Row(
|
||||
children: [
|
||||
if (date.day == 1) FlowyText.medium(monthString),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isToday ? Theme.of(context).colorScheme.primary : null,
|
||||
borderRadius: Corners.s6Border,
|
||||
),
|
||||
width: isToday ? 26 : null,
|
||||
height: isToday ? 26 : null,
|
||||
padding: GridSize.typeOptionContentInsets,
|
||||
child: Center(
|
||||
child: FlowyText.medium(
|
||||
dayString,
|
||||
color: dayTextColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return day;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -9,6 +9,9 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../application/row/row_data_controller.dart';
|
||||
import '../../widgets/row/cell_builder.dart';
|
||||
import '../../widgets/row/row_detail.dart';
|
||||
import 'calendar_day.dart';
|
||||
import 'layout/sizes.dart';
|
||||
import 'toolbar/calendar_toolbar.dart';
|
||||
@ -70,19 +73,16 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
},
|
||||
),
|
||||
BlocListener<CalendarBloc, CalendarState>(
|
||||
listenWhen: (p, c) => p.updateEvent != c.updateEvent,
|
||||
listenWhen: (p, c) => p.editEvent != c.editEvent,
|
||||
listener: (context, state) {
|
||||
if (state.updateEvent != null) {
|
||||
_eventController.removeWhere(
|
||||
(element) =>
|
||||
state.updateEvent!.event!.eventId ==
|
||||
element.event!.eventId,
|
||||
);
|
||||
_eventController.add(state.updateEvent!);
|
||||
if (state.editEvent != null) {
|
||||
_showEditEventPage(state.editEvent!.event!, context);
|
||||
}
|
||||
},
|
||||
),
|
||||
BlocListener<CalendarBloc, CalendarState>(
|
||||
// Event create by click the + button or double click on the
|
||||
// calendar
|
||||
listenWhen: (p, c) => p.newEvent != c.newEvent,
|
||||
listener: (context, state) {
|
||||
if (state.newEvent != null) {
|
||||
@ -116,7 +116,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
child: MonthView(
|
||||
key: _calendarState,
|
||||
controller: _eventController,
|
||||
cellAspectRatio: .9,
|
||||
cellAspectRatio: .6,
|
||||
startDay: _weekdayFromInt(firstDayOfWeek),
|
||||
borderColor: Theme.of(context).dividerColor,
|
||||
headerBuilder: _headerNavigatorBuilder,
|
||||
@ -137,7 +137,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
FlowyIconButton(
|
||||
width: CalendarSize.navigatorButtonWidth,
|
||||
height: CalendarSize.navigatorButtonHeight,
|
||||
icon: svgWidget('home/arrow_left'),
|
||||
icon: const FlowySvg(name: 'home/arrow_left'),
|
||||
tooltipText: LocaleKeys.calendar_navigation_previousMonth.tr(),
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
onPressed: () => _calendarState?.currentState?.previousPage(),
|
||||
@ -155,7 +155,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
FlowyIconButton(
|
||||
width: CalendarSize.navigatorButtonWidth,
|
||||
height: CalendarSize.navigatorButtonHeight,
|
||||
icon: svgWidget('home/arrow_right'),
|
||||
icon: const FlowySvg(name: 'home/arrow_right'),
|
||||
tooltipText: LocaleKeys.calendar_navigation_nextMonth.tr(),
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
onPressed: () => _calendarState?.currentState?.nextPage(),
|
||||
@ -185,7 +185,12 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
isInMonth,
|
||||
) {
|
||||
final events = calenderEvents.map((value) => value.event!).toList();
|
||||
|
||||
// Sort the events by timestamp. Because the database view is not
|
||||
// reserving the order of the events. Reserving the order of the rows/events
|
||||
// is implemnted in the develop branch(WIP). Will be replaced with that.
|
||||
events.sort(
|
||||
(a, b) => a.event.timestamp.compareTo(b.event.timestamp),
|
||||
);
|
||||
return CalendarDayCard(
|
||||
viewId: widget.view.id,
|
||||
isToday: isToday,
|
||||
@ -208,4 +213,24 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
// MonthView places the first day of week on the second column for some reason.
|
||||
return WeekDays.values[(dayOfWeek + 1) % 7];
|
||||
}
|
||||
|
||||
void _showEditEventPage(CalendarDayEvent event, BuildContext context) {
|
||||
final dataController = RowController(
|
||||
rowId: event.eventId,
|
||||
viewId: widget.view.id,
|
||||
rowCache: _calendarBloc.rowCache,
|
||||
);
|
||||
|
||||
FlowyOverlay.show(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return RowDetailPage(
|
||||
cellBuilder: GridCellBuilder(
|
||||
cellCache: _calendarBloc.rowCache.cellCache,
|
||||
),
|
||||
dataController: dataController,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -87,7 +87,7 @@ class GridBloc extends Bloc<GridEvent, GridState> {
|
||||
}
|
||||
},
|
||||
);
|
||||
databaseController.addListener(onDatabaseChanged: onDatabaseChanged);
|
||||
databaseController.setListener(onDatabaseChanged: onDatabaseChanged);
|
||||
}
|
||||
|
||||
Future<void> _openGrid(Emitter<GridState> emit) async {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'dart:async';
|
||||
@ -7,31 +8,39 @@ import '../../../application/row/row_data_controller.dart';
|
||||
part 'row_detail_bloc.freezed.dart';
|
||||
|
||||
class RowDetailBloc extends Bloc<RowDetailEvent, RowDetailState> {
|
||||
final RowBackendService rowService;
|
||||
final RowController dataController;
|
||||
|
||||
RowDetailBloc({
|
||||
required this.dataController,
|
||||
}) : super(RowDetailState.initial()) {
|
||||
}) : rowService = RowBackendService(viewId: dataController.viewId),
|
||||
super(RowDetailState.initial()) {
|
||||
on<RowDetailEvent>(
|
||||
(event, emit) async {
|
||||
await event.map(
|
||||
initial: (_Initial value) async {
|
||||
await event.when(
|
||||
initial: () async {
|
||||
await _startListening();
|
||||
final cells = dataController.loadData();
|
||||
if (!isClosed) {
|
||||
add(RowDetailEvent.didReceiveCellDatas(cells.values.toList()));
|
||||
}
|
||||
},
|
||||
didReceiveCellDatas: (_DidReceiveCellDatas value) {
|
||||
emit(state.copyWith(gridCells: value.gridCells));
|
||||
didReceiveCellDatas: (cells) {
|
||||
emit(state.copyWith(gridCells: cells));
|
||||
},
|
||||
deleteField: (_DeleteField value) {
|
||||
deleteField: (fieldId) {
|
||||
final fieldService = FieldBackendService(
|
||||
viewId: dataController.viewId,
|
||||
fieldId: value.fieldId,
|
||||
fieldId: fieldId,
|
||||
);
|
||||
fieldService.deleteField();
|
||||
},
|
||||
deleteRow: (rowId) async {
|
||||
await rowService.deleteRow(rowId);
|
||||
},
|
||||
duplicateRow: (String rowId) async {
|
||||
await rowService.duplicateRow(rowId);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -58,6 +67,8 @@ class RowDetailBloc extends Bloc<RowDetailEvent, RowDetailState> {
|
||||
class RowDetailEvent with _$RowDetailEvent {
|
||||
const factory RowDetailEvent.initial() = _Initial;
|
||||
const factory RowDetailEvent.deleteField(String fieldId) = _DeleteField;
|
||||
const factory RowDetailEvent.deleteRow(String rowId) = _DeleteRow;
|
||||
const factory RowDetailEvent.duplicateRow(String rowId) = _DuplicateRow;
|
||||
const factory RowDetailEvent.didReceiveCellDatas(
|
||||
List<CellIdentifier> gridCells,
|
||||
) = _DidReceiveCellDatas;
|
||||
|
@ -147,8 +147,6 @@ class _FieldNameTextFieldState extends State<_FieldNameTextField> {
|
||||
widget.popoverMutex.listenOnPopoverChanged(() {
|
||||
if (focusNode.hasFocus) {
|
||||
focusNode.unfocus();
|
||||
} else {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
|
||||
@ -205,6 +203,7 @@ class _DeleteFieldButton extends StatelessWidget {
|
||||
builder: (context, state) {
|
||||
final enable = !state.canDelete && !state.isGroupField;
|
||||
Widget button = FlowyButton(
|
||||
disable: !enable,
|
||||
text: FlowyText.medium(
|
||||
LocaleKeys.grid_field_delete.tr(),
|
||||
color: enable ? null : Theme.of(context).disabledColor,
|
||||
|
@ -1,6 +1,5 @@
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -58,15 +57,12 @@ class FieldTypeCell extends StatelessWidget {
|
||||
return SizedBox(
|
||||
height: GridSize.popoverItemHeight,
|
||||
child: FlowyButton(
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
text: FlowyText.medium(
|
||||
fieldType.title(),
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
),
|
||||
onTap: () => onSelectField(fieldType),
|
||||
leftIcon: svgWidget(
|
||||
fieldType.iconName(),
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
leftIcon: FlowySvg(
|
||||
name: fieldType.iconName(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -4,7 +4,6 @@ import 'package:appflowy/plugins/database_view/application/field/type_option/typ
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:dartz/dartz.dart' show Either;
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
|
||||
@ -113,20 +112,12 @@ class _SwitchFieldButton extends StatelessWidget {
|
||||
Widget _buildMoreButton(BuildContext context) {
|
||||
final bloc = context.read<FieldTypeOptionEditBloc>();
|
||||
return FlowyButton(
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
text: FlowyText.medium(
|
||||
bloc.state.field.fieldType.title(),
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
),
|
||||
margin: GridSize.typeOptionContentInsets,
|
||||
leftIcon: svgWidget(
|
||||
bloc.state.field.fieldType.iconName(),
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
rightIcon: svgWidget(
|
||||
"grid/more",
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
leftIcon: FlowySvg(name: bloc.state.field.fieldType.iconName()),
|
||||
rightIcon: const FlowySvg(name: 'grid/more'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -186,6 +186,7 @@ class CreateFieldButton extends StatelessWidget {
|
||||
return AppFlowyPopover(
|
||||
direction: PopoverDirection.bottomWithRightAligned,
|
||||
asBarrier: true,
|
||||
margin: EdgeInsets.zero,
|
||||
constraints: BoxConstraints.loose(const Size(240, 600)),
|
||||
child: FlowyButton(
|
||||
radius: BorderRadius.zero,
|
||||
|
@ -1,7 +1,5 @@
|
||||
import 'package:appflowy/plugins/database_view/application/field/type_option/date_bloc.dart';
|
||||
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart';
|
||||
import 'package:easy_localization/easy_localization.dart' hide DateFormat;
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
@ -54,7 +52,6 @@ class DateTypeOptionWidget extends TypeOptionWidget {
|
||||
const TypeOptionSeparator(),
|
||||
_renderDateFormatButton(context, state.typeOption.dateFormat),
|
||||
_renderTimeFormatButton(context, state.typeOption.timeFormat),
|
||||
const _IncludeTimeButton(),
|
||||
];
|
||||
|
||||
return ListView.separated(
|
||||
@ -191,44 +188,6 @@ class TimeFormatButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _IncludeTimeButton extends StatelessWidget {
|
||||
const _IncludeTimeButton({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocSelector<DateTypeOptionBloc, DateTypeOptionState, bool>(
|
||||
selector: (state) => state.typeOption.includeTime,
|
||||
builder: (context, includeTime) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: SizedBox(
|
||||
height: GridSize.popoverItemHeight,
|
||||
child: Padding(
|
||||
padding: GridSize.typeOptionContentInsets,
|
||||
child: Row(
|
||||
children: [
|
||||
FlowyText.medium(LocaleKeys.grid_field_includeTime.tr()),
|
||||
const Spacer(),
|
||||
Toggle(
|
||||
value: includeTime,
|
||||
onChanged: (value) {
|
||||
context
|
||||
.read<DateTypeOptionBloc>()
|
||||
.add(DateTypeOptionEvent.includeTime(!value));
|
||||
},
|
||||
style: ToggleStyle.big,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DateFormatList extends StatelessWidget {
|
||||
final DateFormatPB selectedFormat;
|
||||
final Function(DateFormatPB format) onSelected;
|
||||
@ -280,7 +239,7 @@ class DateFormatCell extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
Widget? checkmark;
|
||||
if (isSelected) {
|
||||
checkmark = svgWidget("grid/checkmark");
|
||||
checkmark = const FlowySvg(name: 'grid/checkmark');
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
@ -364,7 +323,7 @@ class TimeFormatCell extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
Widget? checkmark;
|
||||
if (isSelected) {
|
||||
checkmark = svgWidget("grid/checkmark");
|
||||
checkmark = const FlowySvg(name: 'grid/checkmark');
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
|
@ -4,7 +4,6 @@ import 'package:appflowy/plugins/database_view/application/field/type_option/typ
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/number_entities.pbenum.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@ -60,15 +59,10 @@ class NumberTypeOptionWidget extends TypeOptionWidget {
|
||||
final selectNumUnitButton = SizedBox(
|
||||
height: GridSize.popoverItemHeight,
|
||||
child: FlowyButton(
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
margin: GridSize.typeOptionContentInsets,
|
||||
rightIcon: svgWidget(
|
||||
"grid/more",
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
),
|
||||
rightIcon: const FlowySvg(name: 'grid/more'),
|
||||
text: FlowyText.regular(
|
||||
state.typeOption.format.title(),
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -79,7 +73,6 @@ class NumberTypeOptionWidget extends TypeOptionWidget {
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FlowyText.medium(
|
||||
LocaleKeys.grid_field_numberFormat.tr(),
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
),
|
||||
);
|
||||
return Padding(
|
||||
@ -188,7 +181,9 @@ class NumberFormatCell extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
Widget? checkmark;
|
||||
if (isSelected) {
|
||||
checkmark = svgWidget("grid/checkmark");
|
||||
checkmark = const FlowySvg(
|
||||
name: 'grid/checkmark',
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
|
@ -105,15 +105,10 @@ class _DeleteTag extends StatelessWidget {
|
||||
return SizedBox(
|
||||
height: GridSize.popoverItemHeight,
|
||||
child: FlowyButton(
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
text: FlowyText.medium(
|
||||
LocaleKeys.grid_selectOption_deleteTag.tr(),
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
),
|
||||
leftIcon: svgWidget(
|
||||
"grid/delete",
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
leftIcon: const FlowySvg(name: 'grid/delete'),
|
||||
onTap: () {
|
||||
context
|
||||
.read<EditSelectOptionBloc>()
|
||||
@ -226,7 +221,11 @@ class _SelectOptionColorCell extends StatelessWidget {
|
||||
return SizedBox(
|
||||
height: GridSize.popoverItemHeight,
|
||||
child: FlowyButton(
|
||||
text: FlowyText.medium(color.optionName()),
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
text: FlowyText.medium(
|
||||
color.optionName(),
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
),
|
||||
leftIcon: colorIcon,
|
||||
rightIcon: checkmark,
|
||||
onTap: () {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
|
||||
import 'package:appflowy/plugins/database_view/application/row/row_cache.dart';
|
||||
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/row/action.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
|
||||
@ -13,23 +14,40 @@ import 'card_cell_builder.dart';
|
||||
import 'container/accessory.dart';
|
||||
import 'container/card_container.dart';
|
||||
|
||||
class Card<CustomCardData> extends StatefulWidget {
|
||||
/// Edit a database row with card style widget
|
||||
class RowCard<CustomCardData> extends StatefulWidget {
|
||||
final RowPB row;
|
||||
final String viewId;
|
||||
final String fieldId;
|
||||
final String? groupingFieldId;
|
||||
|
||||
/// Allows passing a custom card data object to the card. The card will be
|
||||
/// returned in the [CardCellBuilder] and can be used to build the card.
|
||||
final CustomCardData? cardData;
|
||||
final bool isEditing;
|
||||
final RowCache rowCache;
|
||||
final CardCellBuilder<CustomCardData> cellBuilder;
|
||||
final void Function(BuildContext) openCard;
|
||||
final VoidCallback onStartEditing;
|
||||
final VoidCallback onEndEditing;
|
||||
final CardConfiguration<CustomCardData>? configuration;
|
||||
|
||||
const Card({
|
||||
/// The [CardCellBuilder] is used to build the card cells.
|
||||
final CardCellBuilder<CustomCardData> cellBuilder;
|
||||
|
||||
/// Called when the user taps on the card.
|
||||
final void Function(BuildContext) openCard;
|
||||
|
||||
/// Called when the user starts editing the card.
|
||||
final VoidCallback onStartEditing;
|
||||
|
||||
/// Called when the user ends editing the card.
|
||||
final VoidCallback onEndEditing;
|
||||
|
||||
/// The [RowCardRenderHook] is used to render the card's cell. Other than
|
||||
/// using the default cell builder. For example the [SelectOptionCardCell]
|
||||
final RowCardRenderHook<CustomCardData>? renderHook;
|
||||
|
||||
final RowCardStyleConfiguration styleConfiguration;
|
||||
|
||||
const RowCard({
|
||||
required this.row,
|
||||
required this.viewId,
|
||||
required this.fieldId,
|
||||
this.groupingFieldId,
|
||||
required this.isEditing,
|
||||
required this.rowCache,
|
||||
required this.cellBuilder,
|
||||
@ -37,15 +55,19 @@ class Card<CustomCardData> extends StatefulWidget {
|
||||
required this.onStartEditing,
|
||||
required this.onEndEditing,
|
||||
this.cardData,
|
||||
this.configuration,
|
||||
this.styleConfiguration = const RowCardStyleConfiguration(
|
||||
showAccessory: true,
|
||||
),
|
||||
this.renderHook,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<Card<CustomCardData>> createState() => _CardState<CustomCardData>();
|
||||
State<RowCard<CustomCardData>> createState() =>
|
||||
_RowCardState<CustomCardData>();
|
||||
}
|
||||
|
||||
class _CardState<T> extends State<Card<T>> {
|
||||
class _RowCardState<T> extends State<RowCard<T>> {
|
||||
late CardBloc _cardBloc;
|
||||
late EditableRowNotifier rowNotifier;
|
||||
late PopoverController popoverController;
|
||||
@ -56,15 +78,15 @@ class _CardState<T> extends State<Card<T>> {
|
||||
rowNotifier = EditableRowNotifier(isEditing: widget.isEditing);
|
||||
_cardBloc = CardBloc(
|
||||
viewId: widget.viewId,
|
||||
groupFieldId: widget.fieldId,
|
||||
groupFieldId: widget.groupingFieldId,
|
||||
isEditing: widget.isEditing,
|
||||
row: widget.row,
|
||||
rowCache: widget.rowCache,
|
||||
)..add(const BoardCardEvent.initial());
|
||||
)..add(const RowCardEvent.initial());
|
||||
|
||||
rowNotifier.isEditing.addListener(() {
|
||||
if (!mounted) return;
|
||||
_cardBloc.add(BoardCardEvent.setIsEditing(rowNotifier.isEditing.value));
|
||||
_cardBloc.add(RowCardEvent.setIsEditing(rowNotifier.isEditing.value));
|
||||
|
||||
if (rowNotifier.isEditing.value) {
|
||||
widget.onStartEditing();
|
||||
@ -81,7 +103,7 @@ class _CardState<T> extends State<Card<T>> {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _cardBloc,
|
||||
child: BlocBuilder<CardBloc, BoardCardState>(
|
||||
child: BlocBuilder<CardBloc, RowCardState>(
|
||||
buildWhen: (previous, current) {
|
||||
// Rebuild when:
|
||||
// 1.If the length of the cells is not the same
|
||||
@ -106,21 +128,26 @@ class _CardState<T> extends State<Card<T>> {
|
||||
context,
|
||||
popoverContext,
|
||||
),
|
||||
child: BoardCardContainer(
|
||||
child: RowCardContainer(
|
||||
buildAccessoryWhen: () => state.isEditing == false,
|
||||
accessoryBuilder: (context) {
|
||||
return [
|
||||
_CardEditOption(rowNotifier: rowNotifier),
|
||||
_CardMoreOption(),
|
||||
];
|
||||
if (widget.styleConfiguration.showAccessory == false) {
|
||||
return [];
|
||||
} else {
|
||||
return [
|
||||
_CardEditOption(rowNotifier: rowNotifier),
|
||||
_CardMoreOption(),
|
||||
];
|
||||
}
|
||||
},
|
||||
openAccessory: _handleOpenAccessory,
|
||||
openCard: (context) => widget.openCard(context),
|
||||
child: _CardContent<T>(
|
||||
rowNotifier: rowNotifier,
|
||||
cellBuilder: widget.cellBuilder,
|
||||
styleConfiguration: widget.styleConfiguration,
|
||||
cells: state.cells,
|
||||
cardConfiguration: widget.configuration,
|
||||
renderHook: widget.renderHook,
|
||||
cardData: widget.cardData,
|
||||
),
|
||||
),
|
||||
@ -166,15 +193,17 @@ class _CardState<T> extends State<Card<T>> {
|
||||
class _CardContent<CustomCardData> extends StatelessWidget {
|
||||
final CardCellBuilder<CustomCardData> cellBuilder;
|
||||
final EditableRowNotifier rowNotifier;
|
||||
final List<BoardCellEquatable> cells;
|
||||
final CardConfiguration<CustomCardData>? cardConfiguration;
|
||||
final List<CellIdentifier> cells;
|
||||
final RowCardRenderHook<CustomCardData>? renderHook;
|
||||
final CustomCardData? cardData;
|
||||
final RowCardStyleConfiguration styleConfiguration;
|
||||
const _CardContent({
|
||||
required this.rowNotifier,
|
||||
required this.cellBuilder,
|
||||
required this.cells,
|
||||
required this.cardData,
|
||||
this.cardConfiguration,
|
||||
required this.styleConfiguration,
|
||||
this.renderHook,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@ -188,30 +217,30 @@ class _CardContent<CustomCardData> extends StatelessWidget {
|
||||
|
||||
List<Widget> _makeCells(
|
||||
BuildContext context,
|
||||
List<BoardCellEquatable> cells,
|
||||
List<CellIdentifier> cells,
|
||||
) {
|
||||
final List<Widget> children = [];
|
||||
// Remove all the cell listeners.
|
||||
rowNotifier.unbind();
|
||||
|
||||
cells.asMap().forEach(
|
||||
(int index, BoardCellEquatable cell) {
|
||||
(int index, CellIdentifier cell) {
|
||||
final isEditing = index == 0 ? rowNotifier.isEditing.value : false;
|
||||
final cellNotifier = EditableCardNotifier(isEditing: isEditing);
|
||||
|
||||
if (index == 0) {
|
||||
// Only use the first cell to receive user's input when click the edit
|
||||
// button
|
||||
rowNotifier.bindCell(cell.identifier, cellNotifier);
|
||||
rowNotifier.bindCell(cell, cellNotifier);
|
||||
}
|
||||
|
||||
final child = Padding(
|
||||
key: cell.identifier.key(),
|
||||
padding: const EdgeInsets.only(left: 4, right: 4),
|
||||
key: cell.key(),
|
||||
padding: styleConfiguration.cellPadding,
|
||||
child: cellBuilder.buildCell(
|
||||
cellId: cell.identifier,
|
||||
cellId: cell,
|
||||
cellNotifier: cellNotifier,
|
||||
cardConfiguration: cardConfiguration,
|
||||
renderHook: renderHook,
|
||||
cardData: cardData,
|
||||
),
|
||||
);
|
||||
@ -265,3 +294,13 @@ class _CardEditOption extends StatelessWidget with CardAccessory {
|
||||
@override
|
||||
AccessoryType get type => AccessoryType.edit;
|
||||
}
|
||||
|
||||
class RowCardStyleConfiguration {
|
||||
final bool showAccessory;
|
||||
final EdgeInsets cellPadding;
|
||||
|
||||
const RowCardStyleConfiguration({
|
||||
this.showAccessory = true,
|
||||
this.cellPadding = const EdgeInsets.only(left: 4, right: 4),
|
||||
});
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import 'dart:collection';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@ -12,9 +11,9 @@ import '../../application/row/row_service.dart';
|
||||
|
||||
part 'card_bloc.freezed.dart';
|
||||
|
||||
class CardBloc extends Bloc<BoardCardEvent, BoardCardState> {
|
||||
class CardBloc extends Bloc<RowCardEvent, RowCardState> {
|
||||
final RowPB row;
|
||||
final String groupFieldId;
|
||||
final String? groupFieldId;
|
||||
final RowBackendService _rowBackendSvc;
|
||||
final RowCache _rowCache;
|
||||
VoidCallback? _rowCallback;
|
||||
@ -28,13 +27,13 @@ class CardBloc extends Bloc<BoardCardEvent, BoardCardState> {
|
||||
}) : _rowBackendSvc = RowBackendService(viewId: viewId),
|
||||
_rowCache = rowCache,
|
||||
super(
|
||||
BoardCardState.initial(
|
||||
RowCardState.initial(
|
||||
row,
|
||||
_makeCells(groupFieldId, rowCache.loadGridCells(row.id)),
|
||||
isEditing,
|
||||
),
|
||||
) {
|
||||
on<BoardCardEvent>(
|
||||
on<RowCardEvent>(
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
initial: () async {
|
||||
@ -69,7 +68,7 @@ class CardBloc extends Bloc<BoardCardEvent, BoardCardState> {
|
||||
return RowInfo(
|
||||
viewId: _rowBackendSvc.viewId,
|
||||
fields: UnmodifiableListView(
|
||||
state.cells.map((cell) => cell.identifier.fieldInfo).toList(),
|
||||
state.cells.map((cell) => cell.fieldInfo).toList(),
|
||||
),
|
||||
rowPB: state.rowPB,
|
||||
);
|
||||
@ -81,70 +80,58 @@ class CardBloc extends Bloc<BoardCardEvent, BoardCardState> {
|
||||
onCellUpdated: (cellMap, reason) {
|
||||
if (!isClosed) {
|
||||
final cells = _makeCells(groupFieldId, cellMap);
|
||||
add(BoardCardEvent.didReceiveCells(cells, reason));
|
||||
add(RowCardEvent.didReceiveCells(cells, reason));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<BoardCellEquatable> _makeCells(
|
||||
String groupFieldId,
|
||||
List<CellIdentifier> _makeCells(
|
||||
String? groupFieldId,
|
||||
CellByFieldId originalCellMap,
|
||||
) {
|
||||
List<BoardCellEquatable> cells = [];
|
||||
List<CellIdentifier> cells = [];
|
||||
for (final entry in originalCellMap.entries) {
|
||||
// Filter out the cell if it's fieldId equal to the groupFieldId
|
||||
if (entry.value.fieldId != groupFieldId) {
|
||||
cells.add(BoardCellEquatable(entry.value));
|
||||
if (groupFieldId != null) {
|
||||
if (entry.value.fieldId == groupFieldId) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
cells.add(entry.value);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class BoardCardEvent with _$BoardCardEvent {
|
||||
const factory BoardCardEvent.initial() = _InitialRow;
|
||||
const factory BoardCardEvent.setIsEditing(bool isEditing) = _IsEditing;
|
||||
const factory BoardCardEvent.didReceiveCells(
|
||||
List<BoardCellEquatable> cells,
|
||||
class RowCardEvent with _$RowCardEvent {
|
||||
const factory RowCardEvent.initial() = _InitialRow;
|
||||
const factory RowCardEvent.setIsEditing(bool isEditing) = _IsEditing;
|
||||
const factory RowCardEvent.didReceiveCells(
|
||||
List<CellIdentifier> cells,
|
||||
RowsChangedReason reason,
|
||||
) = _DidReceiveCells;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class BoardCardState with _$BoardCardState {
|
||||
const factory BoardCardState({
|
||||
class RowCardState with _$RowCardState {
|
||||
const factory RowCardState({
|
||||
required RowPB rowPB,
|
||||
required List<BoardCellEquatable> cells,
|
||||
required List<CellIdentifier> cells,
|
||||
required bool isEditing,
|
||||
RowsChangedReason? changeReason,
|
||||
}) = _BoardCardState;
|
||||
}) = _RowCardState;
|
||||
|
||||
factory BoardCardState.initial(
|
||||
factory RowCardState.initial(
|
||||
RowPB rowPB,
|
||||
List<BoardCellEquatable> cells,
|
||||
List<CellIdentifier> cells,
|
||||
bool isEditing,
|
||||
) =>
|
||||
BoardCardState(
|
||||
RowCardState(
|
||||
rowPB: rowPB,
|
||||
cells: cells,
|
||||
isEditing: isEditing,
|
||||
);
|
||||
}
|
||||
|
||||
class BoardCellEquatable extends Equatable {
|
||||
final CellIdentifier identifier;
|
||||
|
||||
const BoardCellEquatable(this.identifier);
|
||||
|
||||
@override
|
||||
List<Object?> get props {
|
||||
return [
|
||||
identifier.fieldInfo.id,
|
||||
identifier.fieldInfo.fieldType,
|
||||
identifier.fieldInfo.visibility,
|
||||
identifier.fieldInfo.width,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -15,15 +15,15 @@ import 'cells/url_card_cell.dart';
|
||||
// T represents as the Generic card data
|
||||
class CardCellBuilder<CustomCardData> {
|
||||
final CellCache cellCache;
|
||||
final Map<FieldType, CardCellStyle>? styles;
|
||||
|
||||
CardCellBuilder(this.cellCache);
|
||||
CardCellBuilder(this.cellCache, {this.styles});
|
||||
|
||||
Widget buildCell({
|
||||
CustomCardData? cardData,
|
||||
required CellIdentifier cellId,
|
||||
EditableCardNotifier? cellNotifier,
|
||||
CardConfiguration<CustomCardData>? cardConfiguration,
|
||||
Map<FieldType, CardCellStyle>? styles,
|
||||
RowCardRenderHook<CustomCardData>? renderHook,
|
||||
}) {
|
||||
final cellControllerBuilder = CellControllerBuilder(
|
||||
cellId: cellId,
|
||||
@ -39,20 +39,21 @@ class CardCellBuilder<CustomCardData> {
|
||||
key: key,
|
||||
);
|
||||
case FieldType.DateTime:
|
||||
return DateCardCell(
|
||||
return DateCardCell<CustomCardData>(
|
||||
renderHook: renderHook?.renderHook[FieldType.DateTime],
|
||||
cellControllerBuilder: cellControllerBuilder,
|
||||
key: key,
|
||||
);
|
||||
case FieldType.SingleSelect:
|
||||
return SelectOptionCardCell<CustomCardData>(
|
||||
renderHook: cardConfiguration?.renderHook[FieldType.SingleSelect],
|
||||
renderHook: renderHook?.renderHook[FieldType.SingleSelect],
|
||||
cellControllerBuilder: cellControllerBuilder,
|
||||
cardData: cardData,
|
||||
key: key,
|
||||
);
|
||||
case FieldType.MultiSelect:
|
||||
return SelectOptionCardCell<CustomCardData>(
|
||||
renderHook: cardConfiguration?.renderHook[FieldType.MultiSelect],
|
||||
renderHook: renderHook?.renderHook[FieldType.MultiSelect],
|
||||
cellControllerBuilder: cellControllerBuilder,
|
||||
cardData: cardData,
|
||||
editableNotifier: cellNotifier,
|
||||
@ -64,19 +65,24 @@ class CardCellBuilder<CustomCardData> {
|
||||
key: key,
|
||||
);
|
||||
case FieldType.Number:
|
||||
return NumberCardCell(
|
||||
return NumberCardCell<CustomCardData>(
|
||||
renderHook: renderHook?.renderHook[FieldType.Number],
|
||||
style: isStyleOrNull<NumberCardCellStyle>(style),
|
||||
cellControllerBuilder: cellControllerBuilder,
|
||||
key: key,
|
||||
);
|
||||
case FieldType.RichText:
|
||||
return TextCardCell(
|
||||
return TextCardCell<CustomCardData>(
|
||||
renderHook: renderHook?.renderHook[FieldType.RichText],
|
||||
cellControllerBuilder: cellControllerBuilder,
|
||||
editableNotifier: cellNotifier,
|
||||
cardData: cardData,
|
||||
style: isStyleOrNull<TextCardCellStyle>(style),
|
||||
key: key,
|
||||
);
|
||||
case FieldType.URL:
|
||||
return URLCardCell(
|
||||
return URLCardCell<CustomCardData>(
|
||||
style: isStyleOrNull<URLCardCellStyle>(style),
|
||||
cellControllerBuilder: cellControllerBuilder,
|
||||
key: key,
|
||||
);
|
||||
|
@ -1,27 +1,72 @@
|
||||
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
|
||||
import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
typedef CellRenderHook<C, T> = Widget? Function(C cellData, T cardData);
|
||||
typedef CellRenderHook<C, CustomCardData> = Widget? Function(
|
||||
C cellData,
|
||||
CustomCardData cardData,
|
||||
BuildContext buildContext,
|
||||
);
|
||||
typedef RenderHookByFieldType<C> = Map<FieldType, CellRenderHook<dynamic, C>>;
|
||||
|
||||
class CardConfiguration<CustomCardData> {
|
||||
/// The [RowCardRenderHook] is used to customize the rendering of the
|
||||
/// card cell. Each cell has itw own field type. So the [renderHook]
|
||||
/// is a map of [FieldType] to [CellRenderHook].
|
||||
class RowCardRenderHook<CustomCardData> {
|
||||
final RenderHookByFieldType<CustomCardData> renderHook = {};
|
||||
CardConfiguration();
|
||||
RowCardRenderHook();
|
||||
|
||||
/// Add render hook for the FieldType.SingleSelect and FieldType.MultiSelect
|
||||
void addSelectOptionHook(
|
||||
CellRenderHook<List<SelectOptionPB>, CustomCardData> hook,
|
||||
CellRenderHook<List<SelectOptionPB>, CustomCardData?> hook,
|
||||
) {
|
||||
selectOptionHook(cellData, cardData) {
|
||||
if (cellData is List<SelectOptionPB>) {
|
||||
hook(cellData, cardData);
|
||||
final hookFn = _typeSafeHook<List<SelectOptionPB>>(hook);
|
||||
renderHook[FieldType.SingleSelect] = hookFn;
|
||||
renderHook[FieldType.MultiSelect] = hookFn;
|
||||
}
|
||||
|
||||
/// Add a render hook for the [FieldType.RichText]
|
||||
void addTextCellHook(
|
||||
CellRenderHook<String, CustomCardData?> hook,
|
||||
) {
|
||||
renderHook[FieldType.RichText] = _typeSafeHook<String>(hook);
|
||||
}
|
||||
|
||||
/// Add a render hook for the [FieldType.Number]
|
||||
void addNumberCellHook(
|
||||
CellRenderHook<String, CustomCardData?> hook,
|
||||
) {
|
||||
renderHook[FieldType.Number] = _typeSafeHook<String>(hook);
|
||||
}
|
||||
|
||||
/// Add a render hook for the [FieldType.Date]
|
||||
void addDateCellHook(
|
||||
CellRenderHook<DateCellDataPB, CustomCardData?> hook,
|
||||
) {
|
||||
renderHook[FieldType.DateTime] = _typeSafeHook<DateCellDataPB>(hook);
|
||||
}
|
||||
|
||||
CellRenderHook<dynamic, CustomCardData> _typeSafeHook<C>(
|
||||
CellRenderHook<C, CustomCardData?> hook,
|
||||
) {
|
||||
hookFn(cellData, cardData, buildContext) {
|
||||
if (cellData == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cellData is C) {
|
||||
return hook(cellData, cardData, buildContext);
|
||||
} else {
|
||||
Log.debug("Unexpected cellData type: ${cellData.runtimeType}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
renderHook[FieldType.SingleSelect] = selectOptionHook;
|
||||
renderHook[FieldType.MultiSelect] = selectOptionHook;
|
||||
return hookFn;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -44,13 +44,16 @@ class _CheckboxCardCellState extends State<CheckboxCardCell> {
|
||||
: svgWidget('editor/editor_uncheck');
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FlowyIconButton(
|
||||
iconPadding: EdgeInsets.zero,
|
||||
icon: icon,
|
||||
width: 20,
|
||||
onPressed: () => context
|
||||
.read<CheckboxCardCellBloc>()
|
||||
.add(const CheckboxCardCellEvent.select()),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: FlowyIconButton(
|
||||
iconPadding: EdgeInsets.zero,
|
||||
icon: icon,
|
||||
width: 20,
|
||||
onPressed: () => context
|
||||
.read<CheckboxCardCellBloc>()
|
||||
.add(const CheckboxCardCellEvent.select()),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -7,11 +7,13 @@ import '../bloc/date_card_cell_bloc.dart';
|
||||
import '../define.dart';
|
||||
import 'card_cell.dart';
|
||||
|
||||
class DateCardCell extends CardCell {
|
||||
class DateCardCell<CustomCardData> extends CardCell {
|
||||
final CellControllerBuilder cellControllerBuilder;
|
||||
final CellRenderHook<dynamic, CustomCardData>? renderHook;
|
||||
|
||||
const DateCardCell({
|
||||
required this.cellControllerBuilder,
|
||||
this.renderHook,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@ -42,6 +44,15 @@ class _DateCardCellState extends State<DateCardCell> {
|
||||
if (state.dateStr.isEmpty) {
|
||||
return const SizedBox();
|
||||
} else {
|
||||
Widget? custom = widget.renderHook?.call(
|
||||
state.data,
|
||||
widget.cardData,
|
||||
context,
|
||||
);
|
||||
if (custom != null) {
|
||||
return custom;
|
||||
}
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
|
@ -7,13 +7,24 @@ import '../bloc/number_card_cell_bloc.dart';
|
||||
import '../define.dart';
|
||||
import 'card_cell.dart';
|
||||
|
||||
class NumberCardCell extends CardCell {
|
||||
class NumberCardCellStyle extends CardCellStyle {
|
||||
final double fontSize;
|
||||
|
||||
NumberCardCellStyle(this.fontSize);
|
||||
}
|
||||
|
||||
class NumberCardCell<CustomCardData>
|
||||
extends CardCell<CustomCardData, NumberCardCellStyle> {
|
||||
final CellRenderHook<String, CustomCardData>? renderHook;
|
||||
final CellControllerBuilder cellControllerBuilder;
|
||||
|
||||
const NumberCardCell({
|
||||
required this.cellControllerBuilder,
|
||||
CustomCardData? cardData,
|
||||
NumberCardCellStyle? style,
|
||||
this.renderHook,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
}) : super(key: key, style: style, cardData: cardData);
|
||||
|
||||
@override
|
||||
State<NumberCardCell> createState() => _NumberCardCellState();
|
||||
@ -42,6 +53,15 @@ class _NumberCardCellState extends State<NumberCardCell> {
|
||||
if (state.content.isEmpty) {
|
||||
return const SizedBox();
|
||||
} else {
|
||||
Widget? custom = widget.renderHook?.call(
|
||||
state.content,
|
||||
widget.cardData,
|
||||
context,
|
||||
);
|
||||
if (custom != null) {
|
||||
return custom;
|
||||
}
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
@ -50,7 +70,7 @@ class _NumberCardCellState extends State<NumberCardCell> {
|
||||
),
|
||||
child: FlowyText.medium(
|
||||
state.content,
|
||||
fontSize: 14,
|
||||
fontSize: widget.style?.fontSize ?? 14,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -11,17 +11,18 @@ import 'card_cell.dart';
|
||||
|
||||
class SelectOptionCardCellStyle extends CardCellStyle {}
|
||||
|
||||
class SelectOptionCardCell<T> extends CardCell<T, SelectOptionCardCellStyle>
|
||||
class SelectOptionCardCell<CustomCardData>
|
||||
extends CardCell<CustomCardData, SelectOptionCardCellStyle>
|
||||
with EditableCell {
|
||||
final CellControllerBuilder cellControllerBuilder;
|
||||
final CellRenderHook<List<SelectOptionPB>, T>? renderHook;
|
||||
final CellRenderHook<List<SelectOptionPB>, CustomCardData>? renderHook;
|
||||
|
||||
@override
|
||||
final EditableCardNotifier? editableNotifier;
|
||||
|
||||
SelectOptionCardCell({
|
||||
required this.cellControllerBuilder,
|
||||
required T? cardData,
|
||||
required CustomCardData? cardData,
|
||||
this.renderHook,
|
||||
this.editableNotifier,
|
||||
Key? key,
|
||||
@ -57,6 +58,7 @@ class _SelectOptionCardCellState extends State<SelectOptionCardCell> {
|
||||
Widget? custom = widget.renderHook?.call(
|
||||
state.selectedOptions,
|
||||
widget.cardData,
|
||||
context,
|
||||
);
|
||||
if (custom != null) {
|
||||
return custom;
|
||||
|
@ -14,18 +14,21 @@ class TextCardCellStyle extends CardCellStyle {
|
||||
TextCardCellStyle(this.fontSize);
|
||||
}
|
||||
|
||||
class TextCardCell extends CardCell<String, TextCardCellStyle>
|
||||
with EditableCell {
|
||||
class TextCardCell<CustomCardData>
|
||||
extends CardCell<CustomCardData, TextCardCellStyle> with EditableCell {
|
||||
@override
|
||||
final EditableCardNotifier? editableNotifier;
|
||||
final CellControllerBuilder cellControllerBuilder;
|
||||
final CellRenderHook<String, CustomCardData>? renderHook;
|
||||
|
||||
const TextCardCell({
|
||||
required this.cellControllerBuilder,
|
||||
required CustomCardData? cardData,
|
||||
this.editableNotifier,
|
||||
this.renderHook,
|
||||
TextCardCellStyle? style,
|
||||
Key? key,
|
||||
}) : super(key: key, style: style);
|
||||
}) : super(key: key, style: style, cardData: cardData);
|
||||
|
||||
@override
|
||||
State<TextCardCell> createState() => _TextCardCellState();
|
||||
@ -104,6 +107,16 @@ class _TextCardCellState extends State<TextCardCell> {
|
||||
return previous != current;
|
||||
},
|
||||
builder: (context, state) {
|
||||
// Returns a custom render widget
|
||||
Widget? custom = widget.renderHook?.call(
|
||||
state.content,
|
||||
widget.cardData,
|
||||
context,
|
||||
);
|
||||
if (custom != null) {
|
||||
return custom;
|
||||
}
|
||||
|
||||
if (state.content.isEmpty &&
|
||||
state.enableEdit == false &&
|
||||
focusWhenInit == false) {
|
||||
|
@ -8,13 +8,21 @@ import '../bloc/url_card_cell_bloc.dart';
|
||||
import '../define.dart';
|
||||
import 'card_cell.dart';
|
||||
|
||||
class URLCardCell extends CardCell {
|
||||
class URLCardCellStyle extends CardCellStyle {
|
||||
final double fontSize;
|
||||
|
||||
URLCardCellStyle(this.fontSize);
|
||||
}
|
||||
|
||||
class URLCardCell<CustomCardData>
|
||||
extends CardCell<CustomCardData, URLCardCellStyle> {
|
||||
final CellControllerBuilder cellControllerBuilder;
|
||||
|
||||
const URLCardCell({
|
||||
required this.cellControllerBuilder,
|
||||
URLCardCellStyle? style,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
}) : super(key: key, style: style);
|
||||
|
||||
@override
|
||||
State<URLCardCell> createState() => _URLCardCellState();
|
||||
@ -55,7 +63,7 @@ class _URLCardCellState extends State<URLCardCell> {
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium!
|
||||
.size(FontSizes.s14)
|
||||
.size(widget.style?.fontSize ?? FontSizes.s14)
|
||||
.textColor(Theme.of(context).colorScheme.primary)
|
||||
.underline,
|
||||
),
|
||||
|
@ -4,13 +4,13 @@ import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
import 'accessory.dart';
|
||||
|
||||
class BoardCardContainer extends StatelessWidget {
|
||||
class RowCardContainer extends StatelessWidget {
|
||||
final Widget child;
|
||||
final CardAccessoryBuilder? accessoryBuilder;
|
||||
final bool Function()? buildAccessoryWhen;
|
||||
final void Function(BuildContext) openCard;
|
||||
final void Function(AccessoryType) openAccessory;
|
||||
const BoardCardContainer({
|
||||
const RowCardContainer({
|
||||
required this.child,
|
||||
required this.openCard,
|
||||
required this.openAccessory,
|
||||
|
@ -20,7 +20,7 @@ class ChecklistProgressBar extends StatelessWidget {
|
||||
percent: percent,
|
||||
padding: EdgeInsets.zero,
|
||||
progressColor: Theme.of(context).colorScheme.primary,
|
||||
backgroundColor: AFThemeExtension.of(context).progressBarBGcolor,
|
||||
backgroundColor: AFThemeExtension.of(context).progressBarBGColor,
|
||||
barRadius: const Radius.circular(5),
|
||||
);
|
||||
}
|
||||
|
@ -289,10 +289,7 @@ Option<DateCellData> calDataFromCellData(DateCellDataPB? cellData) {
|
||||
Option<DateCellData> dateData = none();
|
||||
if (cellData != null) {
|
||||
final timestamp = cellData.timestamp * 1000;
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(
|
||||
timestamp.toInt(),
|
||||
isUtc: true,
|
||||
);
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(timestamp.toInt());
|
||||
dateData = Some(
|
||||
DateCellData(
|
||||
date: date,
|
||||
|
@ -1,11 +1,11 @@
|
||||
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'dart:async';
|
||||
|
||||
part 'number_cell_bloc.freezed.dart';
|
||||
|
||||
//
|
||||
class NumberCellBloc extends Bloc<NumberCellEvent, NumberCellState> {
|
||||
final NumberCellController cellController;
|
||||
void Function()? _onCellChangedFn;
|
||||
@ -22,17 +22,18 @@ class NumberCellBloc extends Bloc<NumberCellEvent, NumberCellState> {
|
||||
didReceiveCellUpdate: (cellContent) {
|
||||
emit(state.copyWith(cellContent: cellContent ?? ""));
|
||||
},
|
||||
updateCell: (text) {
|
||||
updateCell: (text) async {
|
||||
if (state.cellContent != text) {
|
||||
emit(state.copyWith(cellContent: text));
|
||||
cellController.saveCellData(
|
||||
text,
|
||||
onFinish: (result) {
|
||||
result.fold(
|
||||
() {},
|
||||
(err) => Log.error(err),
|
||||
);
|
||||
},
|
||||
await cellController.saveCellData(text);
|
||||
|
||||
// If the input content is "abc" that can't parsered as number then the data stored in the backend will be an empty string.
|
||||
// So for every cell data that will be formatted in the backend.
|
||||
// It needs to get the formatted data after saving.
|
||||
add(
|
||||
NumberCellEvent.didReceiveCellUpdate(
|
||||
cellController.getCellData(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
@ -8,9 +8,13 @@ import '../../cell_builder.dart';
|
||||
|
||||
class GridTextCellStyle extends GridCellStyle {
|
||||
String? placeholder;
|
||||
TextStyle? textStyle;
|
||||
bool? autofocus;
|
||||
|
||||
GridTextCellStyle({
|
||||
this.placeholder,
|
||||
this.textStyle,
|
||||
this.autofocus,
|
||||
});
|
||||
}
|
||||
|
||||
@ -66,7 +70,9 @@ class _GridTextCellState extends GridFocusNodeCellState<GridTextCell> {
|
||||
controller: _controller,
|
||||
focusNode: focusNode,
|
||||
maxLines: null,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
style: widget.cellStyle?.textStyle ??
|
||||
Theme.of(context).textTheme.bodyMedium,
|
||||
autofocus: widget.cellStyle?.autofocus ?? false,
|
||||
decoration: InputDecoration(
|
||||
contentPadding: EdgeInsets.only(
|
||||
top: GridSize.cellContentInsets.top,
|
||||
|
@ -3,6 +3,7 @@ import 'package:appflowy/plugins/database_view/application/field/type_option/typ
|
||||
import 'package:appflowy/plugins/database_view/application/row/row_data_controller.dart';
|
||||
import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
@ -43,83 +44,85 @@ class RowDetailPage extends StatefulWidget with FlowyOverlayDelegate {
|
||||
}
|
||||
|
||||
class _RowDetailPageState extends State<RowDetailPage> {
|
||||
final padding = const EdgeInsets.symmetric(
|
||||
horizontal: 40,
|
||||
vertical: 20,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyDialog(
|
||||
child: BlocProvider(
|
||||
create: (context) {
|
||||
final bloc = RowDetailBloc(
|
||||
dataController: widget.dataController,
|
||||
);
|
||||
bloc.add(const RowDetailEvent.initial());
|
||||
return bloc;
|
||||
return RowDetailBloc(dataController: widget.dataController)
|
||||
..add(const RowDetailEvent.initial());
|
||||
},
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: Column(
|
||||
children: [
|
||||
const _Header(),
|
||||
Expanded(
|
||||
child: _PropertyColumn(
|
||||
cellBuilder: widget.cellBuilder,
|
||||
viewId: widget.dataController.viewId,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ListView(
|
||||
children: [
|
||||
// using ListView here for future expansion:
|
||||
// - header and cover image
|
||||
// - lower rich text area
|
||||
IntrinsicHeight(child: _responsiveRowInfo()),
|
||||
const Divider(height: 1.0),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 30,
|
||||
child: Row(
|
||||
children: const [Spacer(), _CloseButton()],
|
||||
),
|
||||
Widget _responsiveRowInfo() {
|
||||
final rowDataColumn = _PropertyColumn(
|
||||
cellBuilder: widget.cellBuilder,
|
||||
viewId: widget.dataController.viewId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CloseButton extends StatelessWidget {
|
||||
const _CloseButton({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyIconButton(
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
width: 24,
|
||||
onPressed: () => FlowyOverlay.pop(context),
|
||||
iconPadding: const EdgeInsets.fromLTRB(2, 2, 2, 2),
|
||||
icon: svgWidget(
|
||||
"home/close",
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
final rowOptionColumn = _RowOptionColumn(
|
||||
viewId: widget.dataController.viewId,
|
||||
rowId: widget.dataController.rowId,
|
||||
);
|
||||
if (MediaQuery.of(context).size.width > 800) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 4,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(50, 50, 20, 20),
|
||||
child: rowDataColumn,
|
||||
),
|
||||
),
|
||||
const VerticalDivider(width: 1.0),
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 50, 20, 20),
|
||||
child: rowOptionColumn,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 50, 20, 20),
|
||||
child: rowDataColumn,
|
||||
),
|
||||
const Divider(height: 1.0),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: rowOptionColumn,
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PropertyColumn extends StatelessWidget {
|
||||
final String viewId;
|
||||
final GridCellBuilder cellBuilder;
|
||||
final ScrollController _scrollController;
|
||||
_PropertyColumn({
|
||||
const _PropertyColumn({
|
||||
required this.viewId,
|
||||
required this.cellBuilder,
|
||||
Key? key,
|
||||
}) : _scrollController = ScrollController(),
|
||||
super(key: key);
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -127,63 +130,61 @@ class _PropertyColumn extends StatelessWidget {
|
||||
buildWhen: (previous, current) => previous.gridCells != current.gridCells,
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _wrapScrollbar(buildPropertyCells(state))),
|
||||
const VSpace(10),
|
||||
_CreatePropertyButton(
|
||||
viewId: viewId,
|
||||
onClosed: _scrollToNewProperty,
|
||||
_RowTitle(
|
||||
cellId: state.gridCells
|
||||
.firstWhereOrNull((e) => e.fieldInfo.isPrimary),
|
||||
cellBuilder: cellBuilder,
|
||||
),
|
||||
const VSpace(20),
|
||||
...state.gridCells
|
||||
.where((element) => !element.fieldInfo.isPrimary)
|
||||
.map(
|
||||
(cell) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: _PropertyCell(
|
||||
cellId: cell,
|
||||
cellBuilder: cellBuilder,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
const VSpace(20),
|
||||
_CreatePropertyButton(viewId: viewId),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildPropertyCells(RowDetailState state) {
|
||||
return ListView.separated(
|
||||
controller: _scrollController,
|
||||
itemCount: state.gridCells.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return _PropertyCell(
|
||||
cellId: state.gridCells[index],
|
||||
cellBuilder: cellBuilder,
|
||||
);
|
||||
},
|
||||
separatorBuilder: (BuildContext context, int index) {
|
||||
return const VSpace(2);
|
||||
},
|
||||
class _RowTitle extends StatelessWidget {
|
||||
final CellIdentifier? cellId;
|
||||
final GridCellBuilder cellBuilder;
|
||||
const _RowTitle({this.cellId, required this.cellBuilder, Key? key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (cellId == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
final style = GridTextCellStyle(
|
||||
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
|
||||
textStyle: Theme.of(context).textTheme.titleLarge,
|
||||
autofocus: true,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _wrapScrollbar(Widget child) {
|
||||
return ScrollbarListStack(
|
||||
axis: Axis.vertical,
|
||||
controller: _scrollController,
|
||||
barSize: GridSize.scrollBarSize,
|
||||
autoHideScrollbar: false,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
void _scrollToNewProperty() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.ease,
|
||||
);
|
||||
});
|
||||
return cellBuilder.build(cellId!, style: style);
|
||||
}
|
||||
}
|
||||
|
||||
class _CreatePropertyButton extends StatefulWidget {
|
||||
final String viewId;
|
||||
final VoidCallback onClosed;
|
||||
|
||||
const _CreatePropertyButton({
|
||||
required this.viewId,
|
||||
required this.onClosed,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@ -206,10 +207,9 @@ class _CreatePropertyButtonState extends State<_CreatePropertyButton> {
|
||||
constraints: BoxConstraints.loose(const Size(240, 200)),
|
||||
controller: popoverController,
|
||||
direction: PopoverDirection.topWithLeftAligned,
|
||||
onClose: widget.onClosed,
|
||||
child: Container(
|
||||
margin: EdgeInsets.zero,
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
decoration: _makeBoxDecoration(context),
|
||||
child: FlowyButton(
|
||||
text: FlowyText.medium(
|
||||
LocaleKeys.grid_field_newProperty.tr(),
|
||||
@ -243,14 +243,6 @@ class _CreatePropertyButtonState extends State<_CreatePropertyButton> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
BoxDecoration _makeBoxDecoration(BuildContext context) {
|
||||
final borderSide =
|
||||
BorderSide(color: Theme.of(context).dividerColor, width: 1.0);
|
||||
return BoxDecoration(
|
||||
border: Border(top: borderSide),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PropertyCell extends StatefulWidget {
|
||||
@ -376,3 +368,69 @@ GridCellStyle? _customCellStyle(FieldType fieldType) {
|
||||
}
|
||||
throw UnimplementedError;
|
||||
}
|
||||
|
||||
class _RowOptionColumn extends StatelessWidget {
|
||||
final String rowId;
|
||||
const _RowOptionColumn({
|
||||
required String viewId,
|
||||
required this.rowId,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 10),
|
||||
child: FlowyText(LocaleKeys.grid_row_action.tr()),
|
||||
),
|
||||
const VSpace(15),
|
||||
_DeleteButton(rowId: rowId),
|
||||
_DuplicateButton(rowId: rowId),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeleteButton extends StatelessWidget {
|
||||
final String rowId;
|
||||
const _DeleteButton({required this.rowId, Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: GridSize.popoverItemHeight,
|
||||
child: FlowyButton(
|
||||
text: FlowyText.regular(LocaleKeys.grid_row_delete.tr()),
|
||||
leftIcon: const FlowySvg(name: "home/trash"),
|
||||
onTap: () {
|
||||
context.read<RowDetailBloc>().add(RowDetailEvent.deleteRow(rowId));
|
||||
FlowyOverlay.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DuplicateButton extends StatelessWidget {
|
||||
final String rowId;
|
||||
const _DuplicateButton({required this.rowId, Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: GridSize.popoverItemHeight,
|
||||
child: FlowyButton(
|
||||
text: FlowyText.regular(LocaleKeys.grid_row_duplicate.tr()),
|
||||
leftIcon: const FlowySvg(name: "grid/duplicate"),
|
||||
onTap: () {
|
||||
context.read<RowDetailBloc>().add(RowDetailEvent.duplicateRow(rowId));
|
||||
FlowyOverlay.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,6 @@
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/board/board_view_menu_item.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/plugins.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/board/board_node_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/cover/cover_node_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/grid/grid_menu_item.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/grid/grid_node_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_plugins.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_toolbar_item.dart';
|
||||
import 'package:dartz/dartz.dart' as dartz;
|
||||
import 'package:flowy_infra_ui/widget/error_page.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@ -20,8 +11,6 @@ import '../../startup/startup.dart';
|
||||
import 'application/doc_bloc.dart';
|
||||
import 'editor_styles.dart';
|
||||
import 'presentation/banner.dart';
|
||||
import 'presentation/plugins/grid/grid_view_menu_item.dart';
|
||||
import 'presentation/plugins/board/board_menu_item.dart';
|
||||
|
||||
class DocumentPage extends StatefulWidget {
|
||||
final VoidCallback onDeleted;
|
||||
|
@ -1,32 +1,63 @@
|
||||
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
EditorStyle customEditorTheme(BuildContext context) {
|
||||
final documentStyle = context.watch<DocumentAppearanceCubit>().state;
|
||||
var editorStyle = Theme.of(context).brightness == Brightness.dark
|
||||
? EditorStyle.dark
|
||||
: EditorStyle.light;
|
||||
editorStyle = editorStyle.copyWith(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 100, vertical: 0),
|
||||
textStyle: editorStyle.textStyle?.copyWith(
|
||||
final theme = Theme.of(context);
|
||||
|
||||
var editorStyle = EditorStyle(
|
||||
// Editor styles
|
||||
padding: const EdgeInsets.symmetric(horizontal: 100),
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
cursorColor: theme.colorScheme.primary,
|
||||
// Text styles
|
||||
textPadding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
textStyle: TextStyle(
|
||||
fontFamily: 'poppins',
|
||||
fontSize: documentStyle.fontSize,
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
placeholderTextStyle: editorStyle.placeholderTextStyle?.copyWith(
|
||||
fontFamily: 'poppins',
|
||||
fontSize: documentStyle.fontSize,
|
||||
),
|
||||
bold: editorStyle.bold?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
selectionColor: theme.colorScheme.tertiary.withOpacity(0.2),
|
||||
// Selection menu
|
||||
selectionMenuBackgroundColor: theme.cardColor,
|
||||
selectionMenuItemTextColor: theme.iconTheme.color,
|
||||
selectionMenuItemIconColor: theme.colorScheme.onBackground,
|
||||
selectionMenuItemSelectedIconColor: theme.colorScheme.onSurface,
|
||||
selectionMenuItemSelectedTextColor: theme.colorScheme.onSurface,
|
||||
selectionMenuItemSelectedColor: theme.hoverColor,
|
||||
// Toolbar and its item's style
|
||||
toolbarColor: theme.colorScheme.onTertiary,
|
||||
toolbarElevation: 0,
|
||||
lineHeight: 1.5,
|
||||
placeholderTextStyle:
|
||||
TextStyle(fontSize: documentStyle.fontSize, color: theme.hintColor),
|
||||
bold: const TextStyle(
|
||||
fontFamily: 'poppins-Bold',
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
selectionMenuBackgroundColor: Theme.of(context).cardColor,
|
||||
selectionMenuItemSelectedIconColor: Theme.of(context).colorScheme.onSurface,
|
||||
selectionMenuItemSelectedTextColor: Theme.of(context).colorScheme.onSurface,
|
||||
italic: const TextStyle(fontStyle: FontStyle.italic),
|
||||
underline: const TextStyle(decoration: TextDecoration.underline),
|
||||
strikethrough: const TextStyle(decoration: TextDecoration.lineThrough),
|
||||
href: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
highlightColorHex: '0x6000BCF0',
|
||||
code: GoogleFonts.robotoMono(
|
||||
textStyle: TextStyle(
|
||||
fontSize: documentStyle.fontSize,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: Colors.red,
|
||||
backgroundColor: theme.colorScheme.inverseSurface,
|
||||
),
|
||||
),
|
||||
popupMenuFGColor: theme.iconTheme.color,
|
||||
popupMenuHoverColor: theme.colorScheme.tertiaryContainer,
|
||||
);
|
||||
|
||||
return editorStyle;
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
@ -24,6 +25,8 @@ class _FontSizeSwitcherState extends State<FontSizeSwitcher> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedBgColor = AFThemeExtension.of(context).toggleButtonBGColor;
|
||||
final foregroundColor = Theme.of(context).colorScheme.onBackground;
|
||||
return BlocBuilder<DocumentAppearanceCubit, DocumentAppearance>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
@ -43,10 +46,16 @@ class _FontSizeSwitcherState extends State<FontSizeSwitcher> {
|
||||
onPressed: (int index) {
|
||||
_updateSelectedFontSize(_fontSizes[index].item2);
|
||||
},
|
||||
color: foregroundColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(5)),
|
||||
selectedColor: Theme.of(context).colorScheme.tertiary,
|
||||
fillColor: Theme.of(context).colorScheme.primary,
|
||||
color: Theme.of(context).hintColor,
|
||||
borderColor: foregroundColor,
|
||||
borderWidth: 0.5,
|
||||
// when selected
|
||||
selectedColor: foregroundColor,
|
||||
selectedBorderColor: foregroundColor,
|
||||
fillColor: selectedBgColor,
|
||||
// when hover
|
||||
hoverColor: selectedBgColor.withOpacity(0.3),
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: 40.0,
|
||||
minWidth: 80.0,
|
||||
|
@ -12,6 +12,7 @@ class DocumentMoreButton extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopupMenuButton<int>(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
offset: const Offset(0, 30),
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
|
@ -0,0 +1,300 @@
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
const String kCalloutType = 'callout';
|
||||
const String kCalloutAttrColor = 'color';
|
||||
const String kCalloutAttrEmoji = 'emoji';
|
||||
|
||||
SelectionMenuItem calloutMenuItem = SelectionMenuItem.node(
|
||||
name: 'Callout',
|
||||
iconData: Icons.note,
|
||||
keywords: ['callout'],
|
||||
nodeBuilder: (editorState) {
|
||||
final node = Node(type: kCalloutType);
|
||||
node.insert(TextNode.empty());
|
||||
return node;
|
||||
},
|
||||
replace: (_, textNode) => textNode.toPlainText().isEmpty,
|
||||
updateSelection: (_, path, __, ___) {
|
||||
return Selection.single(path: [...path, 0], startOffset: 0);
|
||||
},
|
||||
);
|
||||
|
||||
class CalloutNodeWidgetBuilder extends NodeWidgetBuilder<Node>
|
||||
with ActionProvider<Node> {
|
||||
@override
|
||||
Widget build(NodeWidgetContext<Node> context) {
|
||||
return _CalloutWidget(
|
||||
key: context.node.key,
|
||||
node: context.node,
|
||||
editorState: context.editorState,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
NodeValidator<Node> get nodeValidator => (node) => node.type == kCalloutType;
|
||||
|
||||
_CalloutWidgetState? _getState(NodeWidgetContext<Node> context) {
|
||||
return context.node.key.currentState as _CalloutWidgetState?;
|
||||
}
|
||||
|
||||
BuildContext? _getBuildContext(NodeWidgetContext<Node> context) {
|
||||
return context.node.key.currentContext;
|
||||
}
|
||||
|
||||
@override
|
||||
List<ActionMenuItem> actions(NodeWidgetContext<Node> context) {
|
||||
return [
|
||||
ActionMenuItem.icon(
|
||||
iconData: Icons.color_lens_outlined,
|
||||
onPressed: () {
|
||||
final state = _getState(context);
|
||||
final ctx = _getBuildContext(context);
|
||||
if (state == null || ctx == null) {
|
||||
return;
|
||||
}
|
||||
final menuState = Provider.of<ActionMenuState>(ctx, listen: false);
|
||||
menuState.isPinned = true;
|
||||
state.colorPopoverController.show();
|
||||
},
|
||||
itemWrapper: (item) {
|
||||
final state = _getState(context);
|
||||
final ctx = _getBuildContext(context);
|
||||
if (state == null || ctx == null) {
|
||||
return item;
|
||||
}
|
||||
return AppFlowyPopover(
|
||||
controller: state.colorPopoverController,
|
||||
popupBuilder: (context) => state._buildColorPicker(),
|
||||
constraints: BoxConstraints.loose(const Size(200, 460)),
|
||||
triggerActions: 0,
|
||||
offset: const Offset(0, 30),
|
||||
child: item,
|
||||
onClose: () {
|
||||
final menuState =
|
||||
Provider.of<ActionMenuState>(ctx, listen: false);
|
||||
menuState.isPinned = false;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
ActionMenuItem.svg(
|
||||
name: 'delete',
|
||||
onPressed: () {
|
||||
final transaction = context.editorState.transaction
|
||||
..deleteNode(context.node);
|
||||
context.editorState.apply(transaction);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class _CalloutWidget extends StatefulWidget {
|
||||
const _CalloutWidget({
|
||||
super.key,
|
||||
required this.node,
|
||||
required this.editorState,
|
||||
});
|
||||
|
||||
final Node node;
|
||||
final EditorState editorState;
|
||||
|
||||
@override
|
||||
State<_CalloutWidget> createState() => _CalloutWidgetState();
|
||||
}
|
||||
|
||||
class _CalloutWidgetState extends State<_CalloutWidget> with SelectableMixin {
|
||||
final PopoverController colorPopoverController = PopoverController();
|
||||
final PopoverController emojiPopoverController = PopoverController();
|
||||
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
widget.node.addListener(nodeChanged);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.node.removeListener(nodeChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void nodeChanged() {
|
||||
if (widget.node.children.isEmpty) {
|
||||
deleteNode();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||
color: tint.color(context),
|
||||
),
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 8, left: 0, right: 15),
|
||||
width: double.infinity,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildEmoji(),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: widget.node.children
|
||||
.map(
|
||||
(child) => widget.editorState.service.renderPluginService
|
||||
.buildPluginWidget(
|
||||
child is TextNode
|
||||
? NodeWidgetContext<TextNode>(
|
||||
context: context,
|
||||
node: child,
|
||||
editorState: widget.editorState,
|
||||
)
|
||||
: NodeWidgetContext<Node>(
|
||||
context: context,
|
||||
node: child,
|
||||
editorState: widget.editorState,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _popover({
|
||||
required PopoverController controller,
|
||||
required Widget Function(BuildContext context) popupBuilder,
|
||||
required Widget child,
|
||||
Size size = const Size(200, 460),
|
||||
}) {
|
||||
return AppFlowyPopover(
|
||||
controller: controller,
|
||||
constraints: BoxConstraints.loose(size),
|
||||
triggerActions: 0,
|
||||
popupBuilder: popupBuilder,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildColorPicker() {
|
||||
return FlowyColorPicker(
|
||||
colors: FlowyTint.values
|
||||
.map(
|
||||
(t) => ColorOption(
|
||||
color: t.color(context),
|
||||
name: t.tintName(AppFlowyEditorLocalizations.current),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
selected: tint.color(context),
|
||||
onTap: (color, index) {
|
||||
setColor(FlowyTint.values[index]);
|
||||
colorPopoverController.close();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmoji() {
|
||||
return _popover(
|
||||
controller: emojiPopoverController,
|
||||
popupBuilder: (context) => _buildEmojiPicker(),
|
||||
size: const Size(300, 200),
|
||||
child: FlowyTextButton(
|
||||
emoji,
|
||||
fontSize: 18,
|
||||
fillColor: Colors.transparent,
|
||||
onPressed: () {
|
||||
emojiPopoverController.show();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmojiPicker() {
|
||||
return EmojiSelectionMenu(
|
||||
editorState: widget.editorState,
|
||||
onSubmitted: (emoji) {
|
||||
setEmoji(emoji.emoji);
|
||||
emojiPopoverController.close();
|
||||
},
|
||||
onExit: () {},
|
||||
);
|
||||
}
|
||||
|
||||
void setColor(FlowyTint tint) {
|
||||
final transaction = widget.editorState.transaction
|
||||
..updateNode(widget.node, {
|
||||
kCalloutAttrColor: tint.name,
|
||||
});
|
||||
widget.editorState.apply(transaction);
|
||||
}
|
||||
|
||||
void setEmoji(String emoji) {
|
||||
final transaction = widget.editorState.transaction
|
||||
..updateNode(widget.node, {
|
||||
kCalloutAttrEmoji: emoji,
|
||||
});
|
||||
widget.editorState.apply(transaction);
|
||||
}
|
||||
|
||||
void deleteNode() {
|
||||
final transaction = widget.editorState.transaction..deleteNode(widget.node);
|
||||
widget.editorState.apply(transaction);
|
||||
}
|
||||
|
||||
FlowyTint get tint {
|
||||
final name = widget.node.attributes[kCalloutAttrColor];
|
||||
return (name is String) ? FlowyTint.fromJson(name) : FlowyTint.tint1;
|
||||
}
|
||||
|
||||
String get emoji {
|
||||
return widget.node.attributes[kCalloutAttrEmoji] ?? "💡";
|
||||
}
|
||||
|
||||
@override
|
||||
Position start() => Position(path: widget.node.path, offset: 0);
|
||||
|
||||
@override
|
||||
Position end() => Position(path: widget.node.path, offset: 1);
|
||||
|
||||
@override
|
||||
Position getPositionInOffset(Offset start) => end();
|
||||
|
||||
@override
|
||||
bool get shouldCursorBlink => false;
|
||||
|
||||
@override
|
||||
CursorStyle get cursorStyle => CursorStyle.borderLine;
|
||||
|
||||
@override
|
||||
Rect? getCursorRectInPosition(Position position) {
|
||||
final size = _renderBox.size;
|
||||
return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Rect> getRectsInSelection(Selection selection) =>
|
||||
[Offset.zero & _renderBox.size];
|
||||
|
||||
@override
|
||||
Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
|
||||
path: widget.node.path,
|
||||
startOffset: 0,
|
||||
endOffset: 1,
|
||||
);
|
||||
|
||||
@override
|
||||
Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset);
|
||||
}
|
@ -0,0 +1,224 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:highlight/highlight.dart' as highlight;
|
||||
import 'package:highlight/languages/all.dart';
|
||||
|
||||
const String kCodeBlockType = 'text/$kCodeBlockSubType';
|
||||
const String kCodeBlockSubType = 'code_block';
|
||||
const String kCodeBlockAttrTheme = 'theme';
|
||||
const String kCodeBlockAttrLanguage = 'language';
|
||||
|
||||
class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder<TextNode>
|
||||
with ActionProvider<TextNode> {
|
||||
@override
|
||||
Widget build(NodeWidgetContext<TextNode> context) {
|
||||
return _CodeBlockNodeWidge(
|
||||
key: context.node.key,
|
||||
textNode: context.node,
|
||||
editorState: context.editorState,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
NodeValidator<Node> get nodeValidator => (node) {
|
||||
return node is TextNode &&
|
||||
node.attributes[kCodeBlockAttrTheme] is String;
|
||||
};
|
||||
|
||||
@override
|
||||
List<ActionMenuItem> actions(NodeWidgetContext<TextNode> context) {
|
||||
return [
|
||||
ActionMenuItem.svg(
|
||||
name: 'delete',
|
||||
onPressed: () {
|
||||
final transaction = context.editorState.transaction
|
||||
..deleteNode(context.node);
|
||||
context.editorState.apply(transaction);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class _CodeBlockNodeWidge extends StatefulWidget {
|
||||
const _CodeBlockNodeWidge({
|
||||
Key? key,
|
||||
required this.textNode,
|
||||
required this.editorState,
|
||||
}) : super(key: key);
|
||||
|
||||
final TextNode textNode;
|
||||
final EditorState editorState;
|
||||
|
||||
@override
|
||||
State<_CodeBlockNodeWidge> createState() => __CodeBlockNodeWidgeState();
|
||||
}
|
||||
|
||||
class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
|
||||
with SelectableMixin, DefaultSelectable {
|
||||
final _richTextKey = GlobalKey(debugLabel: kCodeBlockType);
|
||||
final _padding = const EdgeInsets.only(left: 20, top: 30, bottom: 30);
|
||||
String? get _language =>
|
||||
widget.textNode.attributes[kCodeBlockAttrLanguage] as String?;
|
||||
String? _detectLanguage;
|
||||
|
||||
@override
|
||||
SelectableMixin<StatefulWidget> get forward =>
|
||||
_richTextKey.currentState as SelectableMixin;
|
||||
|
||||
@override
|
||||
GlobalKey<State<StatefulWidget>>? get iconKey => null;
|
||||
|
||||
@override
|
||||
Offset get baseOffset => super.baseOffset + _padding.topLeft;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
_buildCodeBlock(context),
|
||||
_buildSwitchCodeButton(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCodeBlock(BuildContext context) {
|
||||
final result = highlight.highlight.parse(
|
||||
widget.textNode.toPlainText(),
|
||||
language: _language,
|
||||
autoDetection: _language == null,
|
||||
);
|
||||
_detectLanguage = _language ?? result.language;
|
||||
final code = result.nodes;
|
||||
final codeTextSpan = _convert(code!);
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
),
|
||||
padding: _padding,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: FlowyRichText(
|
||||
key: _richTextKey,
|
||||
textNode: widget.textNode,
|
||||
editorState: widget.editorState,
|
||||
lineHeight: 1.0,
|
||||
cursorHeight: 15.0,
|
||||
textSpanDecorator: (textSpan) => TextSpan(
|
||||
style: widget.editorState.editorStyle.textStyle,
|
||||
children: codeTextSpan,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSwitchCodeButton(BuildContext context) {
|
||||
return Positioned(
|
||||
top: -5,
|
||||
left: 10,
|
||||
child: SizedBox(
|
||||
height: 35,
|
||||
child: DropdownButton<String>(
|
||||
value: _detectLanguage,
|
||||
iconSize: 14.0,
|
||||
onChanged: (value) {
|
||||
final transaction = widget.editorState.transaction
|
||||
..updateNode(widget.textNode, {
|
||||
kCodeBlockAttrLanguage: value,
|
||||
});
|
||||
widget.editorState.apply(transaction);
|
||||
},
|
||||
items:
|
||||
allLanguages.keys.map<DropdownMenuItem<String>>((String value) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: value,
|
||||
child: FlowyText.medium(
|
||||
value,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
),
|
||||
);
|
||||
}).toList(growable: false),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Copy from flutter.highlight package.
|
||||
// https://github.com/git-touch/highlight.dart/blob/master/flutter_highlight/lib/flutter_highlight.dart
|
||||
List<TextSpan> _convert(List<highlight.Node> nodes) {
|
||||
List<TextSpan> spans = [];
|
||||
var currentSpans = spans;
|
||||
List<List<TextSpan>> stack = [];
|
||||
|
||||
void traverse(highlight.Node node) {
|
||||
if (node.value != null) {
|
||||
currentSpans.add(node.className == null
|
||||
? TextSpan(text: node.value)
|
||||
: TextSpan(
|
||||
text: node.value,
|
||||
style: _builtInCodeBlockTheme[node.className!],),);
|
||||
} else if (node.children != null) {
|
||||
List<TextSpan> tmp = [];
|
||||
currentSpans.add(TextSpan(
|
||||
children: tmp, style: _builtInCodeBlockTheme[node.className!],),);
|
||||
stack.add(currentSpans);
|
||||
currentSpans = tmp;
|
||||
|
||||
for (var n in node.children!) {
|
||||
traverse(n);
|
||||
if (n == node.children!.last) {
|
||||
currentSpans = stack.isEmpty ? spans : stack.removeLast();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var node in nodes) {
|
||||
traverse(node);
|
||||
}
|
||||
|
||||
return spans;
|
||||
}
|
||||
}
|
||||
|
||||
const _builtInCodeBlockTheme = {
|
||||
'root':
|
||||
TextStyle(backgroundColor: Color(0xffffffff), color: Color(0xff000000)),
|
||||
'comment': TextStyle(color: Color(0xff007400)),
|
||||
'quote': TextStyle(color: Color(0xff007400)),
|
||||
'tag': TextStyle(color: Color(0xffaa0d91)),
|
||||
'attribute': TextStyle(color: Color(0xffaa0d91)),
|
||||
'keyword': TextStyle(color: Color(0xffaa0d91)),
|
||||
'selector-tag': TextStyle(color: Color(0xffaa0d91)),
|
||||
'literal': TextStyle(color: Color(0xffaa0d91)),
|
||||
'name': TextStyle(color: Color(0xffaa0d91)),
|
||||
'variable': TextStyle(color: Color(0xff3F6E74)),
|
||||
'template-variable': TextStyle(color: Color(0xff3F6E74)),
|
||||
'code': TextStyle(color: Color(0xffc41a16)),
|
||||
'string': TextStyle(color: Color(0xffc41a16)),
|
||||
'meta-string': TextStyle(color: Color(0xffc41a16)),
|
||||
'regexp': TextStyle(color: Color(0xff0E0EFF)),
|
||||
'link': TextStyle(color: Color(0xff0E0EFF)),
|
||||
'title': TextStyle(color: Color(0xff1c00cf)),
|
||||
'symbol': TextStyle(color: Color(0xff1c00cf)),
|
||||
'bullet': TextStyle(color: Color(0xff1c00cf)),
|
||||
'number': TextStyle(color: Color(0xff1c00cf)),
|
||||
'section': TextStyle(color: Color(0xff643820)),
|
||||
'meta': TextStyle(color: Color(0xff643820)),
|
||||
'type': TextStyle(color: Color(0xff5c2699)),
|
||||
'built_in': TextStyle(color: Color(0xff5c2699)),
|
||||
'builtin-name': TextStyle(color: Color(0xff5c2699)),
|
||||
'params': TextStyle(color: Color(0xff5c2699)),
|
||||
'attr': TextStyle(color: Color(0xff836C28)),
|
||||
'subst': TextStyle(color: Color(0xff000000)),
|
||||
'formula': TextStyle(
|
||||
backgroundColor: Color(0xffeeeeee), fontStyle: FontStyle.italic,),
|
||||
'addition': TextStyle(backgroundColor: Color(0xffbaeeba)),
|
||||
'deletion': TextStyle(backgroundColor: Color(0xffffc8bd)),
|
||||
'selector-id': TextStyle(color: Color(0xff9b703f)),
|
||||
'selector-class': TextStyle(color: Color(0xff9b703f)),
|
||||
'doctag': TextStyle(fontWeight: FontWeight.bold),
|
||||
'strong': TextStyle(fontWeight: FontWeight.bold),
|
||||
'emphasis': TextStyle(fontStyle: FontStyle.italic),
|
||||
};
|
@ -0,0 +1,125 @@
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
ShortcutEvent enterInCodeBlock = ShortcutEvent(
|
||||
key: 'Press Enter In Code Block',
|
||||
command: 'enter',
|
||||
handler: _enterInCodeBlockHandler,
|
||||
);
|
||||
|
||||
ShortcutEvent ignoreKeysInCodeBlock = ShortcutEvent(
|
||||
key: 'White space in code block',
|
||||
command: 'space, slash, shift+underscore',
|
||||
handler: _ignorekHandler,
|
||||
);
|
||||
|
||||
ShortcutEvent pasteInCodeBlock = ShortcutEvent(
|
||||
key: 'Paste in code block',
|
||||
command: 'meta+v',
|
||||
windowsCommand: 'ctrl+v',
|
||||
linuxCommand: 'ctrl+v',
|
||||
handler: _pasteHandler,
|
||||
);
|
||||
|
||||
ShortcutEventHandler _enterInCodeBlockHandler = (editorState, event) {
|
||||
final selection = editorState.service.selectionService.currentSelection.value;
|
||||
final nodes = editorState.service.selectionService.currentSelectedNodes;
|
||||
final codeBlockNode =
|
||||
nodes.whereType<TextNode>().where((node) => node.id == kCodeBlockType);
|
||||
if (codeBlockNode.length != 1 ||
|
||||
selection == null ||
|
||||
!selection.isCollapsed) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final transaction = editorState.transaction
|
||||
..insertText(
|
||||
codeBlockNode.first,
|
||||
selection.end.offset,
|
||||
'\n',
|
||||
);
|
||||
editorState.apply(transaction);
|
||||
return KeyEventResult.handled;
|
||||
};
|
||||
|
||||
ShortcutEventHandler _ignorekHandler = (editorState, event) {
|
||||
final nodes = editorState.service.selectionService.currentSelectedNodes;
|
||||
final codeBlockNodes =
|
||||
nodes.whereType<TextNode>().where((node) => node.id == kCodeBlockType);
|
||||
if (codeBlockNodes.length == 1) {
|
||||
return KeyEventResult.skipRemainingHandlers;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
};
|
||||
|
||||
ShortcutEventHandler _pasteHandler = (editorState, event) {
|
||||
final selection = editorState.service.selectionService.currentSelection.value;
|
||||
final nodes = editorState.service.selectionService.currentSelectedNodes;
|
||||
final codeBlockNodes =
|
||||
nodes.whereType<TextNode>().where((node) => node.id == kCodeBlockType);
|
||||
if (selection != null &&
|
||||
selection.isCollapsed &&
|
||||
codeBlockNodes.length == 1) {
|
||||
Clipboard.getData(Clipboard.kTextPlain).then((value) {
|
||||
final text = value?.text;
|
||||
if (text == null) return;
|
||||
final transaction = editorState.transaction;
|
||||
transaction.insertText(
|
||||
codeBlockNodes.first,
|
||||
selection.startIndex,
|
||||
text,
|
||||
);
|
||||
editorState.apply(transaction);
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
};
|
||||
|
||||
SelectionMenuItem codeBlockMenuItem = SelectionMenuItem(
|
||||
name: 'Code Block',
|
||||
icon: (editorState, onSelected) => Icon(
|
||||
Icons.abc,
|
||||
color: onSelected
|
||||
? editorState.editorStyle.selectionMenuItemSelectedIconColor
|
||||
: editorState.editorStyle.selectionMenuItemIconColor,
|
||||
size: 18.0,
|
||||
),
|
||||
keywords: ['code block', 'code snippet'],
|
||||
handler: (editorState, _, __) {
|
||||
final selection =
|
||||
editorState.service.selectionService.currentSelection.value;
|
||||
final textNodes = editorState.service.selectionService.currentSelectedNodes
|
||||
.whereType<TextNode>();
|
||||
if (selection == null || textNodes.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final transaction = editorState.transaction;
|
||||
final textNode = textNodes.first;
|
||||
if (textNode.toPlainText().isEmpty && textNode.next is TextNode) {
|
||||
transaction.updateNode(textNodes.first, {
|
||||
BuiltInAttributeKey.subtype: kCodeBlockSubType,
|
||||
kCodeBlockAttrTheme: 'vs',
|
||||
kCodeBlockAttrLanguage: null,
|
||||
});
|
||||
transaction.afterSelection = selection;
|
||||
editorState.apply(transaction);
|
||||
} else {
|
||||
transaction.insertNode(
|
||||
selection.end.path,
|
||||
TextNode(
|
||||
attributes: {
|
||||
BuiltInAttributeKey.subtype: kCodeBlockSubType,
|
||||
kCodeBlockAttrTheme: 'vs',
|
||||
kCodeBlockAttrLanguage: null,
|
||||
},
|
||||
delta: Delta()..insert('\n'),
|
||||
),
|
||||
);
|
||||
transaction.afterSelection = selection;
|
||||
}
|
||||
editorState.apply(transaction);
|
||||
},
|
||||
);
|
@ -2,11 +2,8 @@ import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/cover/change_cover_popover_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/cover/cover_image_picker.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/cover/cover_node_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
@ -257,8 +254,6 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
|
||||
if (index == 0) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary.withOpacity(0.15),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
@ -270,6 +265,8 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
|
||||
Icons.add,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
hoverColor:
|
||||
Theme.of(context).colorScheme.primary.withOpacity(0.15),
|
||||
width: 20,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
|
@ -145,7 +145,7 @@ class _NetworkImageUrlInputState extends State<NetworkImageUrlInput> {
|
||||
},
|
||||
hoverColor: Colors.transparent,
|
||||
fillColor: buttonDisabled
|
||||
? Colors.grey
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
height: 36,
|
||||
title: LocaleKeys.document_plugins_cover_add.tr(),
|
||||
@ -174,7 +174,7 @@ class ImagePickerActionButtons extends StatelessWidget {
|
||||
children: [
|
||||
FlowyTextButton(
|
||||
LocaleKeys.document_plugins_cover_back.tr(),
|
||||
hoverColor: Colors.transparent,
|
||||
hoverColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
fillColor: Colors.transparent,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
onPressed: () => onBackPressed(),
|
||||
@ -182,7 +182,7 @@ class ImagePickerActionButtons extends StatelessWidget {
|
||||
FlowyTextButton(
|
||||
LocaleKeys.document_plugins_cover_saveToGallery.tr(),
|
||||
onPressed: () => onSave(),
|
||||
hoverColor: Colors.transparent,
|
||||
hoverColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
fillColor: Colors.transparent,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
fontColor: Theme.of(context).colorScheme.primary,
|
||||
@ -204,48 +204,61 @@ class CoverImagePreviewWidget extends StatefulWidget {
|
||||
|
||||
class _CoverImagePreviewWidgetState extends State<CoverImagePreviewWidget> {
|
||||
_buildFilePickerWidget(BuildContext ctx) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
svgWidget(
|
||||
"editor/add",
|
||||
size: const Size(20, 20),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 3,
|
||||
),
|
||||
FlowyText(
|
||||
LocaleKeys.document_plugins_cover_pasteImageUrl.tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 10,
|
||||
),
|
||||
FlowyText(
|
||||
LocaleKeys.document_plugins_cover_or.tr(),
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 10,
|
||||
),
|
||||
FlowyButton(
|
||||
onTap: () {
|
||||
ctx.read<CoverImagePickerBloc>().add(const PickFileImage());
|
||||
},
|
||||
useIntrinsicWidth: true,
|
||||
leftIcon: svgWidget(
|
||||
"file_icon",
|
||||
size: const Size(25, 25),
|
||||
),
|
||||
text: FlowyText(
|
||||
LocaleKeys.document_plugins_cover_pickFromFiles.tr(),
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: Corners.s6Border,
|
||||
border: Border.fromBorderSide(
|
||||
BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const FlowySvg(
|
||||
name: 'editor/add',
|
||||
size: Size(20, 20),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 3,
|
||||
),
|
||||
FlowyText(
|
||||
LocaleKeys.document_plugins_cover_pasteImageUrl.tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 10,
|
||||
),
|
||||
FlowyText(
|
||||
LocaleKeys.document_plugins_cover_or.tr(),
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 10,
|
||||
),
|
||||
FlowyButton(
|
||||
hoverColor: Theme.of(context).hoverColor,
|
||||
onTap: () {
|
||||
ctx.read<CoverImagePickerBloc>().add(const PickFileImage());
|
||||
},
|
||||
useIntrinsicWidth: true,
|
||||
leftIcon: const FlowySvg(
|
||||
name: 'file_icon',
|
||||
size: Size(20, 20),
|
||||
),
|
||||
text: FlowyText(
|
||||
LocaleKeys.document_plugins_cover_pickFromFiles.tr(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -5,7 +5,7 @@ import 'package:appflowy/plugins/document/presentation/plugins/cover/change_cove
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/cover/emoji_popover.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/cover/icon_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/emoji_picker/emoji_picker.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide FlowySvg;
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
@ -393,21 +393,32 @@ class _CoverImageState extends State<_CoverImage> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppFlowyPopover(
|
||||
onClose: () {
|
||||
setOverlayButtonsHidden(true);
|
||||
},
|
||||
offset: const Offset(-125, 10),
|
||||
controller: popoverController,
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
constraints: BoxConstraints.loose(const Size(380, 450)),
|
||||
margin: EdgeInsets.zero,
|
||||
child: RoundedTextButton(
|
||||
onPressed: () {
|
||||
popoverController.show();
|
||||
},
|
||||
hoverColor: Theme.of(context).colorScheme.surface,
|
||||
textColor: Theme.of(context).colorScheme.tertiary,
|
||||
fillColor: Theme.of(context).colorScheme.surface.withOpacity(0.8),
|
||||
width: 120,
|
||||
height: 28,
|
||||
title: LocaleKeys.document_plugins_cover_changeCover.tr(),
|
||||
child: Visibility(
|
||||
maintainState: true,
|
||||
maintainAnimation: true,
|
||||
maintainSize: true,
|
||||
visible: !isOverlayButtonsHidden,
|
||||
child: RoundedTextButton(
|
||||
onPressed: () {
|
||||
popoverController.show();
|
||||
setOverlayButtonsHidden(true);
|
||||
},
|
||||
hoverColor: Theme.of(context).colorScheme.surface,
|
||||
textColor: Theme.of(context).colorScheme.tertiary,
|
||||
fillColor:
|
||||
Theme.of(context).colorScheme.surface.withOpacity(0.5),
|
||||
width: 120,
|
||||
height: 28,
|
||||
title: LocaleKeys.document_plugins_cover_changeCover.tr(),
|
||||
),
|
||||
),
|
||||
popupBuilder: (BuildContext popoverContext) {
|
||||
return ChangeCoverPopover(
|
||||
@ -418,18 +429,24 @@ class _CoverImageState extends State<_CoverImage> {
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
FlowyIconButton(
|
||||
fillColor: Theme.of(context).colorScheme.surface.withOpacity(0.8),
|
||||
hoverColor: Theme.of(context).colorScheme.surface,
|
||||
iconPadding: const EdgeInsets.all(5),
|
||||
width: 28,
|
||||
icon: svgWidget(
|
||||
'editor/delete',
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
Visibility(
|
||||
maintainAnimation: true,
|
||||
maintainSize: true,
|
||||
maintainState: true,
|
||||
visible: !isOverlayButtonsHidden,
|
||||
child: FlowyIconButton(
|
||||
hoverColor: Theme.of(context).colorScheme.surface,
|
||||
fillColor: Theme.of(context).colorScheme.surface.withOpacity(0.5),
|
||||
iconPadding: const EdgeInsets.all(5),
|
||||
width: 28,
|
||||
icon: svgWidget(
|
||||
'editor/delete',
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
),
|
||||
onPressed: () {
|
||||
widget.onCoverChanged(CoverSelectionType.initial, null);
|
||||
},
|
||||
),
|
||||
onPressed: () {
|
||||
widget.onCoverChanged(CoverSelectionType.initial, null);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -477,20 +494,30 @@ class _CoverImageState extends State<_CoverImage> {
|
||||
break;
|
||||
}
|
||||
//OverflowBox needs to be wraped by a widget with constraints(or from its parent) first,otherwise it will occur an error
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: OverflowBox(
|
||||
maxWidth: screenSize.width,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
height: double.infinity,
|
||||
width: double.infinity,
|
||||
child: coverImage,
|
||||
),
|
||||
hasCover ? _buildCoverOverlayButtons(context) : const SizedBox()
|
||||
],
|
||||
return MouseRegion(
|
||||
onEnter: (event) {
|
||||
setOverlayButtonsHidden(false);
|
||||
},
|
||||
onExit: (event) {
|
||||
setOverlayButtonsHidden(true);
|
||||
},
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: OverflowBox(
|
||||
maxWidth: screenSize.width,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
height: double.infinity,
|
||||
width: double.infinity,
|
||||
child: coverImage,
|
||||
),
|
||||
hasCover
|
||||
? _buildCoverOverlayButtons(context)
|
||||
: const SizedBox.shrink()
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -4,7 +4,6 @@ import 'package:appflowy/workspace/presentation/widgets/emoji_picker/src/default
|
||||
import 'package:appflowy/workspace/presentation/widgets/emoji_picker/src/emoji_view_state.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -0,0 +1,84 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const String kDividerType = 'divider';
|
||||
|
||||
class DividerWidgetBuilder extends NodeWidgetBuilder<Node> {
|
||||
@override
|
||||
Widget build(NodeWidgetContext<Node> context) {
|
||||
return _DividerWidget(
|
||||
key: context.node.key,
|
||||
node: context.node,
|
||||
editorState: context.editorState,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
NodeValidator<Node> get nodeValidator => (node) {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
class _DividerWidget extends StatefulWidget {
|
||||
const _DividerWidget({
|
||||
Key? key,
|
||||
required this.node,
|
||||
required this.editorState,
|
||||
}) : super(key: key);
|
||||
|
||||
final Node node;
|
||||
final EditorState editorState;
|
||||
|
||||
@override
|
||||
State<_DividerWidget> createState() => _DividerWidgetState();
|
||||
}
|
||||
|
||||
class _DividerWidgetState extends State<_DividerWidget> with SelectableMixin {
|
||||
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: Colors.grey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Position start() => Position(path: widget.node.path, offset: 0);
|
||||
|
||||
@override
|
||||
Position end() => Position(path: widget.node.path, offset: 1);
|
||||
|
||||
@override
|
||||
Position getPositionInOffset(Offset start) => end();
|
||||
|
||||
@override
|
||||
bool get shouldCursorBlink => false;
|
||||
|
||||
@override
|
||||
CursorStyle get cursorStyle => CursorStyle.borderLine;
|
||||
|
||||
@override
|
||||
Rect? getCursorRectInPosition(Position position) {
|
||||
final size = _renderBox.size;
|
||||
return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Rect> getRectsInSelection(Selection selection) =>
|
||||
[Offset.zero & _renderBox.size];
|
||||
|
||||
@override
|
||||
Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
|
||||
path: widget.node.path,
|
||||
startOffset: 0,
|
||||
endOffset: 1,
|
||||
);
|
||||
|
||||
@override
|
||||
Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset);
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/divider/divider_node_widget.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// insert divider into a document by typing three minuses.
|
||||
// ---
|
||||
ShortcutEvent insertDividerEvent = ShortcutEvent(
|
||||
key: 'Divider',
|
||||
command: 'Minus',
|
||||
handler: _insertDividerHandler,
|
||||
);
|
||||
|
||||
ShortcutEventHandler _insertDividerHandler = (editorState, event) {
|
||||
final selection = editorState.service.selectionService.currentSelection.value;
|
||||
final textNodes = editorState.service.selectionService.currentSelectedNodes
|
||||
.whereType<TextNode>();
|
||||
if (textNodes.length != 1 || selection == null) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
final textNode = textNodes.first;
|
||||
if (textNode.toPlainText() != '--') {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
final transaction = editorState.transaction
|
||||
..deleteText(textNode, 0, 2) // remove the existing minuses.
|
||||
..insertNode(textNode.path, Node(type: kDividerType)) // insert the divder
|
||||
..afterSelection = Selection.single(
|
||||
// update selection to the next text node.
|
||||
path: textNode.path.next,
|
||||
startOffset: 0,
|
||||
);
|
||||
editorState.apply(transaction);
|
||||
return KeyEventResult.handled;
|
||||
};
|
||||
|
||||
SelectionMenuItem dividerMenuItem = SelectionMenuItem(
|
||||
name: 'Divider',
|
||||
icon: (editorState, onSelected) => Icon(
|
||||
Icons.horizontal_rule,
|
||||
color: onSelected
|
||||
? editorState.editorStyle.selectionMenuItemSelectedIconColor
|
||||
: editorState.editorStyle.selectionMenuItemIconColor,
|
||||
size: 18.0,
|
||||
),
|
||||
keywords: ['horizontal rule', 'divider'],
|
||||
handler: (editorState, _, __) {
|
||||
final selection =
|
||||
editorState.service.selectionService.currentSelection.value;
|
||||
final textNodes = editorState.service.selectionService.currentSelectedNodes
|
||||
.whereType<TextNode>();
|
||||
if (textNodes.length != 1 || selection == null) {
|
||||
return;
|
||||
}
|
||||
final textNode = textNodes.first;
|
||||
// insert the divider at current path if the text node is empty.
|
||||
if (textNode.toPlainText().isEmpty) {
|
||||
final transaction = editorState.transaction
|
||||
..insertNode(textNode.path, Node(type: kDividerType))
|
||||
..afterSelection = Selection.single(
|
||||
path: textNode.path.next,
|
||||
startOffset: 0,
|
||||
);
|
||||
editorState.apply(transaction);
|
||||
} else {
|
||||
// insert the divider at the path next to current path if the text node is not empty.
|
||||
final transaction = editorState.transaction
|
||||
..insertNode(selection.end.path.next, Node(type: kDividerType))
|
||||
..afterSelection = selection;
|
||||
editorState.apply(transaction);
|
||||
}
|
||||
},
|
||||
);
|
@ -0,0 +1,176 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'emoji_picker.dart';
|
||||
|
||||
SelectionMenuItem emojiMenuItem = SelectionMenuItem(
|
||||
name: 'Emoji',
|
||||
icon: (editorState, onSelected) => Icon(
|
||||
Icons.emoji_emotions_outlined,
|
||||
color: onSelected
|
||||
? editorState.editorStyle.selectionMenuItemSelectedIconColor
|
||||
: editorState.editorStyle.selectionMenuItemIconColor,
|
||||
size: 18.0,
|
||||
),
|
||||
keywords: ['emoji'],
|
||||
handler: _showEmojiSelectionMenu,
|
||||
);
|
||||
|
||||
OverlayEntry? _emojiSelectionMenu;
|
||||
EditorState? _editorState;
|
||||
void _showEmojiSelectionMenu(
|
||||
EditorState editorState,
|
||||
SelectionMenuService menuService,
|
||||
BuildContext context,
|
||||
) {
|
||||
final alignment = menuService.alignment;
|
||||
final offset = menuService.offset;
|
||||
menuService.dismiss();
|
||||
|
||||
_emojiSelectionMenu?.remove();
|
||||
_emojiSelectionMenu = OverlayEntry(builder: (context) {
|
||||
return Positioned(
|
||||
top: alignment == Alignment.bottomLeft ? offset.dy : null,
|
||||
bottom: alignment == Alignment.topLeft ? offset.dy : null,
|
||||
left: offset.dx,
|
||||
child: Material(
|
||||
child: EmojiSelectionMenu(
|
||||
editorState: editorState,
|
||||
onSubmitted: (text) {
|
||||
// insert emoji
|
||||
editorState.insertEmoji(text);
|
||||
},
|
||||
onExit: () {
|
||||
_dismissEmojiSelectionMenu();
|
||||
//close emoji panel
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},);
|
||||
|
||||
Overlay.of(context).insert(_emojiSelectionMenu!);
|
||||
|
||||
editorState.service.selectionService.currentSelection
|
||||
.addListener(_dismissEmojiSelectionMenu);
|
||||
}
|
||||
|
||||
void _dismissEmojiSelectionMenu() {
|
||||
_emojiSelectionMenu?.remove();
|
||||
_emojiSelectionMenu = null;
|
||||
|
||||
_editorState?.service.selectionService.currentSelection
|
||||
.removeListener(_dismissEmojiSelectionMenu);
|
||||
_editorState = null;
|
||||
}
|
||||
|
||||
class EmojiSelectionMenu extends StatefulWidget {
|
||||
const EmojiSelectionMenu({
|
||||
Key? key,
|
||||
required this.onSubmitted,
|
||||
required this.onExit,
|
||||
required this.editorState,
|
||||
}) : super(key: key);
|
||||
|
||||
final void Function(Emoji emoji) onSubmitted;
|
||||
final void Function() onExit;
|
||||
final EditorState editorState;
|
||||
|
||||
@override
|
||||
State<EmojiSelectionMenu> createState() => _EmojiSelectionMenuState();
|
||||
}
|
||||
|
||||
class _EmojiSelectionMenuState extends State<EmojiSelectionMenu> {
|
||||
EditorStyle get style => widget.editorState.editorStyle;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
bool _handleGlobalKeyEvent(KeyEvent event) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.escape &&
|
||||
event is KeyDownEvent) {
|
||||
//triggers on esc
|
||||
widget.onExit();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void deactivate() {
|
||||
HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent);
|
||||
super.deactivate();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 300,
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
decoration: BoxDecoration(
|
||||
color: style.selectionMenuBackgroundColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 5,
|
||||
spreadRadius: 1,
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
),
|
||||
child: _buildEmojiBox(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmojiBox(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 200,
|
||||
child: EmojiPicker(
|
||||
onEmojiSelected: (category, emoji) => widget.onSubmitted(emoji),
|
||||
config: Config(
|
||||
columns: 8,
|
||||
emojiSizeMax: 28,
|
||||
bgColor:
|
||||
style.selectionMenuBackgroundColor ?? const Color(0xffF2F2F2),
|
||||
iconColor: Colors.grey,
|
||||
iconColorSelected: const Color(0xff333333),
|
||||
indicatorColor: const Color(0xff333333),
|
||||
progressIndicatorColor: const Color(0xff333333),
|
||||
buttonMode: ButtonMode.CUPERTINO,
|
||||
initCategory: Category.RECENT,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on EditorState {
|
||||
void insertEmoji(Emoji emoji) {
|
||||
final selectionService = service.selectionService;
|
||||
final currentSelection = selectionService.currentSelection.value;
|
||||
final nodes = selectionService.currentSelectedNodes;
|
||||
if (currentSelection == null ||
|
||||
!currentSelection.isCollapsed ||
|
||||
nodes.first is! TextNode) {
|
||||
return;
|
||||
}
|
||||
final textNode = nodes.first as TextNode;
|
||||
final tr = transaction;
|
||||
tr.insertText(
|
||||
textNode,
|
||||
currentSelection.endIndex,
|
||||
emoji.emoji,
|
||||
);
|
||||
apply(tr);
|
||||
}
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
export 'src/config.dart';
|
||||
export 'src/emoji_picker.dart';
|
||||
export 'src/emoji_picker_builder.dart';
|
||||
export 'src/models/emoji_model.dart';
|
@ -0,0 +1,165 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'models/category_models.dart';
|
||||
import 'emoji_picker.dart';
|
||||
|
||||
/// Config for customizations
|
||||
class Config {
|
||||
/// Constructor
|
||||
const Config(
|
||||
{this.columns = 7,
|
||||
this.emojiSizeMax = 32.0,
|
||||
this.verticalSpacing = 0,
|
||||
this.horizontalSpacing = 0,
|
||||
this.initCategory = Category.RECENT,
|
||||
this.bgColor = const Color(0xFFEBEFF2),
|
||||
this.indicatorColor = Colors.blue,
|
||||
this.iconColor = Colors.grey,
|
||||
this.iconColorSelected = Colors.blue,
|
||||
this.progressIndicatorColor = Colors.blue,
|
||||
this.backspaceColor = Colors.blue,
|
||||
this.showRecentsTab = true,
|
||||
this.recentsLimit = 28,
|
||||
this.noRecentsText = 'No Recents',
|
||||
this.noRecentsStyle =
|
||||
const TextStyle(fontSize: 20, color: Colors.black26),
|
||||
this.tabIndicatorAnimDuration = kTabScrollDuration,
|
||||
this.categoryIcons = const CategoryIcons(),
|
||||
this.buttonMode = ButtonMode.MATERIAL,});
|
||||
|
||||
/// Number of emojis per row
|
||||
final int columns;
|
||||
|
||||
/// Width and height the emoji will be maximal displayed
|
||||
/// Can be smaller due to screen size and amount of columns
|
||||
final double emojiSizeMax;
|
||||
|
||||
/// Vertical spacing between emojis
|
||||
final double verticalSpacing;
|
||||
|
||||
/// Horizontal spacing between emojis
|
||||
final double horizontalSpacing;
|
||||
|
||||
/// The initial [Category] that will be selected
|
||||
/// This [Category] will have its button in the bottombar darkened
|
||||
final Category initCategory;
|
||||
|
||||
/// The background color of the Widget
|
||||
final Color bgColor;
|
||||
|
||||
/// The color of the category indicator
|
||||
final Color indicatorColor;
|
||||
|
||||
/// The color of the category icons
|
||||
final Color iconColor;
|
||||
|
||||
/// The color of the category icon when selected
|
||||
final Color iconColorSelected;
|
||||
|
||||
/// The color of the loading indicator during initialization
|
||||
final Color progressIndicatorColor;
|
||||
|
||||
/// The color of the backspace icon button
|
||||
final Color backspaceColor;
|
||||
|
||||
/// Show extra tab with recently used emoji
|
||||
final bool showRecentsTab;
|
||||
|
||||
/// Limit of recently used emoji that will be saved
|
||||
final int recentsLimit;
|
||||
|
||||
/// The text to be displayed if no recent emojis to display
|
||||
final String noRecentsText;
|
||||
|
||||
/// The text style for [noRecentsText]
|
||||
final TextStyle noRecentsStyle;
|
||||
|
||||
/// Duration of tab indicator to animate to next category
|
||||
final Duration tabIndicatorAnimDuration;
|
||||
|
||||
/// Determines the icon to display for each [Category]
|
||||
final CategoryIcons categoryIcons;
|
||||
|
||||
/// Change between Material and Cupertino button style
|
||||
final ButtonMode buttonMode;
|
||||
|
||||
/// Get Emoji size based on properties and screen width
|
||||
double getEmojiSize(double width) {
|
||||
final maxSize = width / columns;
|
||||
return min(maxSize, emojiSizeMax);
|
||||
}
|
||||
|
||||
/// Returns the icon for the category
|
||||
IconData getIconForCategory(Category category) {
|
||||
switch (category) {
|
||||
case Category.RECENT:
|
||||
return categoryIcons.recentIcon;
|
||||
case Category.SMILEYS:
|
||||
return categoryIcons.smileyIcon;
|
||||
case Category.ANIMALS:
|
||||
return categoryIcons.animalIcon;
|
||||
case Category.FOODS:
|
||||
return categoryIcons.foodIcon;
|
||||
case Category.TRAVEL:
|
||||
return categoryIcons.travelIcon;
|
||||
case Category.ACTIVITIES:
|
||||
return categoryIcons.activityIcon;
|
||||
case Category.OBJECTS:
|
||||
return categoryIcons.objectIcon;
|
||||
case Category.SYMBOLS:
|
||||
return categoryIcons.symbolIcon;
|
||||
case Category.FLAGS:
|
||||
return categoryIcons.flagIcon;
|
||||
case Category.SEARCH:
|
||||
return categoryIcons.searchIcon;
|
||||
default:
|
||||
throw Exception('Unsupported Category');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(other) {
|
||||
return (other is Config) &&
|
||||
other.columns == columns &&
|
||||
other.emojiSizeMax == emojiSizeMax &&
|
||||
other.verticalSpacing == verticalSpacing &&
|
||||
other.horizontalSpacing == horizontalSpacing &&
|
||||
other.initCategory == initCategory &&
|
||||
other.bgColor == bgColor &&
|
||||
other.indicatorColor == indicatorColor &&
|
||||
other.iconColor == iconColor &&
|
||||
other.iconColorSelected == iconColorSelected &&
|
||||
other.progressIndicatorColor == progressIndicatorColor &&
|
||||
other.backspaceColor == backspaceColor &&
|
||||
other.showRecentsTab == showRecentsTab &&
|
||||
other.recentsLimit == recentsLimit &&
|
||||
other.noRecentsText == noRecentsText &&
|
||||
other.noRecentsStyle == noRecentsStyle &&
|
||||
other.tabIndicatorAnimDuration == tabIndicatorAnimDuration &&
|
||||
other.categoryIcons == categoryIcons &&
|
||||
other.buttonMode == buttonMode;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
columns.hashCode ^
|
||||
emojiSizeMax.hashCode ^
|
||||
verticalSpacing.hashCode ^
|
||||
horizontalSpacing.hashCode ^
|
||||
initCategory.hashCode ^
|
||||
bgColor.hashCode ^
|
||||
indicatorColor.hashCode ^
|
||||
iconColor.hashCode ^
|
||||
iconColorSelected.hashCode ^
|
||||
progressIndicatorColor.hashCode ^
|
||||
backspaceColor.hashCode ^
|
||||
showRecentsTab.hashCode ^
|
||||
recentsLimit.hashCode ^
|
||||
noRecentsText.hashCode ^
|
||||
noRecentsStyle.hashCode ^
|
||||
tabIndicatorAnimDuration.hashCode ^
|
||||
categoryIcons.hashCode ^
|
||||
buttonMode.hashCode;
|
||||
}
|
@ -0,0 +1,293 @@
|
||||
import 'package:flowy_infra_ui/style_widget/scrolling/styled_scroll_bar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'config.dart';
|
||||
import 'emoji_picker.dart';
|
||||
import 'emoji_picker_builder.dart';
|
||||
import 'emoji_view_state.dart';
|
||||
import 'models/category_models.dart';
|
||||
import 'models/emoji_model.dart';
|
||||
|
||||
class DefaultEmojiPickerView extends EmojiPickerBuilder {
|
||||
const DefaultEmojiPickerView(Config config, EmojiViewState state, {Key? key})
|
||||
: super(config, state, key: key);
|
||||
|
||||
@override
|
||||
DefaultEmojiPickerViewState createState() => DefaultEmojiPickerViewState();
|
||||
}
|
||||
|
||||
class DefaultEmojiPickerViewState extends State<DefaultEmojiPickerView>
|
||||
with TickerProviderStateMixin {
|
||||
PageController? _pageController;
|
||||
TabController? _tabController;
|
||||
final TextEditingController _emojiController = TextEditingController();
|
||||
final FocusNode _emojiFocusNode = FocusNode();
|
||||
CategoryEmoji searchEmojiList = CategoryEmoji(Category.SEARCH, <Emoji>[]);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
var initCategory = widget.state.categoryEmoji.indexWhere(
|
||||
(element) => element.category == widget.config.initCategory,);
|
||||
if (initCategory == -1) {
|
||||
initCategory = 0;
|
||||
}
|
||||
_tabController = TabController(
|
||||
initialIndex: initCategory,
|
||||
length: widget.state.categoryEmoji.length,
|
||||
vsync: this,);
|
||||
_pageController = PageController(initialPage: initCategory);
|
||||
_emojiFocusNode.requestFocus();
|
||||
|
||||
_emojiController.addListener(() {
|
||||
String query = _emojiController.text.toLowerCase();
|
||||
if (query.isEmpty) {
|
||||
searchEmojiList.emoji.clear();
|
||||
_pageController!.jumpToPage(
|
||||
_tabController!.index,
|
||||
);
|
||||
} else {
|
||||
searchEmojiList.emoji.clear();
|
||||
for (var element in widget.state.categoryEmoji) {
|
||||
searchEmojiList.emoji.addAll(
|
||||
element.emoji.where((item) {
|
||||
return item.name.toLowerCase().contains(query);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
setState(() {});
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emojiController.dispose();
|
||||
_emojiFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildBackspaceButton() {
|
||||
if (widget.state.onBackspacePressed != null) {
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: IconButton(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
icon: Icon(
|
||||
Icons.backspace,
|
||||
color: widget.config.backspaceColor,
|
||||
),
|
||||
onPressed: () {
|
||||
widget.state.onBackspacePressed!();
|
||||
},),
|
||||
);
|
||||
}
|
||||
return Container();
|
||||
}
|
||||
|
||||
bool isEmojiSearching() {
|
||||
bool result =
|
||||
searchEmojiList.emoji.isNotEmpty || _emojiController.text.isNotEmpty;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final emojiSize = widget.config.getEmojiSize(constraints.maxWidth);
|
||||
|
||||
return Container(
|
||||
color: widget.config.bgColor,
|
||||
padding: const EdgeInsets.all(5.0),
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 25.0,
|
||||
child: TextField(
|
||||
controller: _emojiController,
|
||||
focusNode: _emojiFocusNode,
|
||||
autofocus: true,
|
||||
style: const TextStyle(fontSize: 14.0),
|
||||
cursorWidth: 1.0,
|
||||
cursorColor: Colors.black,
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 5.0),
|
||||
hintText: "Search emoji",
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
borderSide: const BorderSide(),
|
||||
gapPadding: 0.0,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
borderSide: const BorderSide(),
|
||||
gapPadding: 0.0,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
hoverColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TabBar(
|
||||
labelColor: widget.config.iconColorSelected,
|
||||
unselectedLabelColor: widget.config.iconColor,
|
||||
controller: isEmojiSearching()
|
||||
? TabController(length: 1, vsync: this)
|
||||
: _tabController,
|
||||
labelPadding: EdgeInsets.zero,
|
||||
indicatorColor: widget.config.indicatorColor,
|
||||
padding: const EdgeInsets.symmetric(vertical: 5.0),
|
||||
indicator: BoxDecoration(
|
||||
border: Border.all(color: Colors.transparent),
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
color: Colors.grey.withOpacity(0.5),
|
||||
),
|
||||
onTap: (index) {
|
||||
_pageController!.animateToPage(
|
||||
index,
|
||||
duration: widget.config.tabIndicatorAnimDuration,
|
||||
curve: Curves.ease,
|
||||
);
|
||||
},
|
||||
tabs: isEmojiSearching()
|
||||
? [_buildCategory(Category.SEARCH, emojiSize)]
|
||||
: widget.state.categoryEmoji
|
||||
.asMap()
|
||||
.entries
|
||||
.map<Widget>((item) => _buildCategory(
|
||||
item.value.category, emojiSize,),)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
_buildBackspaceButton(),
|
||||
],
|
||||
),
|
||||
Flexible(
|
||||
child: PageView.builder(
|
||||
itemCount: searchEmojiList.emoji.isNotEmpty
|
||||
? 1
|
||||
: widget.state.categoryEmoji.length,
|
||||
controller: _pageController,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
// onPageChanged: (index) {
|
||||
// _tabController!.animateTo(
|
||||
// index,
|
||||
// duration: widget.config.tabIndicatorAnimDuration,
|
||||
// );
|
||||
// },
|
||||
itemBuilder: (context, index) {
|
||||
CategoryEmoji catEmoji = isEmojiSearching()
|
||||
? searchEmojiList
|
||||
: widget.state.categoryEmoji[index];
|
||||
return _buildPage(emojiSize, catEmoji);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategory(Category category, double categorySize) {
|
||||
return Tab(
|
||||
height: categorySize,
|
||||
child: Icon(
|
||||
widget.config.getIconForCategory(category),
|
||||
size: categorySize / 1.3,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildButtonWidget(
|
||||
{required VoidCallback onPressed, required Widget child,}) {
|
||||
if (widget.config.buttonMode == ButtonMode.MATERIAL) {
|
||||
return InkWell(
|
||||
onTap: onPressed,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
return GestureDetector(
|
||||
onTap: onPressed,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPage(double emojiSize, CategoryEmoji categoryEmoji) {
|
||||
// Display notice if recent has no entries yet
|
||||
final scrollController = ScrollController();
|
||||
|
||||
if (categoryEmoji.category == Category.RECENT &&
|
||||
categoryEmoji.emoji.isEmpty) {
|
||||
return _buildNoRecent();
|
||||
} else if (categoryEmoji.category == Category.SEARCH &&
|
||||
categoryEmoji.emoji.isEmpty) {
|
||||
return const Center(child: Text("No Emoji Found"));
|
||||
}
|
||||
// Build page normally
|
||||
return ScrollbarListStack(
|
||||
axis: Axis.vertical,
|
||||
controller: scrollController,
|
||||
barSize: 4.0,
|
||||
scrollbarPadding: const EdgeInsets.symmetric(horizontal: 5.0),
|
||||
handleColor: const Color(0xffDFE0E0),
|
||||
trackColor: const Color(0xffDFE0E0),
|
||||
child: ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: GridView.builder(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.all(0),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: widget.config.columns,
|
||||
mainAxisSpacing: widget.config.verticalSpacing,
|
||||
crossAxisSpacing: widget.config.horizontalSpacing,
|
||||
),
|
||||
itemCount: categoryEmoji.emoji.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = categoryEmoji.emoji[index];
|
||||
return _buildEmoji(emojiSize, categoryEmoji, item);
|
||||
},
|
||||
cacheExtent: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmoji(
|
||||
double emojiSize,
|
||||
CategoryEmoji categoryEmoji,
|
||||
Emoji emoji,
|
||||
) {
|
||||
return _buildButtonWidget(
|
||||
onPressed: () {
|
||||
widget.state.onEmojiSelected(categoryEmoji.category, emoji);
|
||||
},
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
emoji.emoji,
|
||||
textScaleFactor: 1.0,
|
||||
style: TextStyle(
|
||||
fontSize: emojiSize,
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),);
|
||||
}
|
||||
|
||||
Widget _buildNoRecent() {
|
||||
return Center(
|
||||
child: Text(
|
||||
widget.config.noRecentsText,
|
||||
style: widget.config.noRecentsStyle,
|
||||
textAlign: TextAlign.center,
|
||||
),);
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,312 @@
|
||||
// ignore_for_file: constant_identifier_names
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'models/category_models.dart';
|
||||
import 'config.dart';
|
||||
import 'default_emoji_picker_view.dart';
|
||||
import 'models/emoji_model.dart';
|
||||
import 'emoji_lists.dart' as emoji_list;
|
||||
import 'emoji_view_state.dart';
|
||||
import 'models/recent_emoji_model.dart';
|
||||
|
||||
/// All the possible categories that [Emoji] can be put into
|
||||
///
|
||||
/// All [Category] are shown in the category bar
|
||||
enum Category {
|
||||
/// Searched emojis
|
||||
SEARCH,
|
||||
|
||||
/// Recent emojis
|
||||
RECENT,
|
||||
|
||||
/// Smiley emojis
|
||||
SMILEYS,
|
||||
|
||||
/// Animal emojis
|
||||
ANIMALS,
|
||||
|
||||
/// Food emojis
|
||||
FOODS,
|
||||
|
||||
/// Activity emojis
|
||||
ACTIVITIES,
|
||||
|
||||
/// Travel emojis
|
||||
TRAVEL,
|
||||
|
||||
/// Objects emojis
|
||||
OBJECTS,
|
||||
|
||||
/// Sumbol emojis
|
||||
SYMBOLS,
|
||||
|
||||
/// Flag emojis
|
||||
FLAGS,
|
||||
}
|
||||
|
||||
/// Enum to alter the keyboard button style
|
||||
enum ButtonMode {
|
||||
/// Android button style - gives the button a splash color with ripple effect
|
||||
MATERIAL,
|
||||
|
||||
/// iOS button style - gives the button a fade out effect when pressed
|
||||
CUPERTINO
|
||||
}
|
||||
|
||||
/// Callback function for when emoji is selected
|
||||
///
|
||||
/// The function returns the selected [Emoji] as well
|
||||
/// as the [Category] from which it originated
|
||||
typedef OnEmojiSelected = void Function(Category category, Emoji emoji);
|
||||
|
||||
/// Callback function for backspace button
|
||||
typedef OnBackspacePressed = void Function();
|
||||
|
||||
/// Callback function for custom view
|
||||
typedef EmojiViewBuilder = Widget Function(Config config, EmojiViewState state);
|
||||
|
||||
/// The Emoji Keyboard widget
|
||||
///
|
||||
/// This widget displays a grid of [Emoji] sorted by [Category]
|
||||
/// which the user can horizontally scroll through.
|
||||
///
|
||||
/// There is also a bottombar which displays all the possible [Category]
|
||||
/// and allow the user to quickly switch to that [Category]
|
||||
class EmojiPicker extends StatefulWidget {
|
||||
/// EmojiPicker for flutter
|
||||
const EmojiPicker({
|
||||
Key? key,
|
||||
required this.onEmojiSelected,
|
||||
this.onBackspacePressed,
|
||||
this.config = const Config(),
|
||||
this.customWidget,
|
||||
}) : super(key: key);
|
||||
|
||||
/// Custom widget
|
||||
final EmojiViewBuilder? customWidget;
|
||||
|
||||
/// The function called when the emoji is selected
|
||||
final OnEmojiSelected onEmojiSelected;
|
||||
|
||||
/// The function called when backspace button is pressed
|
||||
final OnBackspacePressed? onBackspacePressed;
|
||||
|
||||
/// Config for customizations
|
||||
final Config config;
|
||||
|
||||
@override
|
||||
EmojiPickerState createState() => EmojiPickerState();
|
||||
}
|
||||
|
||||
class EmojiPickerState extends State<EmojiPicker> {
|
||||
static const platform = MethodChannel('emoji_picker_flutter');
|
||||
|
||||
List<CategoryEmoji> categoryEmoji = List.empty(growable: true);
|
||||
List<RecentEmoji> recentEmoji = List.empty(growable: true);
|
||||
late Future<void> updateEmojiFuture;
|
||||
|
||||
// Prevent emojis to be reloaded with every build
|
||||
bool loaded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
updateEmojiFuture = _updateEmojis();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant EmojiPicker oldWidget) {
|
||||
if (oldWidget.config != widget.config) {
|
||||
// Config changed - rebuild EmojiPickerView completely
|
||||
loaded = false;
|
||||
updateEmojiFuture = _updateEmojis();
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!loaded) {
|
||||
// Load emojis
|
||||
updateEmojiFuture.then(
|
||||
(value) => WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
loaded = true;
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Show loading indicator
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (widget.config.showRecentsTab) {
|
||||
categoryEmoji[0].emoji =
|
||||
recentEmoji.map((e) => e.emoji).toList().cast<Emoji>();
|
||||
}
|
||||
|
||||
var state = EmojiViewState(
|
||||
categoryEmoji,
|
||||
_getOnEmojiListener(),
|
||||
widget.onBackspacePressed,
|
||||
);
|
||||
|
||||
// Build
|
||||
return widget.customWidget == null
|
||||
? DefaultEmojiPickerView(widget.config, state)
|
||||
: widget.customWidget!(widget.config, state);
|
||||
}
|
||||
|
||||
// Add recent emoji handling to tap listener
|
||||
OnEmojiSelected _getOnEmojiListener() {
|
||||
return (category, emoji) {
|
||||
if (widget.config.showRecentsTab) {
|
||||
_addEmojiToRecentlyUsed(emoji).then((value) {
|
||||
if (category != Category.RECENT && mounted) {
|
||||
setState(() {
|
||||
// rebuild to update recent emoji tab
|
||||
// when it is not current tab
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
widget.onEmojiSelected(category, emoji);
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize emoji data
|
||||
Future<void> _updateEmojis() async {
|
||||
categoryEmoji.clear();
|
||||
if (widget.config.showRecentsTab) {
|
||||
recentEmoji = await _getRecentEmojis();
|
||||
final List<Emoji> recentEmojiMap =
|
||||
recentEmoji.map((e) => e.emoji).toList().cast<Emoji>();
|
||||
categoryEmoji.add(CategoryEmoji(Category.RECENT, recentEmojiMap));
|
||||
}
|
||||
categoryEmoji.addAll([
|
||||
CategoryEmoji(Category.SMILEYS,
|
||||
await _getAvailableEmojis(emoji_list.smileys, title: 'smileys'),),
|
||||
CategoryEmoji(Category.ANIMALS,
|
||||
await _getAvailableEmojis(emoji_list.animals, title: 'animals'),),
|
||||
CategoryEmoji(Category.FOODS,
|
||||
await _getAvailableEmojis(emoji_list.foods, title: 'foods'),),
|
||||
CategoryEmoji(
|
||||
Category.ACTIVITIES,
|
||||
await _getAvailableEmojis(emoji_list.activities,
|
||||
title: 'activities',),),
|
||||
CategoryEmoji(Category.TRAVEL,
|
||||
await _getAvailableEmojis(emoji_list.travel, title: 'travel'),),
|
||||
CategoryEmoji(Category.OBJECTS,
|
||||
await _getAvailableEmojis(emoji_list.objects, title: 'objects'),),
|
||||
CategoryEmoji(Category.SYMBOLS,
|
||||
await _getAvailableEmojis(emoji_list.symbols, title: 'symbols'),),
|
||||
CategoryEmoji(Category.FLAGS,
|
||||
await _getAvailableEmojis(emoji_list.flags, title: 'flags'),)
|
||||
]);
|
||||
}
|
||||
|
||||
// Get available emoji for given category title
|
||||
Future<List<Emoji>> _getAvailableEmojis(Map<String, String> map,
|
||||
{required String title,}) async {
|
||||
Map<String, String>? newMap;
|
||||
|
||||
// Get Emojis cached locally if available
|
||||
newMap = await _restoreFilteredEmojis(title);
|
||||
|
||||
if (newMap == null) {
|
||||
// Check if emoji is available on this platform
|
||||
newMap = await _getPlatformAvailableEmoji(map);
|
||||
// Save available Emojis to local storage for faster loading next time
|
||||
if (newMap != null) {
|
||||
await _cacheFilteredEmojis(title, newMap);
|
||||
}
|
||||
}
|
||||
|
||||
// Map to Emoji Object
|
||||
return newMap!.entries
|
||||
.map<Emoji>((entry) => Emoji(entry.key, entry.value))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Check if emoji is available on current platform
|
||||
Future<Map<String, String>?> _getPlatformAvailableEmoji(
|
||||
Map<String, String> emoji,) async {
|
||||
if (Platform.isAndroid) {
|
||||
Map<String, String>? filtered = {};
|
||||
var delimiter = '|';
|
||||
try {
|
||||
var entries = emoji.values.join(delimiter);
|
||||
var keys = emoji.keys.join(delimiter);
|
||||
var result = (await platform.invokeMethod<String>('checkAvailability',
|
||||
{'emojiKeys': keys, 'emojiEntries': entries},)) as String;
|
||||
var resultKeys = result.split(delimiter);
|
||||
for (var i = 0; i < resultKeys.length; i++) {
|
||||
filtered[resultKeys[i]] = emoji[resultKeys[i]]!;
|
||||
}
|
||||
} on PlatformException catch (_) {
|
||||
filtered = null;
|
||||
}
|
||||
return filtered;
|
||||
} else {
|
||||
return emoji;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore locally cached emoji
|
||||
Future<Map<String, String>?> _restoreFilteredEmojis(String title) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
var emojiJson = prefs.getString(title);
|
||||
if (emojiJson == null) {
|
||||
return null;
|
||||
}
|
||||
var emojis =
|
||||
Map<String, String>.from(jsonDecode(emojiJson) as Map<String, dynamic>);
|
||||
return emojis;
|
||||
}
|
||||
|
||||
// Stores filtered emoji locally for faster access next time
|
||||
Future<void> _cacheFilteredEmojis(
|
||||
String title, Map<String, String> emojis,) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
var emojiJson = jsonEncode(emojis);
|
||||
prefs.setString(title, emojiJson);
|
||||
}
|
||||
|
||||
// Returns list of recently used emoji from cache
|
||||
Future<List<RecentEmoji>> _getRecentEmojis() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
var emojiJson = prefs.getString('recent');
|
||||
if (emojiJson == null) {
|
||||
return [];
|
||||
}
|
||||
var json = jsonDecode(emojiJson) as List<dynamic>;
|
||||
return json.map<RecentEmoji>(RecentEmoji.fromJson).toList();
|
||||
}
|
||||
|
||||
// Add an emoji to recently used list or increase its counter
|
||||
Future<void> _addEmojiToRecentlyUsed(Emoji emoji) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
var recentEmojiIndex =
|
||||
recentEmoji.indexWhere((element) => element.emoji.emoji == emoji.emoji);
|
||||
if (recentEmojiIndex != -1) {
|
||||
// Already exist in recent list
|
||||
// Just update counter
|
||||
recentEmoji[recentEmojiIndex].counter++;
|
||||
} else {
|
||||
recentEmoji.add(RecentEmoji(emoji, 1));
|
||||
}
|
||||
// Sort by counter desc
|
||||
recentEmoji.sort((a, b) => b.counter - a.counter);
|
||||
// Limit entries to recentsLimit
|
||||
recentEmoji = recentEmoji.sublist(
|
||||
0, min(widget.config.recentsLimit, recentEmoji.length),);
|
||||
// save locally
|
||||
prefs.setString('recent', jsonEncode(recentEmoji));
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'config.dart';
|
||||
import 'emoji_view_state.dart';
|
||||
|
||||
/// Template class for custom implementation
|
||||
/// Inherit this class to create your own EmojiPicker
|
||||
abstract class EmojiPickerBuilder extends StatefulWidget {
|
||||
/// Constructor
|
||||
const EmojiPickerBuilder(this.config, this.state, {Key? key})
|
||||
: super(key: key);
|
||||
|
||||
/// Config for customizations
|
||||
final Config config;
|
||||
|
||||
/// State that holds current emoji data
|
||||
final EmojiViewState state;
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
import 'models/category_models.dart';
|
||||
import 'emoji_picker.dart';
|
||||
|
||||
/// State that holds current emoji data
|
||||
class EmojiViewState {
|
||||
/// Constructor
|
||||
EmojiViewState(
|
||||
this.categoryEmoji,
|
||||
this.onEmojiSelected,
|
||||
this.onBackspacePressed,
|
||||
);
|
||||
|
||||
/// List of all category including their emoji
|
||||
final List<CategoryEmoji> categoryEmoji;
|
||||
|
||||
/// Callback when pressed on emoji
|
||||
final OnEmojiSelected onEmojiSelected;
|
||||
|
||||
/// Callback when pressed on backspace
|
||||
final OnBackspacePressed? onBackspacePressed;
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'emoji_model.dart';
|
||||
import '../emoji_picker.dart';
|
||||
|
||||
/// Container for Category and their emoji
|
||||
class CategoryEmoji {
|
||||
/// Constructor
|
||||
CategoryEmoji(this.category, this.emoji);
|
||||
|
||||
/// Category instance
|
||||
final Category category;
|
||||
|
||||
/// List of emoji of this category
|
||||
List<Emoji> emoji;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Name: $category, Emoji: $emoji';
|
||||
}
|
||||
}
|
||||
|
||||
/// Class that defines the icon representing a [Category]
|
||||
class CategoryIcon {
|
||||
/// Icon of Category
|
||||
const CategoryIcon({
|
||||
required this.icon,
|
||||
this.color = const Color(0xffd3d3d3),
|
||||
this.selectedColor = const Color(0xffb2b2b2),
|
||||
});
|
||||
|
||||
/// The icon to represent the category
|
||||
final IconData icon;
|
||||
|
||||
/// The default color of the icon
|
||||
final Color color;
|
||||
|
||||
/// The color of the icon once the category is selected
|
||||
final Color selectedColor;
|
||||
}
|
||||
|
||||
/// Class used to define all the [CategoryIcon] shown for each [Category]
|
||||
///
|
||||
/// This allows the keyboard to be personalized by changing icons shown.
|
||||
/// If a [CategoryIcon] is set as null or not defined during initialization,
|
||||
/// the default icons will be used instead
|
||||
class CategoryIcons {
|
||||
/// Constructor
|
||||
const CategoryIcons({
|
||||
this.recentIcon = Icons.access_time,
|
||||
this.smileyIcon = Icons.tag_faces,
|
||||
this.animalIcon = Icons.pets,
|
||||
this.foodIcon = Icons.fastfood,
|
||||
this.activityIcon = Icons.directions_run,
|
||||
this.travelIcon = Icons.location_city,
|
||||
this.objectIcon = Icons.lightbulb_outline,
|
||||
this.symbolIcon = Icons.emoji_symbols,
|
||||
this.flagIcon = Icons.flag,
|
||||
this.searchIcon = Icons.search,
|
||||
});
|
||||
|
||||
/// Icon for [Category.RECENT]
|
||||
final IconData recentIcon;
|
||||
|
||||
/// Icon for [Category.SMILEYS]
|
||||
final IconData smileyIcon;
|
||||
|
||||
/// Icon for [Category.ANIMALS]
|
||||
final IconData animalIcon;
|
||||
|
||||
/// Icon for [Category.FOODS]
|
||||
final IconData foodIcon;
|
||||
|
||||
/// Icon for [Category.ACTIVITIES]
|
||||
final IconData activityIcon;
|
||||
|
||||
/// Icon for [Category.TRAVEL]
|
||||
final IconData travelIcon;
|
||||
|
||||
/// Icon for [Category.OBJECTS]
|
||||
final IconData objectIcon;
|
||||
|
||||
/// Icon for [Category.SYMBOLS]
|
||||
final IconData symbolIcon;
|
||||
|
||||
/// Icon for [Category.FLAGS]
|
||||
final IconData flagIcon;
|
||||
|
||||
/// Icon for [Category.SEARCH]
|
||||
final IconData searchIcon;
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
/// A class to store data for each individual emoji
|
||||
class Emoji {
|
||||
/// Emoji constructor
|
||||
const Emoji(this.name, this.emoji);
|
||||
|
||||
/// The name or description for this emoji
|
||||
final String name;
|
||||
|
||||
/// The unicode string for this emoji
|
||||
///
|
||||
/// This is the string that should be displayed to view the emoji
|
||||
final String emoji;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
// return 'Name: $name, Emoji: $emoji';
|
||||
return name;
|
||||
}
|
||||
|
||||
/// Parse Emoji from json
|
||||
static Emoji fromJson(Map<String, dynamic> json) {
|
||||
return Emoji(json['name'] as String, json['emoji'] as String);
|
||||
}
|
||||
|
||||
/// Encode Emoji to json
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
'emoji': emoji,
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
import 'emoji_model.dart';
|
||||
|
||||
/// Class that holds an recent emoji
|
||||
/// Recent Emoji has an instance of the emoji
|
||||
/// And a counter, which counts how often this emoji
|
||||
/// has been used before
|
||||
class RecentEmoji {
|
||||
/// Constructor
|
||||
RecentEmoji(this.emoji, this.counter);
|
||||
|
||||
/// Emoji instance
|
||||
final Emoji emoji;
|
||||
|
||||
/// Counter how often emoji has been used before
|
||||
int counter = 0;
|
||||
|
||||
/// Parse RecentEmoji from json
|
||||
static RecentEmoji fromJson(dynamic json) {
|
||||
return RecentEmoji(
|
||||
Emoji.fromJson(json['emoji'] as Map<String, dynamic>),
|
||||
json['counter'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
/// Encode RecentEmoji to json
|
||||
Map<String, dynamic> toJson() => {
|
||||
'emoji': emoji,
|
||||
'counter': counter,
|
||||
};
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension FlowyTintExtension on FlowyTint {
|
||||
String tintName(
|
||||
AppFlowyEditorLocalizations l10n, {
|
||||
ThemeMode? themeMode,
|
||||
String? theme,
|
||||
}) {
|
||||
switch (this) {
|
||||
case FlowyTint.tint1:
|
||||
return l10n.lightLightTint1;
|
||||
case FlowyTint.tint2:
|
||||
return l10n.lightLightTint2;
|
||||
case FlowyTint.tint3:
|
||||
return l10n.lightLightTint3;
|
||||
case FlowyTint.tint4:
|
||||
return l10n.lightLightTint4;
|
||||
case FlowyTint.tint5:
|
||||
return l10n.lightLightTint5;
|
||||
case FlowyTint.tint6:
|
||||
return l10n.lightLightTint6;
|
||||
case FlowyTint.tint7:
|
||||
return l10n.lightLightTint7;
|
||||
case FlowyTint.tint8:
|
||||
return l10n.lightLightTint8;
|
||||
case FlowyTint.tint9:
|
||||
return l10n.lightLightTint9;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,168 +0,0 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
ShortcutEvent insertHorizontalRule = ShortcutEvent(
|
||||
key: 'Horizontal rule',
|
||||
command: 'Minus',
|
||||
handler: _insertHorzaontalRule,
|
||||
);
|
||||
|
||||
ShortcutEventHandler _insertHorzaontalRule = (editorState, event) {
|
||||
final selection = editorState.service.selectionService.currentSelection.value;
|
||||
final textNodes = editorState.service.selectionService.currentSelectedNodes
|
||||
.whereType<TextNode>();
|
||||
if (textNodes.length != 1 || selection == null) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
final textNode = textNodes.first;
|
||||
if (textNode.toPlainText() == '--') {
|
||||
final transaction = editorState.transaction
|
||||
..deleteText(textNode, 0, 2)
|
||||
..insertNode(
|
||||
textNode.path,
|
||||
Node(
|
||||
type: 'horizontal_rule',
|
||||
children: LinkedList(),
|
||||
attributes: {},
|
||||
),
|
||||
)
|
||||
..afterSelection =
|
||||
Selection.single(path: textNode.path.next, startOffset: 0);
|
||||
editorState.apply(transaction);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
};
|
||||
|
||||
SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem(
|
||||
name: 'Horizontal rule',
|
||||
icon: (editorState, onSelected) => Icon(
|
||||
Icons.horizontal_rule,
|
||||
color: onSelected
|
||||
? editorState.editorStyle.selectionMenuItemSelectedIconColor
|
||||
: editorState.editorStyle.selectionMenuItemIconColor,
|
||||
size: 18.0,
|
||||
),
|
||||
keywords: ['horizontal rule'],
|
||||
handler: (editorState, _, __) {
|
||||
final selection =
|
||||
editorState.service.selectionService.currentSelection.value;
|
||||
final textNodes = editorState.service.selectionService.currentSelectedNodes
|
||||
.whereType<TextNode>();
|
||||
if (selection == null || textNodes.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final textNode = textNodes.first;
|
||||
if (textNode.toPlainText().isEmpty) {
|
||||
final transaction = editorState.transaction
|
||||
..insertNode(
|
||||
textNode.path,
|
||||
Node(
|
||||
type: 'horizontal_rule',
|
||||
children: LinkedList(),
|
||||
attributes: {},
|
||||
),
|
||||
)
|
||||
..afterSelection =
|
||||
Selection.single(path: textNode.path.next, startOffset: 0);
|
||||
editorState.apply(transaction);
|
||||
} else {
|
||||
final transaction = editorState.transaction
|
||||
..insertNode(
|
||||
selection.end.path.next,
|
||||
TextNode(
|
||||
children: LinkedList(),
|
||||
attributes: {
|
||||
'subtype': 'horizontal_rule',
|
||||
},
|
||||
delta: Delta()..insert('---'),
|
||||
),
|
||||
)
|
||||
..afterSelection = selection;
|
||||
editorState.apply(transaction);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
class HorizontalRuleWidgetBuilder extends NodeWidgetBuilder<Node> {
|
||||
@override
|
||||
Widget build(NodeWidgetContext<Node> context) {
|
||||
return _HorizontalRuleWidget(
|
||||
key: context.node.key,
|
||||
node: context.node,
|
||||
editorState: context.editorState,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
NodeValidator<Node> get nodeValidator => (node) {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
class _HorizontalRuleWidget extends StatefulWidget {
|
||||
const _HorizontalRuleWidget({
|
||||
Key? key,
|
||||
required this.node,
|
||||
required this.editorState,
|
||||
}) : super(key: key);
|
||||
|
||||
final Node node;
|
||||
final EditorState editorState;
|
||||
|
||||
@override
|
||||
State<_HorizontalRuleWidget> createState() => __HorizontalRuleWidgetState();
|
||||
}
|
||||
|
||||
class __HorizontalRuleWidgetState extends State<_HorizontalRuleWidget>
|
||||
with SelectableMixin {
|
||||
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: Colors.grey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Position start() => Position(path: widget.node.path, offset: 0);
|
||||
|
||||
@override
|
||||
Position end() => Position(path: widget.node.path, offset: 1);
|
||||
|
||||
@override
|
||||
Position getPositionInOffset(Offset start) => end();
|
||||
|
||||
@override
|
||||
bool get shouldCursorBlink => false;
|
||||
|
||||
@override
|
||||
CursorStyle get cursorStyle => CursorStyle.borderLine;
|
||||
|
||||
@override
|
||||
Rect? getCursorRectInPosition(Position position) {
|
||||
final size = _renderBox.size;
|
||||
return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Rect> getRectsInSelection(Selection selection) =>
|
||||
[Offset.zero & _renderBox.size];
|
||||
|
||||
@override
|
||||
Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
|
||||
path: widget.node.path,
|
||||
startOffset: 0,
|
||||
endOffset: 1,
|
||||
);
|
||||
|
||||
@override
|
||||
Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset);
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
|
||||
class Svg extends StatelessWidget {
|
||||
const Svg({
|
||||
Key? key,
|
||||
this.name,
|
||||
this.width,
|
||||
this.height,
|
||||
this.color,
|
||||
this.number,
|
||||
this.padding,
|
||||
}) : super(key: key);
|
||||
|
||||
final String? name;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final Color? color;
|
||||
final int? number;
|
||||
final EdgeInsets? padding;
|
||||
|
||||
final _defaultWidth = 20.0;
|
||||
final _defaultHeight = 20.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: padding ?? const EdgeInsets.all(0),
|
||||
child: _buildSvg(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSvg() {
|
||||
if (name != null) {
|
||||
return SvgPicture.asset(
|
||||
'assets/images/$name.svg',
|
||||
colorFilter:
|
||||
color != null ? ColorFilter.mode(color!, BlendMode.srcIn) : null,
|
||||
fit: BoxFit.fill,
|
||||
height: height,
|
||||
width: width,
|
||||
package: 'appflowy_editor_plugins',
|
||||
);
|
||||
} else if (number != null) {
|
||||
final numberText =
|
||||
'<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg"><text x="30" y="150" fill="black" font-size="160">$number.</text></svg>';
|
||||
return SvgPicture.string(
|
||||
numberText,
|
||||
width: width ?? _defaultWidth,
|
||||
height: height ?? _defaultHeight,
|
||||
);
|
||||
}
|
||||
return Container();
|
||||
}
|
||||
}
|
@ -0,0 +1,224 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
|
||||
import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_math_fork/flutter_math.dart';
|
||||
|
||||
const String kMathEquationType = 'math_equation';
|
||||
const String kMathEquationAttr = 'math_equation';
|
||||
|
||||
SelectionMenuItem mathEquationMenuItem = SelectionMenuItem(
|
||||
name: 'Math Equation',
|
||||
icon: (editorState, onSelected) => Icon(
|
||||
Icons.text_fields_rounded,
|
||||
color: onSelected
|
||||
? editorState.editorStyle.selectionMenuItemSelectedIconColor
|
||||
: editorState.editorStyle.selectionMenuItemIconColor,
|
||||
size: 18.0,
|
||||
),
|
||||
keywords: ['tex, latex, katex', 'math equation'],
|
||||
handler: (editorState, _, __) {
|
||||
final selection =
|
||||
editorState.service.selectionService.currentSelection.value;
|
||||
final textNodes = editorState.service.selectionService.currentSelectedNodes
|
||||
.whereType<TextNode>();
|
||||
if (selection == null || textNodes.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final textNode = textNodes.first;
|
||||
final Path mathEquationNodePath;
|
||||
if (textNode.toPlainText().isEmpty) {
|
||||
mathEquationNodePath = selection.end.path;
|
||||
} else {
|
||||
mathEquationNodePath = selection.end.path.next;
|
||||
}
|
||||
// insert the math equation node
|
||||
final transaction = editorState.transaction
|
||||
..insertNode(
|
||||
mathEquationNodePath,
|
||||
Node(type: kMathEquationType, attributes: {kMathEquationAttr: ''}),
|
||||
)
|
||||
..afterSelection = selection;
|
||||
editorState.apply(transaction);
|
||||
|
||||
// tricy to show the editing dialog.
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
final mathEquationState = editorState.document
|
||||
.nodeAtPath(mathEquationNodePath)
|
||||
?.key
|
||||
.currentState;
|
||||
if (mathEquationState != null &&
|
||||
mathEquationState is _MathEquationNodeWidgetState) {
|
||||
mathEquationState.showEditingDialog();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
class MathEquationNodeWidgetBuidler extends NodeWidgetBuilder<Node>
|
||||
with ActionProvider<Node> {
|
||||
@override
|
||||
Widget build(NodeWidgetContext<Node> context) {
|
||||
return _MathEquationNodeWidget(
|
||||
key: context.node.key,
|
||||
node: context.node,
|
||||
editorState: context.editorState,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
NodeValidator<Node> get nodeValidator =>
|
||||
(node) => node.attributes[kMathEquationAttr] is String;
|
||||
|
||||
@override
|
||||
List<ActionMenuItem> actions(NodeWidgetContext<Node> context) {
|
||||
return [
|
||||
ActionMenuItem.svg(
|
||||
name: "delete",
|
||||
onPressed: () {
|
||||
final transaction = context.editorState.transaction
|
||||
..deleteNode(context.node);
|
||||
context.editorState.apply(transaction);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class _MathEquationNodeWidget extends StatefulWidget {
|
||||
const _MathEquationNodeWidget({
|
||||
Key? key,
|
||||
required this.node,
|
||||
required this.editorState,
|
||||
}) : super(key: key);
|
||||
|
||||
final Node node;
|
||||
final EditorState editorState;
|
||||
|
||||
@override
|
||||
State<_MathEquationNodeWidget> createState() =>
|
||||
_MathEquationNodeWidgetState();
|
||||
}
|
||||
|
||||
class _MathEquationNodeWidgetState extends State<_MathEquationNodeWidget> {
|
||||
String get _mathEquation =>
|
||||
widget.node.attributes[kMathEquationAttr] as String;
|
||||
bool _isHover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onHover: (value) {
|
||||
setState(() {
|
||||
_isHover = value;
|
||||
});
|
||||
},
|
||||
onTap: () {
|
||||
showEditingDialog();
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildMathEquation(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMathEquation(BuildContext context) {
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
constraints: const BoxConstraints(minHeight: 50),
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||
color: _isHover || _mathEquation.isEmpty
|
||||
? Theme.of(context).colorScheme.tertiaryContainer
|
||||
: Colors.transparent,
|
||||
),
|
||||
child: Center(
|
||||
child: _mathEquation.isEmpty
|
||||
? FlowyText.medium(
|
||||
LocaleKeys.document_plugins_mathEquation_addMathEquation.tr(),
|
||||
fontSize: 16,
|
||||
)
|
||||
: Math.tex(
|
||||
_mathEquation,
|
||||
textStyle: const TextStyle(fontSize: 20),
|
||||
mathStyle: MathStyle.display,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showEditingDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final controller = TextEditingController(text: _mathEquation);
|
||||
return AlertDialog(
|
||||
backgroundColor: Theme.of(context).canvasColor,
|
||||
title: Text(
|
||||
LocaleKeys.document_plugins_mathEquation_editMathEquation.tr(),
|
||||
),
|
||||
content: RawKeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
onKey: (key) {
|
||||
if (key is! RawKeyDownEvent) return;
|
||||
if (key.logicalKey == LogicalKeyboardKey.enter &&
|
||||
!key.isShiftPressed) {
|
||||
_updateMathEquation(controller.text, context);
|
||||
} else if (key.logicalKey == LogicalKeyboardKey.escape) {
|
||||
_dismiss(context);
|
||||
}
|
||||
},
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
controller: controller,
|
||||
maxLines: null,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'E = MC^2',
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
SecondaryTextButton(
|
||||
LocaleKeys.button_Cancel.tr(),
|
||||
onPressed: () => _dismiss(context),
|
||||
),
|
||||
PrimaryTextButton(
|
||||
LocaleKeys.button_Done.tr(),
|
||||
onPressed: () => _updateMathEquation(controller.text, context),
|
||||
),
|
||||
],
|
||||
actionsPadding: const EdgeInsets.only(bottom: 20),
|
||||
actionsAlignment: MainAxisAlignment.spaceAround,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _updateMathEquation(String mathEquation, BuildContext context) {
|
||||
if (mathEquation == _mathEquation) {
|
||||
_dismiss(context);
|
||||
return;
|
||||
}
|
||||
final transaction = widget.editorState.transaction;
|
||||
transaction.updateNode(
|
||||
widget.node,
|
||||
{
|
||||
kMathEquationAttr: mathEquation,
|
||||
},
|
||||
);
|
||||
widget.editorState.apply(transaction);
|
||||
_dismiss(context);
|
||||
}
|
||||
|
||||
void _dismiss(BuildContext context) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
@ -50,6 +50,7 @@ abstract class OpenAIRepository {
|
||||
String? suffix,
|
||||
int maxTokens = 2048,
|
||||
double temperature = 0.3,
|
||||
bool useAction = false,
|
||||
});
|
||||
|
||||
/// Get edits from GPT-3
|
||||
|
@ -5,7 +5,8 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
enum SmartEditAction {
|
||||
summarize,
|
||||
fixSpelling;
|
||||
fixSpelling,
|
||||
improveWriting;
|
||||
|
||||
String get toInstruction {
|
||||
switch (this) {
|
||||
@ -13,6 +14,8 @@ enum SmartEditAction {
|
||||
return 'Tl;dr';
|
||||
case SmartEditAction.fixSpelling:
|
||||
return 'Correct this to standard English:';
|
||||
case SmartEditAction.improveWriting:
|
||||
return 'Rewrite this in your own words:';
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +25,8 @@ enum SmartEditAction {
|
||||
return '$input\n\nTl;dr';
|
||||
case SmartEditAction.fixSpelling:
|
||||
return 'Correct this to standard English:\n\n$input';
|
||||
case SmartEditAction.improveWriting:
|
||||
return 'Rewrite this:\n\n$input';
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,6 +36,8 @@ enum SmartEditAction {
|
||||
return SmartEditAction.summarize;
|
||||
case 1:
|
||||
return SmartEditAction.fixSpelling;
|
||||
case 2:
|
||||
return SmartEditAction.improveWriting;
|
||||
}
|
||||
return SmartEditAction.fixSpelling;
|
||||
}
|
||||
@ -41,6 +48,8 @@ enum SmartEditAction {
|
||||
return LocaleKeys.document_plugins_smartEditSummarize.tr();
|
||||
case SmartEditAction.fixSpelling:
|
||||
return LocaleKeys.document_plugins_smartEditFixSpelling.tr();
|
||||
case SmartEditAction.improveWriting:
|
||||
return LocaleKeys.document_plugins_smartEditImproveWriting.tr();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import 'package:appflowy/plugins/document/presentation/plugins/openai/service/op
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/openai/util/learn_more_action.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/discard_dialog.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
@ -242,7 +242,7 @@ class _SmartEditInputState extends State<_SmartEditInput> {
|
||||
),
|
||||
onPressed: () async {
|
||||
await _onReplace();
|
||||
_onExit();
|
||||
await _onExit();
|
||||
},
|
||||
),
|
||||
const Space(10, 0),
|
||||
@ -257,7 +257,7 @@ class _SmartEditInputState extends State<_SmartEditInput> {
|
||||
),
|
||||
onPressed: () async {
|
||||
await _onInsertBelow();
|
||||
_onExit();
|
||||
await _onExit();
|
||||
},
|
||||
),
|
||||
const Space(10, 0),
|
||||
@ -272,10 +272,13 @@ class _SmartEditInputState extends State<_SmartEditInput> {
|
||||
),
|
||||
onPressed: () async => await _onExit(),
|
||||
),
|
||||
const Spacer(),
|
||||
FlowyText.regular(
|
||||
LocaleKeys.document_plugins_warning.tr(),
|
||||
color: Theme.of(context).hintColor,
|
||||
const Spacer(flex: 2),
|
||||
Expanded(
|
||||
child: FlowyText.regular(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
LocaleKeys.document_plugins_warning.tr(),
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@ -298,7 +301,22 @@ class _SmartEditInputState extends State<_SmartEditInput> {
|
||||
selection,
|
||||
texts,
|
||||
);
|
||||
return widget.editorState.apply(transaction);
|
||||
await widget.editorState.apply(transaction);
|
||||
|
||||
int endOffset = texts.last.length;
|
||||
if (texts.length == 1) {
|
||||
endOffset += selection.start.offset;
|
||||
}
|
||||
|
||||
await widget.editorState.updateCursorSelection(
|
||||
Selection(
|
||||
start: selection.start,
|
||||
end: Position(
|
||||
path: [selection.start.path.first + texts.length - 1],
|
||||
offset: endOffset,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onInsertBelow() async {
|
||||
@ -317,7 +335,16 @@ class _SmartEditInputState extends State<_SmartEditInput> {
|
||||
),
|
||||
),
|
||||
);
|
||||
return widget.editorState.apply(transaction);
|
||||
await widget.editorState.apply(transaction);
|
||||
|
||||
await widget.editorState.updateCursorSelection(
|
||||
Selection(
|
||||
start: Position(path: selection.end.path.next, offset: 0),
|
||||
end: Position(
|
||||
path: [selection.end.path.next.first + texts.length],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onExit() async {
|
||||
@ -333,49 +360,42 @@ class _SmartEditInputState extends State<_SmartEditInput> {
|
||||
}
|
||||
|
||||
Future<void> _requestCompletions() async {
|
||||
final result = await UserBackendService.getCurrentUserProfile();
|
||||
return result.fold((l) async {
|
||||
final openAIRepository = HttpOpenAIRepository(
|
||||
client: client,
|
||||
apiKey: l.openaiKey,
|
||||
);
|
||||
final openAIRepository = await getIt.getAsync<OpenAIRepository>();
|
||||
|
||||
var lines = input.split('\n\n');
|
||||
if (action == SmartEditAction.summarize) {
|
||||
lines = [lines.join('\n')];
|
||||
}
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
final element = lines[i];
|
||||
await openAIRepository.getStreamedCompletions(
|
||||
useAction: true,
|
||||
prompt: action.prompt(element),
|
||||
onStart: () async {
|
||||
setState(() {
|
||||
loading = false;
|
||||
});
|
||||
},
|
||||
onProcess: (response) async {
|
||||
setState(() {
|
||||
this.result += response.choices.first.text;
|
||||
});
|
||||
},
|
||||
onEnd: () async {
|
||||
setState(() {
|
||||
if (i != lines.length - 1) {
|
||||
this.result += '\n';
|
||||
}
|
||||
});
|
||||
},
|
||||
onError: (error) async {
|
||||
await _showError(error.message);
|
||||
await _onExit();
|
||||
},
|
||||
);
|
||||
}
|
||||
}, (r) async {
|
||||
await _showError(r.msg);
|
||||
await _onExit();
|
||||
});
|
||||
var lines = input.split('\n\n');
|
||||
if (action == SmartEditAction.summarize) {
|
||||
lines = [lines.join('\n')];
|
||||
}
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
final element = lines[i];
|
||||
await openAIRepository.getStreamedCompletions(
|
||||
useAction: true,
|
||||
prompt: action.prompt(element),
|
||||
onStart: () async {
|
||||
setState(() {
|
||||
loading = false;
|
||||
});
|
||||
},
|
||||
onProcess: (response) async {
|
||||
setState(() {
|
||||
if (response.choices.first.text != '\n') {
|
||||
result += response.choices.first.text;
|
||||
}
|
||||
});
|
||||
},
|
||||
onEnd: () async {
|
||||
setState(() {
|
||||
if (i != lines.length - 1) {
|
||||
result += '\n';
|
||||
}
|
||||
});
|
||||
},
|
||||
onError: (error) async {
|
||||
await _showError(error.message);
|
||||
await _onExit();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showError(String message) async {
|
||||
|
@ -0,0 +1,21 @@
|
||||
export 'board/board_node_widget.dart';
|
||||
export 'board/board_menu_item.dart';
|
||||
export 'board/board_view_menu_item.dart';
|
||||
export 'callout/callout_node_widget.dart';
|
||||
export 'code_block/code_block_node_widget.dart';
|
||||
export 'code_block/code_block_shortcut_event.dart';
|
||||
export 'cover/change_cover_popover_bloc.dart';
|
||||
export 'cover/cover_node_widget.dart';
|
||||
export 'cover/cover_image_picker.dart';
|
||||
export 'divider/divider_node_widget.dart';
|
||||
export 'divider/divider_shortcut_event.dart';
|
||||
export 'emoji_picker/emoji_menu_item.dart';
|
||||
export 'extensions/flowy_tint_extension.dart';
|
||||
export 'grid/grid_menu_item.dart';
|
||||
export 'grid/grid_node_widget.dart';
|
||||
export 'grid/grid_view_menu_item.dart';
|
||||
export 'math_equation/math_equation_node_widget.dart';
|
||||
export 'openai/widgets/auto_completion_node_widget.dart';
|
||||
export 'openai/widgets/auto_completion_plugins.dart';
|
||||
export 'openai/widgets/smart_edit_node_widget.dart';
|
||||
export 'openai/widgets/smart_edit_toolbar_item.dart';
|
@ -4,10 +4,14 @@ class TrashSizes {
|
||||
static double get fileNameWidth => 320 * scale;
|
||||
static double get lashModifyWidth => 230 * scale;
|
||||
static double get createTimeWidth => 230 * scale;
|
||||
static double get padding => 100 * scale;
|
||||
// padding between createTime and action icon
|
||||
static double get padding => 40 * scale;
|
||||
static double get actionIconWidth => 40 * scale;
|
||||
static double get totalWidth =>
|
||||
TrashSizes.fileNameWidth +
|
||||
TrashSizes.lashModifyWidth +
|
||||
TrashSizes.createTimeWidth +
|
||||
TrashSizes.padding;
|
||||
TrashSizes.padding +
|
||||
// restore and delete icon
|
||||
2 * TrashSizes.actionIconWidth;
|
||||
}
|
||||
|
@ -38,23 +38,19 @@ class TrashCell extends StatelessWidget {
|
||||
),
|
||||
const Spacer(),
|
||||
FlowyIconButton(
|
||||
width: 26,
|
||||
iconColorOnHover: Theme.of(context).colorScheme.onSurface,
|
||||
width: TrashSizes.actionIconWidth,
|
||||
onPressed: onRestore,
|
||||
iconPadding: const EdgeInsets.all(5),
|
||||
icon: svgWidget(
|
||||
"editor/restore",
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
icon: const FlowySvg(name: 'editor/restore'),
|
||||
),
|
||||
const HSpace(20),
|
||||
FlowyIconButton(
|
||||
width: 26,
|
||||
iconColorOnHover: Theme.of(context).colorScheme.onSurface,
|
||||
width: TrashSizes.actionIconWidth,
|
||||
onPressed: onDelete,
|
||||
iconPadding: const EdgeInsets.all(5),
|
||||
icon: svgWidget(
|
||||
"editor/delete",
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
icon: const FlowySvg(name: 'editor/delete'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -96,10 +96,7 @@ class _TrashPageState extends State<TrashPage> {
|
||||
IntrinsicWidth(
|
||||
child: FlowyButton(
|
||||
text: FlowyText.medium(LocaleKeys.trash_restoreAll.tr()),
|
||||
leftIcon: svgWidget(
|
||||
'editor/restore',
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
leftIcon: const FlowySvg(name: 'editor/restore'),
|
||||
onTap: () => context.read<TrashBloc>().add(
|
||||
const TrashEvent.restoreAll(),
|
||||
),
|
||||
@ -109,10 +106,7 @@ class _TrashPageState extends State<TrashPage> {
|
||||
IntrinsicWidth(
|
||||
child: FlowyButton(
|
||||
text: FlowyText.medium(LocaleKeys.trash_deleteAll.tr()),
|
||||
leftIcon: svgWidget(
|
||||
'editor/delete',
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
leftIcon: const FlowySvg(name: 'editor/delete'),
|
||||
onTap: () =>
|
||||
context.read<TrashBloc>().add(const TrashEvent.deleteAll()),
|
||||
),
|
||||
|
Reference in New Issue
Block a user