Browse Source

contacts view (with bugs)

ignalxy 4 years ago
parent
commit
d27db7116f

BIN
images/avatar.png


BIN
images/ic_car_models_header1.png


BIN
images/ic_car_models_header2.png


BIN
images/ic_favorite.png


BIN
images/ic_index_bar_bubble_gray.png


BIN
images/ic_index_bar_bubble_white.png


BIN
imgs/avatar.jpg


+ 6 - 0
lib/azlistview/azlistview.dart

@@ -0,0 +1,6 @@
+library azlistview;
+
+export 'src/az_common.dart';
+export 'src/az_listview.dart';
+export 'src/index_bar.dart';
+export 'src/suspension_view.dart';

+ 57 - 0
lib/azlistview/src/az_common.dart

@@ -0,0 +1,57 @@
+/// ISuspension Bean.
+abstract class ISuspensionBean {
+  bool isShowSuspension = false;
+
+  String getSuspensionTag(); //Suspension Tag
+}
+
+/// Suspension Util.
+class SuspensionUtil {
+  /// sort list by suspension tag.
+  /// 根据[A-Z]排序。
+  static void sortListBySuspensionTag(List<ISuspensionBean>? list) {
+    if (list == null || list.isEmpty) return;
+    list.sort((a, b) {
+      if (a.getSuspensionTag() == "@" || b.getSuspensionTag() == "#") {
+        return -1;
+      } else if (a.getSuspensionTag() == "#" || b.getSuspensionTag() == "@") {
+        return 1;
+      } else {
+        return a.getSuspensionTag().compareTo(b.getSuspensionTag());
+      }
+    });
+  }
+
+  /// get index data list by suspension tag.
+  /// 获取索引列表。
+  static List<String> getTagIndexList(List<ISuspensionBean>? list) {
+    List<String> indexData = [];
+    if (list != null && list.isNotEmpty) {
+      String? tempTag;
+      for (int i = 0, length = list.length; i < length; i++) {
+        String tag = list[i].getSuspensionTag();
+        if (tempTag != tag) {
+          indexData.add(tag);
+          tempTag = tag;
+        }
+      }
+    }
+    return indexData;
+  }
+
+  /// set show suspension status.
+  /// 设置显示悬停Header状态。
+  static void setShowSuspensionStatus(List<ISuspensionBean>? list) {
+    if (list == null || list.isEmpty) return;
+    String? tempTag;
+    for (int i = 0, length = list.length; i < length; i++) {
+      String tag = list[i].getSuspensionTag();
+      if (tempTag != tag) {
+        tempTag = tag;
+        list[i].isShowSuspension = true;
+      } else {
+        list[i].isShowSuspension = false;
+      }
+    }
+  }
+}

+ 210 - 0
lib/azlistview/src/az_listview.dart

@@ -0,0 +1,210 @@
+import 'package:flutter/material.dart';
+import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
+
+import 'az_common.dart';
+import 'index_bar.dart';
+import 'suspension_view.dart';
+
+/// AzListView
+class AzListView extends StatefulWidget {
+  AzListView({
+    Key? key,
+    required this.data,
+    required this.itemCount,
+    required this.itemBuilder,
+    this.itemScrollController,
+    this.itemPositionsListener,
+    this.physics,
+    this.padding,
+    this.susItemBuilder,
+    this.susItemHeight = kSusItemHeight,
+    this.susPosition,
+    this.indexHintBuilder,
+    this.indexBarData = kIndexBarData,
+    this.indexBarWidth = kIndexBarWidth,
+    this.indexBarHeight,
+    this.indexBarItemHeight = kIndexBarItemHeight,
+    this.indexBarAlignment = Alignment.centerRight,
+    this.indexBarMargin,
+    this.indexBarOptions = const IndexBarOptions(),
+  }) : super(key: key);
+
+  /// with  ISuspensionBean Data
+  final List<ISuspensionBean> data;
+
+  /// Number of items the [itemBuilder] can produce.
+  final int itemCount;
+
+  /// Called to build children for the list with
+  /// 0 <= index < itemCount.
+  final IndexedWidgetBuilder itemBuilder;
+
+  /// Controller for jumping or scrolling to an item.
+  final ItemScrollController? itemScrollController;
+
+  /// Notifier that reports the items laid out in the list after each frame.
+  final ItemPositionsListener? itemPositionsListener;
+
+  /// How the scroll view should respond to user input.
+  ///
+  /// For example, determines how the scroll view continues to animate after the
+  /// user stops dragging the scroll view.
+  ///
+  /// See [ScrollView.physics].
+  final ScrollPhysics? physics;
+
+  /// The amount of space by which to inset the children.
+  final EdgeInsets? padding;
+
+  /// Called to build suspension header.
+  final IndexedWidgetBuilder? susItemBuilder;
+
+  /// Suspension widget Height.
+  final double susItemHeight;
+
+  /// Suspension item position.
+  final Offset? susPosition;
+
+  /// IndexHintBuilder.
+  final IndexHintBuilder? indexHintBuilder;
+
+  /// Index data.
+  final List<String> indexBarData;
+
+  /// IndexBar Width.
+  final double indexBarWidth;
+
+  /// IndexBar Height.
+  final double? indexBarHeight;
+
+  /// IndexBar Item Height.
+  final double indexBarItemHeight;
+
+  /// IndexBar alignment.
+  final AlignmentGeometry indexBarAlignment;
+
+  /// IndexBar margin.
+  final EdgeInsetsGeometry? indexBarMargin;
+
+  /// IndexBar options.
+  final IndexBarOptions indexBarOptions;
+
+  @override
+  _AzListViewState createState() => _AzListViewState();
+}
+
+class _AzListViewState extends State<AzListView> {
+  /// Controller to scroll or jump to a particular item.
+  late ItemScrollController itemScrollController;
+
+  /// Listener that reports the position of items when the list is scrolled.
+  late ItemPositionsListener itemPositionsListener;
+
+  IndexBarDragListener dragListener = IndexBarDragListener.create();
+
+  final IndexBarController indexBarController = IndexBarController();
+
+  String selectTag = '';
+
+  @override
+  void initState() {
+    super.initState();
+    itemScrollController =
+        widget.itemScrollController ?? ItemScrollController();
+    itemPositionsListener =
+        widget.itemPositionsListener ?? ItemPositionsListener.create();
+    dragListener.dragDetails.addListener(_valueChanged);
+    if (widget.indexBarOptions.selectItemDecoration != null) {
+      itemPositionsListener.itemPositions.addListener(_positionsChanged);
+    }
+  }
+
+  @override
+  void dispose() {
+    super.dispose();
+    dragListener.dragDetails.removeListener(_valueChanged);
+    if (widget.indexBarOptions.selectItemDecoration != null) {
+      itemPositionsListener.itemPositions.removeListener(_positionsChanged);
+    }
+  }
+
+  int _getIndex(String tag) {
+    for (int i = 0; i < widget.itemCount; i++) {
+      ISuspensionBean bean = widget.data[i];
+      if (tag == bean.getSuspensionTag()) {
+        return i;
+      }
+    }
+    return -1;
+  }
+
+  void _scrollTopIndex(String tag) {
+    int index = _getIndex(tag);
+    if (index != -1) {
+      itemScrollController.jumpTo(index: index);
+    }
+  }
+
+  void _valueChanged() {
+    IndexBarDragDetails details = dragListener.dragDetails.value;
+    String tag = details.tag!;
+    if (details.action == IndexBarDragDetails.actionDown ||
+        details.action == IndexBarDragDetails.actionUpdate) {
+      selectTag = tag;
+      _scrollTopIndex(tag);
+    }
+  }
+
+  void _positionsChanged() {
+    Iterable<ItemPosition> positions =
+        itemPositionsListener.itemPositions.value;
+    if (positions.isNotEmpty) {
+      ItemPosition itemPosition = positions
+          .where((ItemPosition position) => position.itemTrailingEdge > 0)
+          .reduce((ItemPosition min, ItemPosition position) =>
+              position.itemTrailingEdge < min.itemTrailingEdge
+                  ? position
+                  : min);
+      int index = itemPosition.index;
+      String tag = widget.data[index].getSuspensionTag();
+      if (selectTag != tag) {
+        selectTag = tag;
+        indexBarController.updateTagIndex(tag);
+      }
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Stack(
+      children: [
+        SuspensionView(
+          data: widget.data,
+          itemCount: widget.itemCount,
+          itemBuilder: widget.itemBuilder,
+          itemScrollController: itemScrollController,
+          itemPositionsListener: itemPositionsListener,
+          susItemBuilder: widget.susItemBuilder,
+          susItemHeight: widget.susItemHeight,
+          susPosition: widget.susPosition,
+          padding: widget.padding,
+          physics: widget.physics,
+        ),
+        Align(
+          alignment: widget.indexBarAlignment,
+          child: IndexBar(
+            data: widget.indexBarData,
+            width: widget.indexBarWidth,
+            height: widget.indexBarHeight,
+            itemHeight: widget.indexBarItemHeight,
+            margin: widget.indexBarMargin,
+            indexHintBuilder: widget.indexHintBuilder,
+            indexBarDragListener: dragListener,
+            options: widget.indexBarOptions,
+            controller: indexBarController,
+          ),
+        ),
+      ],
+    );
+  }
+}

+ 581 - 0
lib/azlistview/src/index_bar.dart

@@ -0,0 +1,581 @@
+import 'dart:ui';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'dart:math' as math;
+
+/// IndexHintBuilder.
+typedef IndexHintBuilder = Widget Function(BuildContext context, String tag);
+
+/// IndexBarDragListener.
+abstract class IndexBarDragListener {
+  /// Creates an [IndexBarDragListener] that can be used by a
+  /// [IndexBar] to return the drag listener.
+  factory IndexBarDragListener.create() => IndexBarDragNotifier();
+
+  /// drag details.
+  ValueListenable<IndexBarDragDetails> get dragDetails;
+}
+
+/// Internal implementation of [ItemPositionsListener].
+class IndexBarDragNotifier implements IndexBarDragListener {
+  @override
+  final ValueNotifier<IndexBarDragDetails> dragDetails =
+      ValueNotifier(IndexBarDragDetails());
+}
+
+/// IndexModel.
+class IndexBarDragDetails {
+  static const int actionDown = 0;
+  static const int actionUp = 1;
+  static const int actionUpdate = 2;
+  static const int actionEnd = 3;
+  static const int actionCancel = 4;
+
+  int? action;
+  int? index; //current touch index.
+  String? tag; //current touch tag.
+
+  double? localPositionY;
+  double? globalPositionY;
+
+  IndexBarDragDetails({
+    this.action,
+    this.index,
+    this.tag,
+    this.localPositionY,
+    this.globalPositionY,
+  });
+}
+
+///Default Index data.
+const List<String> kIndexBarData = const [
+  'A',
+  'B',
+  'C',
+  'D',
+  'E',
+  'F',
+  'G',
+  'H',
+  'I',
+  'J',
+  'K',
+  'L',
+  'M',
+  'N',
+  'O',
+  'P',
+  'Q',
+  'R',
+  'S',
+  'T',
+  'U',
+  'V',
+  'W',
+  'X',
+  'Y',
+  'Z',
+  '#'
+];
+
+const double kIndexBarWidth = 30;
+
+const double kIndexBarItemHeight = 16;
+
+/// IndexBar options.
+class IndexBarOptions {
+  /// Creates IndexBar options.
+  /// Examples.
+  /// needReBuild = true
+  /// ignoreDragCancel = true
+  /// color = Colors.transparent
+  /// downColor = Color(0xFFEEEEEE)
+  /// decoration
+  /// downDecoration
+  /// textStyle = TextStyle(fontSize: 12, color: Color(0xFF666666))
+  /// downTextStyle = TextStyle(fontSize: 12, color: Colors.white)
+  /// selectTextStyle = TextStyle(fontSize: 12, color: Colors.white)
+  /// downItemDecoration = BoxDecoration(shape: BoxShape.circle, color: Colors.blueAccent)
+  /// selectItemDecoration = BoxDecoration(shape: BoxShape.circle, color: Colors.blueAccent)
+  /// indexHintWidth = 72
+  /// indexHintHeight = 72
+  /// indexHintDecoration = BoxDecoration(color: Colors.black87, shape: BoxShape.rectangle, borderRadius: BorderRadius.all(Radius.circular(6)),)
+  /// indexHintTextStyle = TextStyle(fontSize: 24.0, color: Colors.white)
+  /// indexHintChildAlignment = Alignment.center
+  /// indexHintAlignment = Alignment.center
+  /// indexHintPosition
+  /// indexHintOffset
+  /// localImages
+  const IndexBarOptions({
+    this.needRebuild = false,
+    this.ignoreDragCancel = false,
+    this.color,
+    this.downColor,
+    this.decoration,
+    this.downDecoration,
+    this.textStyle = const TextStyle(fontSize: 12, color: Color(0xFF666666)),
+    this.downTextStyle,
+    this.selectTextStyle,
+    this.downItemDecoration,
+    this.selectItemDecoration,
+    this.indexHintWidth = 72,
+    this.indexHintHeight = 72,
+    this.indexHintDecoration = const BoxDecoration(
+      color: Colors.black87,
+      shape: BoxShape.rectangle,
+      borderRadius: BorderRadius.all(Radius.circular(6)),
+    ),
+    this.indexHintTextStyle =
+        const TextStyle(fontSize: 24.0, color: Colors.white),
+    this.indexHintChildAlignment = Alignment.center,
+    this.indexHintAlignment = Alignment.center,
+    this.indexHintPosition,
+    this.indexHintOffset = Offset.zero,
+    this.localImages = const [],
+  });
+
+  /// need to rebuild.
+  final bool needRebuild;
+
+  /// Ignore DragCancel.
+  final bool ignoreDragCancel;
+
+  /// IndexBar background color.
+  final Color? color;
+
+  /// IndexBar down background color.
+  final Color? downColor;
+
+  /// IndexBar decoration.
+  final Decoration? decoration;
+
+  /// IndexBar down decoration.
+  final Decoration? downDecoration;
+
+  /// IndexBar textStyle.
+  final TextStyle textStyle;
+
+  /// IndexBar down textStyle.
+  final TextStyle? downTextStyle;
+
+  /// IndexBar select textStyle.
+  final TextStyle? selectTextStyle;
+
+  /// IndexBar down item decoration.
+  final Decoration? downItemDecoration;
+
+  /// IndexBar select item decoration.
+  final Decoration? selectItemDecoration;
+
+  /// Index hint width.
+  final double indexHintWidth;
+
+  /// Index hint height.
+  final double indexHintHeight;
+
+  /// Index hint decoration.
+  final Decoration indexHintDecoration;
+
+  /// Index hint alignment.
+  final Alignment indexHintAlignment;
+
+  /// Index hint child alignment.
+  final Alignment indexHintChildAlignment;
+
+  /// Index hint textStyle.
+  final TextStyle indexHintTextStyle;
+
+  /// Index hint position.
+  final Offset? indexHintPosition;
+
+  /// Index hint offset.
+  final Offset indexHintOffset;
+
+  /// local images.
+  final List<String> localImages;
+}
+
+/// IndexBarController.
+class IndexBarController {
+  _IndexBarState? _indexBarState;
+
+  bool get isAttached => _indexBarState != null;
+
+  void updateTagIndex(String tag) {
+    _indexBarState?._updateTagIndex(tag);
+  }
+
+  void _attach(_IndexBarState state) {
+    _indexBarState = state;
+  }
+
+  void _detach() {
+    _indexBarState = null;
+  }
+}
+
+/// IndexBar.
+class IndexBar extends StatefulWidget {
+  IndexBar({
+    Key? key,
+    this.data = kIndexBarData,
+    this.width = kIndexBarWidth,
+    this.height,
+    this.itemHeight = kIndexBarItemHeight,
+    this.margin,
+    this.indexHintBuilder,
+    IndexBarDragListener? indexBarDragListener,
+    this.options = const IndexBarOptions(),
+    this.controller,
+  })  : indexBarDragNotifier = indexBarDragListener as IndexBarDragNotifier?,
+        super(key: key);
+
+  /// Index data.
+  final List<String> data;
+
+  /// IndexBar width(def:30).
+  final double width;
+
+  /// IndexBar height.
+  final double? height;
+
+  /// IndexBar item height(def:16).
+  final double itemHeight;
+
+  /// Empty space to surround the [decoration] and [child].
+  final EdgeInsetsGeometry? margin;
+
+  /// IndexHint Builder
+  final IndexHintBuilder? indexHintBuilder;
+
+  /// IndexBar drag listener.
+  final IndexBarDragNotifier? indexBarDragNotifier;
+
+  /// IndexBar options.
+  final IndexBarOptions options;
+
+  /// IndexBarController. If non-null, this can be used to control the state of the IndexBar.
+  final IndexBarController? controller;
+
+  @override
+  _IndexBarState createState() => _IndexBarState();
+}
+
+class _IndexBarState extends State<IndexBar> {
+  /// overlay entry.
+  static OverlayEntry? overlayEntry;
+
+  double floatTop = 0;
+  String indexTag = '';
+  int selectIndex = 0;
+  int action = IndexBarDragDetails.actionEnd;
+
+  @override
+  void initState() {
+    super.initState();
+    widget.indexBarDragNotifier?.dragDetails.addListener(_valueChanged);
+    widget.controller?._attach(this);
+  }
+
+  void _valueChanged() {
+    if (widget.indexBarDragNotifier == null) return;
+    IndexBarDragDetails details =
+        widget.indexBarDragNotifier!.dragDetails.value;
+    selectIndex = details.index!;
+    indexTag = details.tag!;
+    action = details.action!;
+    floatTop = details.globalPositionY! +
+        widget.itemHeight / 2 -
+        widget.options.indexHintHeight / 2;
+
+    if (_isActionDown()) {
+      _addOverlay(context);
+    } else {
+      _removeOverlay();
+    }
+
+    if (widget.options.needRebuild) {
+      if (widget.options.ignoreDragCancel &&
+          action == IndexBarDragDetails.actionCancel) {
+      } else {
+        setState(() {});
+      }
+    }
+  }
+
+  bool _isActionDown() {
+    return action == IndexBarDragDetails.actionDown ||
+        action == IndexBarDragDetails.actionUpdate;
+  }
+
+  @override
+  void dispose() {
+    widget.controller?._detach();
+    _removeOverlay();
+    widget.indexBarDragNotifier?.dragDetails.removeListener(_valueChanged);
+    super.dispose();
+  }
+
+  Widget _buildIndexHint(BuildContext context, String tag) {
+    if (widget.indexHintBuilder != null) {
+      return widget.indexHintBuilder!(context, tag);
+    }
+    Widget child;
+    TextStyle textStyle = widget.options.indexHintTextStyle;
+    List<String> localImages = widget.options.localImages;
+    if (localImages.contains(tag)) {
+      child = Image.asset(
+        tag,
+        width: textStyle.fontSize,
+        height: textStyle.fontSize,
+        color: textStyle.color,
+      );
+    } else {
+      child = Text('$tag', style: textStyle);
+    }
+    return Container(
+      width: widget.options.indexHintWidth,
+      height: widget.options.indexHintHeight,
+      alignment: widget.options.indexHintChildAlignment,
+      decoration: widget.options.indexHintDecoration,
+      child: child,
+    );
+  }
+
+  /// add overlay.
+  void _addOverlay(BuildContext context) {
+    OverlayState? overlayState = Overlay.of(context);
+    if (overlayState == null) return;
+    if (overlayEntry == null) {
+      overlayEntry = OverlayEntry(builder: (BuildContext ctx) {
+        double left;
+        double top;
+        if (widget.options.indexHintPosition != null) {
+          left = widget.options.indexHintPosition!.dx;
+          top = widget.options.indexHintPosition!.dy;
+        } else {
+          if (widget.options.indexHintAlignment == Alignment.centerRight) {
+            left = MediaQuery.of(context).size.width -
+                kIndexBarWidth -
+                widget.options.indexHintWidth +
+                widget.options.indexHintOffset.dx;
+            top = floatTop + widget.options.indexHintOffset.dy;
+          } else if (widget.options.indexHintAlignment ==
+              Alignment.centerLeft) {
+            left = kIndexBarWidth + widget.options.indexHintOffset.dx;
+            top = floatTop + widget.options.indexHintOffset.dy;
+          } else {
+            left = MediaQuery.of(context).size.width / 2 -
+                widget.options.indexHintWidth / 2 +
+                widget.options.indexHintOffset.dx;
+            top = MediaQuery.of(context).size.height / 2 -
+                widget.options.indexHintHeight / 2 +
+                widget.options.indexHintOffset.dy;
+          }
+        }
+        return Positioned(
+            left: left,
+            top: top,
+            child: Material(
+              color: Colors.transparent,
+              child: _buildIndexHint(ctx, indexTag),
+            ));
+      });
+      overlayState.insert(overlayEntry!);
+    } else {
+      //重新绘制UI,类似setState
+      overlayEntry?.markNeedsBuild();
+    }
+  }
+
+  /// remove overlay.
+  void _removeOverlay() {
+    overlayEntry?.remove();
+    overlayEntry = null;
+  }
+
+  Widget _buildItem(BuildContext context, int index) {
+    String tag = widget.data[index];
+    Decoration? decoration;
+    TextStyle? textStyle;
+    if (widget.options.downItemDecoration != null) {
+      decoration = (_isActionDown() && selectIndex == index)
+          ? widget.options.downItemDecoration
+          : null;
+      textStyle = (_isActionDown() && selectIndex == index)
+          ? widget.options.downTextStyle
+          : widget.options.textStyle;
+    } else if (widget.options.selectItemDecoration != null) {
+      decoration =
+          (selectIndex == index) ? widget.options.selectItemDecoration : null;
+      textStyle = (selectIndex == index)
+          ? widget.options.selectTextStyle
+          : widget.options.textStyle;
+    } else {
+      textStyle = _isActionDown()
+          ? (widget.options.downTextStyle ?? widget.options.textStyle)
+          : widget.options.textStyle;
+    }
+
+    Widget child;
+    List<String> localImages = widget.options.localImages;
+    if (localImages.contains(tag)) {
+      child = Image.asset(
+        tag,
+        width: textStyle?.fontSize,
+        height: textStyle?.fontSize,
+        color: textStyle?.color,
+      );
+    } else {
+      child = Text('$tag', style: textStyle);
+    }
+
+    return Container(
+      alignment: Alignment.center,
+      decoration: decoration,
+      child: child,
+    );
+  }
+
+  void _updateTagIndex(String tag) {
+    if (_isActionDown()) return;
+    selectIndex = widget.data.indexOf(tag);
+    setState(() {});
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      color: _isActionDown() ? widget.options.downColor : widget.options.color,
+      decoration: _isActionDown()
+          ? widget.options.downDecoration
+          : widget.options.decoration,
+      width: widget.width,
+      height: widget.height,
+      margin: widget.margin,
+      alignment: Alignment.center,
+      child: BaseIndexBar(
+        data: widget.data,
+        width: widget.width,
+        itemHeight: widget.itemHeight,
+        itemBuilder: (BuildContext context, int index) {
+          return _buildItem(context, index);
+        },
+        indexBarDragNotifier: widget.indexBarDragNotifier,
+      ),
+    );
+  }
+}
+
+class BaseIndexBar extends StatefulWidget {
+  BaseIndexBar({
+    Key? key,
+    this.data = kIndexBarData,
+    this.width = kIndexBarWidth,
+    this.itemHeight = kIndexBarItemHeight,
+    this.itemBuilder,
+    this.textStyle = const TextStyle(fontSize: 12.0, color: Color(0xFF666666)),
+    this.indexBarDragNotifier,
+  }) : super(key: key);
+
+  /// index data.
+  final List<String> data;
+
+  /// IndexBar width(def:30).
+  final double width;
+
+  /// IndexBar item height(def:16).
+  final double itemHeight;
+
+  /// IndexBar text style.
+  final TextStyle textStyle;
+
+  final IndexedWidgetBuilder? itemBuilder;
+
+  final IndexBarDragNotifier? indexBarDragNotifier;
+
+  @override
+  _BaseIndexBarState createState() => _BaseIndexBarState();
+}
+
+class _BaseIndexBarState extends State<BaseIndexBar> {
+  int lastIndex = -1;
+  int _widgetTop = 0;
+
+  /// get index.
+  int _getIndex(double offset) {
+    int index = offset ~/ widget.itemHeight;
+    return math.min(index, widget.data.length - 1);
+  }
+
+  /// trigger drag event.
+  _triggerDragEvent(int action) {
+    widget.indexBarDragNotifier?.dragDetails.value = IndexBarDragDetails(
+      action: action,
+      index: lastIndex,
+      tag: widget.data[lastIndex],
+      localPositionY: lastIndex * widget.itemHeight,
+      globalPositionY: lastIndex * widget.itemHeight + _widgetTop,
+    );
+  }
+
+  RenderBox? _getRenderBox(BuildContext context) {
+    RenderObject? renderObject = context.findRenderObject();
+    RenderBox? box;
+    if (renderObject != null) {
+      box = renderObject as RenderBox;
+    }
+    return box;
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    List<Widget> children = List.generate(widget.data.length, (index) {
+      Widget child = widget.itemBuilder == null
+          ? Center(
+              child: Text('${widget.data[index]}', style: widget.textStyle))
+          : widget.itemBuilder!(context, index);
+      return SizedBox(
+        width: widget.width,
+        height: widget.itemHeight,
+        child: child,
+      );
+    });
+
+    return GestureDetector(
+      onVerticalDragDown: (DragDownDetails details) {
+        RenderBox? box = _getRenderBox(context);
+        if (box == null) return;
+        Offset topLeftPosition = box.localToGlobal(Offset.zero);
+        _widgetTop = topLeftPosition.dy.toInt();
+        int index = _getIndex(details.localPosition.dy);
+        if (index >= 0) {
+          lastIndex = index;
+          _triggerDragEvent(IndexBarDragDetails.actionDown);
+        }
+      },
+      onVerticalDragUpdate: (DragUpdateDetails details) {
+        int index = _getIndex(details.localPosition.dy);
+        if (index >= 0 && lastIndex != index) {
+          lastIndex = index;
+          _triggerDragEvent(IndexBarDragDetails.actionUpdate);
+        }
+      },
+      onVerticalDragEnd: (DragEndDetails details) {
+        _triggerDragEvent(IndexBarDragDetails.actionEnd);
+      },
+      onVerticalDragCancel: () {
+        _triggerDragEvent(IndexBarDragDetails.actionCancel);
+      },
+      onTapUp: (TapUpDetails details) {
+        //_triggerDragEvent(IndexBarDragDetails.actionUp);
+      },
+      behavior: HitTestBehavior.translucent,
+      child: Column(
+        mainAxisSize: MainAxisSize.min,
+        children: children,
+      ),
+    );
+  }
+}

+ 165 - 0
lib/azlistview/src/suspension_view.dart

@@ -0,0 +1,165 @@
+import 'package:flutter/material.dart';
+import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
+import 'dart:math' as math;
+import 'az_common.dart';
+
+const double kSusItemHeight = 40;
+
+/// SuspensionView.
+class SuspensionView extends StatefulWidget {
+  SuspensionView({
+    Key? key,
+    required this.data,
+    required this.itemCount,
+    required this.itemBuilder,
+    this.itemScrollController,
+    this.itemPositionsListener,
+    this.susItemBuilder,
+    this.susItemHeight = kSusItemHeight,
+    this.susPosition,
+    this.physics,
+    this.padding,
+  }) : super(key: key);
+
+  /// Suspension data.
+  final List<ISuspensionBean> data;
+
+  /// Number of items the [itemBuilder] can produce.
+  final int itemCount;
+
+  /// Called to build children for the list with
+  /// 0 <= index < itemCount.
+  final IndexedWidgetBuilder itemBuilder;
+
+  /// Controller for jumping or scrolling to an item.
+  final ItemScrollController? itemScrollController;
+
+  /// Notifier that reports the items laid out in the list after each frame.
+  final ItemPositionsListener? itemPositionsListener;
+
+  /// Called to build suspension header.
+  final IndexedWidgetBuilder? susItemBuilder;
+
+  /// Suspension item Height.
+  final double susItemHeight;
+
+  /// Suspension item position.
+  final Offset? susPosition;
+
+  /// How the scroll view should respond to user input.
+  ///
+  /// For example, determines how the scroll view continues to animate after the
+  /// user stops dragging the scroll view.
+  ///
+  /// See [ScrollView.physics].
+  final ScrollPhysics? physics;
+
+  /// The amount of space by which to inset the children.
+  final EdgeInsets? padding;
+
+  @override
+  _SuspensionViewState createState() => _SuspensionViewState();
+}
+
+class _SuspensionViewState extends State<SuspensionView> {
+  /// Controller to scroll or jump to a particular item.
+  late ItemScrollController itemScrollController;
+
+  /// Listener that reports the position of items when the list is scrolled.
+  late ItemPositionsListener itemPositionsListener;
+
+  @override
+  void initState() {
+    super.initState();
+    itemScrollController =
+        widget.itemScrollController ?? ItemScrollController();
+    itemPositionsListener =
+        widget.itemPositionsListener ?? ItemPositionsListener.create();
+  }
+
+  @override
+  void dispose() {
+    super.dispose();
+  }
+
+  /// build sus widget.
+  Widget _buildSusWidget(BuildContext context) {
+    if (widget.susItemBuilder == null) {
+      return Container();
+    }
+    return ValueListenableBuilder<Iterable<ItemPosition>>(
+      valueListenable: itemPositionsListener.itemPositions,
+      builder: (ctx, positions, child) {
+        if (positions.isEmpty || widget.itemCount == 0) {
+          return Container();
+        }
+        ItemPosition itemPosition = positions
+            .where((ItemPosition position) => position.itemTrailingEdge > 0)
+            .reduce((ItemPosition min, ItemPosition position) =>
+                position.itemTrailingEdge < min.itemTrailingEdge
+                    ? position
+                    : min);
+        if (itemPosition.itemLeadingEdge > 0) return Container();
+        int index = itemPosition.index;
+        double left = 0;
+        double top = 0;
+        if (index < widget.itemCount) {
+          if (widget.susPosition != null) {
+            left = widget.susPosition!.dx;
+            top = widget.susPosition!.dy;
+          } else {
+            int next = math.min(index + 1, widget.itemCount - 1);
+            ISuspensionBean bean = widget.data[next];
+            if (bean.isShowSuspension) {
+              double height =
+                  context.findRenderObject()?.paintBounds.height ?? 0;
+              double topTemp = itemPosition.itemTrailingEdge * height;
+              top = math.min(widget.susItemHeight, topTemp) -
+                  widget.susItemHeight;
+            }
+          }
+        } else {
+          index = 0;
+        }
+        return Positioned(
+          left: left,
+          top: top,
+          child: widget.susItemBuilder!(ctx, index),
+        );
+      },
+    );
+  }
+
+  Widget _buildItem(BuildContext context, int index) {
+    ISuspensionBean bean = widget.data[index];
+    if (!bean.isShowSuspension || widget.susItemBuilder == null) {
+      return widget.itemBuilder(context, index);
+    }
+    return Column(
+      mainAxisSize: MainAxisSize.min,
+      children: [
+        widget.susItemBuilder!(context, index),
+        widget.itemBuilder(context, index),
+      ],
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Stack(
+      children: <Widget>[
+        widget.itemCount == 0
+            ? Container()
+            : ScrollablePositionedList.builder(
+                itemCount: widget.itemCount,
+                itemBuilder: (context, index) => _buildItem(context, index),
+                itemScrollController: itemScrollController,
+                itemPositionsListener: itemPositionsListener,
+                physics: widget.physics,
+                padding: widget.padding,
+              ),
+        _buildSusWidget(context),
+      ],
+    );
+  }
+}

+ 35 - 5
lib/common/api.dart

@@ -1,18 +1,22 @@
 import 'dart:convert';
 import 'dart:io';
 
+import 'package:cookie_jar/cookie_jar.dart';
 import 'package:crypto/crypto.dart';
 import 'package:dio/adapter.dart';
 import 'package:dio/dio.dart';
+import 'package:dio_cookie_manager/dio_cookie_manager.dart';
 import 'package:e2ee_chat/common/global.dart';
 
 class Api {
   Api();
 
-  static Dio dio = Dio(BaseOptions(
+  static final Dio dio = Dio(BaseOptions(
     baseUrl: 'http://10.122.237.112:80/',
   ));
 
+  static final cookieJar = CookieJar();
+
   static init() async {
     if (!Global.isRelease) {
       (dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (client) {
@@ -23,8 +27,15 @@ class Api {
         client.badCertificateCallback = (X509Certificate cert, String host, int port) => true;
       };
     }
+    
+    dio.interceptors.add(CookieManager(cookieJar));
 
-    debug(await Dio().get('http://10.122.237.112/account/login'));
+    try {
+      await dio.get('/account/login/');
+      // await Dio().get('http://10.122.237.112/account/login/');
+    } on DioError catch(e) {
+      debug('Api init connect to server failed!');
+    }
   }
 
   Future<String?> login({required String username, String? password, String? token}) async {
@@ -43,7 +54,7 @@ class Api {
         "/account/login/", data: FormData.fromMap(data),
         // options: _options,
       );
-      debug('Api login response: $r');
+      // debug('Api login response: $r');
       var json = jsonDecode(r.data);
       _token = json["token"].toString();
       debug('Api login new token: $_token');
@@ -103,14 +114,33 @@ class Api {
     return _result;
   }
 
+  Future<Map<String, dynamic>?> userProfile(String username) async {
+    debug('Api get user profile begin');
+    Map<String, dynamic>? _result;
+    try {
+      var r = await dio.post('/account/profile/$username');
+      _result = json.decode(r.data);
+    } catch (e) {
+      debug('get user profile failed!');
+    }
+    debug('Api get user profile end');
+    return _result;
+}
+
   Future<List<String>?> friendList() async {
     debug('Api friendList begin');
     List<String>? friends;
     try {
       var r = await dio.post('/friends/friends_list/');
-      friends = jsonDecode(r.data);
+      friends = [];
+      for (var i in jsonDecode(r.data) as List<dynamic>) {
+        friends.add(i.toString());
+      }
+      debug('Api friendList get: $friends');
     } on DioError catch (e) {
-      debug('get friend list failed!');
+      debug('get friend list failed: ${e.response?.statusCode ?? "unknown"}');
+    } catch(e) {
+      debug('Api get friend list failed: $e');
     }
     debug('Api friendList end');
     return friends;

+ 2 - 2
lib/common/global.dart

@@ -29,7 +29,7 @@ class Global {
   static GlobalKey<NavigatorState> navigatorKey = GlobalKey();
 
   static final Image defaultAvatar = Image.asset(
-    "imgs/avatar.jpg",
+    "images/avatar.png",
     width: 80,
   );
 
@@ -57,7 +57,7 @@ class Global {
 
   static listenIsLogin() async {
     while (true) {
-      if (!profile.isLogin && (profile.isLogout || !await LoginModel().login())) {
+      if (!profile.isLogin && (profile.isLogout || !await LoginPresenter().login())) {
         debug("offline detected! navigate to login route");
         await Global.navigatorKey.currentState?.pushNamed("login");
       }

+ 1 - 0
lib/l10n/localization_intl.dart

@@ -26,6 +26,7 @@ class GmLocalizations {
   String get language => Intl.message('Language', name: 'language');
   String get auto => Intl.message('Auto', name: 'auto');
   String get theme => Intl.message('Theme', name: 'theme');
+  String get search => Intl.message('Search', name: 'search');
 }
 
 //Locale代理类

+ 4 - 2
lib/main.dart

@@ -1,4 +1,5 @@
 import 'package:e2ee_chat/objectbox.g.dart';
+import 'package:e2ee_chat/view/add_friend.dart';
 import 'package:flutter/material.dart';
 import 'package:provider/provider.dart';
 import 'package:provider/single_child_widget.dart';
@@ -7,7 +8,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
 import 'common/global.dart';
 import 'l10n/localization_intl.dart';
 import 'presenter/locale.dart';
-import 'presenter/theme_model.dart';
+import 'presenter/theme.dart';
 import 'presenter/login.dart';
 import 'view/language.dart';
 import 'view/login.dart';
@@ -27,7 +28,7 @@ class MyApp extends StatelessWidget {
       providers: <SingleChildWidget>[
         ChangeNotifierProvider.value(value: ThemeModel()),
         ChangeNotifierProvider.value(value: LocaleModel()),
-        ChangeNotifierProvider.value(value: LoginModel()),
+        ChangeNotifierProvider.value(value: LoginPresenter()),
       ],
       child: Consumer2<ThemeModel, LocaleModel>(
         builder: (BuildContext context, themeModel, localeModel, Widget? child) {
@@ -68,6 +69,7 @@ class MyApp extends StatelessWidget {
               "login": (context) => LoginRoute(),
               "themes": (context) => ThemeRoute(),
               "language": (context) => LanguageRoute(),
+              "add friend": (context) => AddFriendView(),
             },
           );
         },

+ 17 - 1
lib/model/user.dart

@@ -1,17 +1,33 @@
+import 'package:e2ee_chat/objectbox.g.dart';
+import 'package:e2ee_chat/presenter/contact.dart';
 import 'package:objectbox/objectbox.dart';
+import 'package:e2ee_chat/common/api.dart';
 
 import 'message.dart';
 
 @Entity()
 class User {
-  User(this.username);
+  User(this.username, {this.bio, this.phone});
   int id = 0;
   @Unique()
   final String username;
   final friends = ToMany<User>();
   final messages = ToMany<Message>();
   final groupMessages = ToMany<GroupMessage>();
+  String? bio;
+  String? phone;
   bool isDND = false;
   bool isStick = false;
   bool isSpecialAttention = false;
+
+  static put(User user) async {
+    final store = await openStore();
+    final box = store.box<User>();
+    box.put(user);
+    store.close();
+  }
+
+  refreshProfile() => UserProfilePresenter(this).refresh();
+
+  // TODO: avatar
 }

+ 30 - 0
lib/presenter/contact.dart

@@ -0,0 +1,30 @@
+import 'package:e2ee_chat/common/api.dart';
+import 'package:e2ee_chat/model/user.dart';
+import 'package:flutter/cupertino.dart';
+
+import 'contact_list.dart';
+
+class UserProfilePresenter extends ChangeNotifier {
+  UserProfilePresenter(this.user);
+  final User user;
+
+  Future<bool> refresh() async {
+    bool _result = true;
+    try {
+      var json = (await Api().userProfile(user.username))!;
+      user.bio = json["bio"];
+      user.phone = json["phone"];
+      notifyListeners();
+      // TODO: avatar
+    } catch (e) {
+      _result = false;
+    }
+    return _result;
+  }
+
+  @override
+  void notifyListeners() {
+    User.put(user);
+    super.notifyListeners();
+  }
+}

+ 71 - 0
lib/presenter/contact_list.dart

@@ -0,0 +1,71 @@
+import 'package:e2ee_chat/azlistview/azlistview.dart';
+import 'package:e2ee_chat/common/global.dart';
+import 'package:e2ee_chat/model/user.dart';
+import 'package:e2ee_chat/presenter/contact.dart';
+import 'package:e2ee_chat/presenter/profile.dart';
+import 'package:e2ee_chat/presenter/login.dart';
+import 'package:flutter/material.dart';
+import 'package:lpinyin/lpinyin.dart';
+import 'package:e2ee_chat/common/api.dart';
+
+import 'login.dart';
+
+String _getTag(String name) {
+  String pinyin = PinyinHelper.getPinyinE(name);
+  String tag = pinyin.substring(0, 1).toUpperCase();
+  if (!RegExp("[A-Z]").hasMatch(tag)) {
+    tag = "#";
+  }
+  return tag;
+}
+
+class ContactListPresenter extends LoginPresenter {
+  List<ContactInfo> get contacts {
+    var list = <ContactInfo>[];
+    try {
+      for (var i in user!.friends) {
+        list.add(ContactInfo(name: i.username, tag: _getTag(i.username)));
+      }
+    } catch (e) {
+      debug(e);
+    }
+    list.add(ContactInfo(name: '新的朋友', tag: '↑', bgColor: Colors.orange, iconData: Icons.person_add));
+    SuspensionUtil.sortListBySuspensionTag(list);
+    SuspensionUtil.setShowSuspensionStatus(list);
+    return list;
+  }
+
+  Future<bool> freshContacts() async {
+    debug('contact list presenter fresh contacts begin');
+    bool _result = false;
+    var _user = user;
+    if (_user != null) {
+      try {
+        var list = (await Api().friendList())!;
+        _user.friends.clear();
+        list.forEach((username) {
+          var contact = User(username);
+          contact.refreshProfile();
+          _user.friends.add(contact);
+        });
+        _result = true;
+      } catch (e) {
+        debug('contact list presenter fresh contacts failed');
+      }
+    }
+    debug('contact list presenter fresh contacts end');
+    return _result;
+  }
+}
+
+class ContactInfo extends ISuspensionBean {
+  ContactInfo({required this.name, required this.tag, this.bgColor, this.iconData});
+
+  final String name;
+  final String tag;
+  final Color? bgColor;
+  final IconData? iconData;
+
+  @override
+  String getSuspensionTag() => tag;
+}

+ 1 - 1
lib/presenter/locale.dart

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
 
 import 'profile.dart';
 
-class LocaleModel extends ProfileChangeNotifier {
+class LocaleModel extends ProfilePresenter {
   LocaleModel();
 
   Locale? get locale => profile.locale;

+ 1 - 1
lib/presenter/login.dart

@@ -8,7 +8,7 @@ import '../model/user.dart';
 
 import 'profile.dart';
 
-class LoginModel extends ProfileChangeNotifier {
+class LoginPresenter extends ProfilePresenter {
 
   // TODO: online keep status; A logout, B logout?
   bool get isLogin => profile.isLogin;

+ 1 - 1
lib/presenter/profile.dart

@@ -3,7 +3,7 @@ import 'package:flutter/cupertino.dart';
 
 import '../common/global.dart';
 
-class ProfileChangeNotifier extends ChangeNotifier {
+class ProfilePresenter extends ChangeNotifier {
   Profile get profile => Global.profile;
 
   @override

+ 24 - 17
lib/presenter/session.dart

@@ -5,18 +5,16 @@ import 'package:flutter/cupertino.dart';
 
 import 'login.dart';
 
-enum DTMessageType {
-  text,
-  image
-}
+enum DTMessageType { text, image }
 
 class SessionListModel extends ChangeNotifier {
+  User? get user => LoginPresenter().user;
 
   List<SessionModel> get sessions {
-    var user = LoginModel().user;
+    final _user = user;
     var list = <SessionModel>[];
-    if (user != null) {
-      var friends = user.friends;
+    if (_user != null) {
+      var friends = _user.friends;
       for (var i in friends) {
         list.add(SessionModel(i));
       }
@@ -28,15 +26,25 @@ class SessionListModel extends ChangeNotifier {
 }
 
 class SessionModel extends SessionListModel {
-
   // TODO: 仅支持单聊
 
-  SessionModel(this._user);
+  SessionModel(this.contact);
 
-  final User _user;
+  final User contact;
 
   /// 消息体
-  Message get lastMessage => _user.messages.last;
+  Message? get lastMessage {
+    final _user = user;
+    Message? message;
+    if (_user != null) {
+      try {
+        message = _user.messages.lastWhere((msg) => msg.from.target == _user || msg.to.target == _user);
+      } catch (e) {
+        debug("no message");
+      }
+    }
+    return message;
+  }
 
   /// 未读数量
   int get unReadCount {
@@ -44,8 +52,7 @@ class SessionModel extends SessionListModel {
     return 0;
   }
 
-  get chatName => _user.username;
-
+  get chatName => contact.username;
 
   /// 单聊
   get isSingle => true;
@@ -54,13 +61,13 @@ class SessionModel extends SessionListModel {
   get isGroup => false;
 
   /// 消息免打扰
-  get isDND => _user.isDND;
+  get isDND => contact.isDND;
 
   /// 是否为置顶
-  get isStick => _user.isStick;
+  get isStick => contact.isStick;
 
   /// 特别关注
-  get isSpecialAttention => _user.isSpecialAttention;
+  get isSpecialAttention => contact.isSpecialAttention;
 
   // TODO: 是否 @ 你
   get isAtYou => false;
@@ -70,4 +77,4 @@ class SessionModel extends SessionListModel {
 
   // TODO: user avatar
   get avatar => Global.defaultAvatar;
-}
+}

+ 1 - 1
lib/presenter/theme_model.dart → lib/presenter/theme.dart

@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
 import 'profile.dart';
 import '../common/global.dart';
 
-class ThemeModel extends ProfileChangeNotifier {
+class ThemeModel extends ProfilePresenter {
   // 获取当前主题,如果为设置主题,则默认使用蓝色主题
   MaterialColor get theme => Global.themes
       .firstWhere((e) => e.value == profile.theme, orElse: () => Colors.blue);

+ 85 - 0
lib/view/add_friend.dart

@@ -0,0 +1,85 @@
+import 'package:dio/dio.dart';
+import 'package:e2ee_chat/common/global.dart';
+import 'package:e2ee_chat/l10n/localization_intl.dart';
+import 'package:e2ee_chat/presenter/login.dart';
+import 'package:e2ee_chat/presenter/theme.dart';
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:fluttertoast/fluttertoast.dart';
+
+class AddFriendView extends StatefulWidget {
+  @override
+  _AddFriendViewState createState() => _AddFriendViewState();
+}
+
+class _AddFriendViewState extends State<AddFriendView> {
+  TextEditingController _unameController = new TextEditingController();
+  GlobalKey _formKey = new GlobalKey<FormState>();
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(title: Text(GmLocalizations.of(context).login)),
+      body: Padding(
+        padding: const EdgeInsets.all(16.0),
+        child: Form(
+          key: _formKey,
+          autovalidateMode: AutovalidateMode.always, // TODO: what is AutovalidateMode?
+          child: Column(
+            children: <Widget>[
+              TextFormField(
+                  controller: _unameController,
+                  decoration: InputDecoration(
+                    labelText: GmLocalizations.of(context).userName,
+                    hintText: GmLocalizations.of(context).userNameOrEmail,
+                    prefixIcon: Icon(Icons.search),
+                  ),
+                  validator: (v) {
+                    return v!.trim().isNotEmpty ? null : GmLocalizations.of(context).userNameRequired;
+                  }),
+              Padding(
+                padding: const EdgeInsets.only(top: 25),
+                child: ConstrainedBox(
+                  constraints: BoxConstraints.expand(height: 55.0),
+                  child: ElevatedButton(
+                    style: ButtonStyle(
+                      foregroundColor: MaterialStateProperty.all<Color>(Provider.of<ThemeModel>(context).theme),
+                      //TODO: something wrong with text color
+                      textStyle: MaterialStateProperty.all<TextStyle>(TextStyle(color: Colors.white)),
+                    ),
+                    onPressed: _onLogin,
+                    child: Text(GmLocalizations.of(context).search),
+                  ),
+                ),
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+
+  void _onLogin() async {
+    // 提交前,先验证各个表单字段是否合法
+    if ((_formKey.currentState as FormState).validate()) {
+      Fluttertoast.showToast(msg: GmLocalizations.of(context).loading);
+      try {
+        // TODO: get user details
+        debug('isLogin: ${Global.profile.isLogin}');
+      } on DioError catch (e) {
+        //登录失败则提示
+        if (e.response?.statusCode == 401) {
+          Fluttertoast.showToast(msg: GmLocalizations.of(context).userNameOrPasswordWrong);
+        } else {
+          Fluttertoast.showToast(msg: e.toString());
+        }
+      } finally {
+        if (Global.profile.isLogin) {
+          debug('Login!');
+          // 返回
+          Navigator.of(context).pop();
+        }
+      }
+    }
+  }
+}

+ 177 - 0
lib/view/contacts.dart

@@ -0,0 +1,177 @@
+import 'package:e2ee_chat/azlistview/azlistview.dart';
+import 'package:e2ee_chat/common/global.dart';
+import 'package:e2ee_chat/model/message.dart';
+import 'package:e2ee_chat/presenter/contact_list.dart';
+import 'package:e2ee_chat/presenter/session.dart';
+import 'package:e2ee_chat/widgets/utils.dart';
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+class ContactListPage extends StatefulWidget {
+  @override
+  _ContactListPageState createState() => _ContactListPageState();
+}
+
+class _ContactListPageState extends State<ContactListPage> {
+
+  @override
+  void initState() {
+    super.initState();
+    ContactListPresenter().freshContacts().then((_) => setState(() {}));
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return ChangeNotifierProvider<ContactListPresenter>(
+      create: (context) => ContactListPresenter(),
+      child: Builder(
+        builder: (context) {
+          var provider = Provider.of<ContactListPresenter>(context);
+          var contacts = provider.contacts;
+          return AzListView(
+              data: contacts,
+              itemCount: contacts.length,
+              itemBuilder: (context, index) {
+                var info = contacts[index];
+                return Utils.getWeChatListItem(
+                  context,
+                  info,
+                  defHeaderBgColor: Color(0xFFE5E5E5),
+                );
+              },
+            physics: BouncingScrollPhysics(),
+            susItemBuilder: (BuildContext context, int index) {
+              ContactInfo model = contacts[index];
+              if ('↑' == model.getSuspensionTag()) {
+                return Container();
+              }
+              return Utils.getSusItem(context, model.getSuspensionTag());
+            },
+            indexBarData: ['↑', '☆', ...kIndexBarData],
+            indexBarOptions: IndexBarOptions(
+              needRebuild: true,
+              ignoreDragCancel: true,
+              downTextStyle: TextStyle(fontSize: 12, color: Colors.white),
+              downItemDecoration:
+              BoxDecoration(shape: BoxShape.circle, color: Colors.green),
+              indexHintWidth: 120 / 2,
+              indexHintHeight: 100 / 2,
+              indexHintDecoration: BoxDecoration(
+                image: DecorationImage(
+                  image: AssetImage(Utils.getImgPath('ic_index_bar_bubble_gray')),
+                  fit: BoxFit.contain,
+                ),
+              ),
+              indexHintAlignment: Alignment.centerRight,
+              indexHintChildAlignment: Alignment(-0.25, 0.0),
+              indexHintOffset: Offset(-20, 0),
+            ),
+          );
+        },
+      ),
+    );
+  }
+}
+
+class ContactItem extends StatelessWidget {
+  final SessionModel model;
+
+  ContactItem(this.model);
+
+  @override
+  Widget build(BuildContext context) {
+    return Row(
+      mainAxisAlignment: MainAxisAlignment.spaceBetween,
+      children: <Widget>[
+        model.avatar ?? Global.defaultAvatar,
+        Expanded(
+          child: Column(
+            children: <Widget>[
+              Padding(
+                padding: EdgeInsets.symmetric(vertical: 8),
+                child: Row(
+                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                  children: <Widget>[
+                    Expanded(
+                      child: Text(
+                        model.chatName,
+                        style: TextStyle(fontSize: 34),
+                        overflow: TextOverflow.ellipsis,
+                      ),
+                    ),
+                    Text(
+                      _formatDate(),
+                      style: TextStyle(fontSize: 26),
+                      overflow: TextOverflow.ellipsis,
+                    ),
+                  ],
+                ),
+              ),
+              Row(
+                mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                children: <Widget>[
+                  Expanded(
+                    child: RichText(
+                      text: TextSpan(children: [
+                        TextSpan(
+                          text: model.isAtYou ? "[@你]" : "",
+                          style: TextStyle(fontSize: 28),
+                        ),
+                        TextSpan(
+                          text: model.isSpecialAttention ? "[特别关注]" : "",
+                          style: TextStyle(fontSize: 28),
+                        ),
+                        TextSpan(
+                          text: model.isAtAll ? "[@所有人]" : "",
+                          style: TextStyle(fontSize: 28),
+                        ),
+                        TextSpan(
+                          text: model.lastMessage?.content.target?.plaintext ?? "",
+                          style: TextStyle(fontSize: 28),
+                        )
+                      ]),
+                      overflow: TextOverflow.ellipsis,
+                    ),
+                  ),
+                  (model.unReadCount > 0 && !model.isDND)
+                      ? Container(
+                          width: 32,
+                          height: 32,
+                          alignment: Alignment.center,
+                          decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)),
+                          child: Text(
+                            model.unReadCount.toString(),
+                            style: TextStyle(color: Colors.white, fontSize: 26),
+                          ))
+                      : Container(),
+                  model.isDND
+                      ? Row(
+                          children: <Widget>[
+                            Icon(Icons.visibility_off),
+                            model.unReadCount > 0
+                                ? Icon(
+                                    Icons.chat_bubble,
+                                    color: Colors.red,
+                                  ) // TODO: 小红点
+                                : Container()
+                          ],
+                        )
+                      : Container()
+                ],
+              )
+            ],
+          ),
+        )
+      ],
+    );
+  }
+
+  String _formatDate() {
+    try {
+      DateTime dateTime = DateTime.fromMillisecondsSinceEpoch(model.lastMessage!.time);
+      return "${dateTime.hour}:${dateTime.minute}";
+    } catch (e) {
+      return "";
+    }
+  }
+}

+ 0 - 14
lib/view/friends.dart

@@ -1,14 +0,0 @@
-import 'package:e2ee_chat/widgets/empty.dart';
-import 'package:flutter/cupertino.dart';
-
-class FriendRoute extends StatefulWidget {
-  @override
-  _FriendRouteState createState() => _FriendRouteState();
-}
-
-class _FriendRouteState extends State<FriendRoute> {
-  @override
-  Widget build(BuildContext context) {
-    return EmptyWidget();
-  }
-}

+ 40 - 26
lib/view/home.dart

@@ -1,15 +1,15 @@
 import 'package:e2ee_chat/common/global.dart';
 import 'package:e2ee_chat/l10n/localization_intl.dart';
 import 'package:e2ee_chat/common/api.dart';
-import 'package:e2ee_chat/presenter/theme_model.dart';
+import 'package:e2ee_chat/presenter/theme.dart';
 import 'package:e2ee_chat/widgets/empty.dart';
 import 'package:e2ee_chat/widgets/mydrawer.dart';
 import 'package:flutter/material.dart';
 import 'package:provider/provider.dart';
 
-import 'friends.dart';
+import 'contacts.dart';
 import 'group.dart';
-import 'message.dart';
+import 'session.dart';
 
 class HomeRoute extends StatefulWidget {
   @override
@@ -31,9 +31,24 @@ class _HomeRouteState extends State<HomeRoute> {
       appBar: AppBar(
         title: Text(gm.home),
         actions: <Widget>[
-          IconButton(onPressed: () {
-            // TODO: 弹出下拉菜单
-          }, icon: Icon(Icons.add))
+          PopupMenuButton(itemBuilder: (context) {
+            return <PopupMenuItem>[PopupMenuItem(child: Container(
+              child: TextButton(
+                onPressed: () => Navigator.of(context).pushNamed("add friend"),
+                  child: Row(
+                    children: [
+                      Icon(Icons.person_add),
+                      Text("添加朋友"),
+                    ],
+                  )
+              ),
+            ))];
+          }),
+          IconButton(
+              onPressed: () {
+                // TODO: 弹出下拉菜单
+              },
+              icon: Icon(Icons.add))
         ],
       ),
       drawer: MyDrawer(),
@@ -47,12 +62,7 @@ class _HomeRouteState extends State<HomeRoute> {
         fixedColor: Provider.of<ThemeModel>(context).theme,
         onTap: _onItemTapped,
       ),
-      body: RefreshIndicator(
-        child: _buildChild(),
-        onRefresh: () async {
-          // TODO: pull to refresh
-        },
-      ), // 构建主页面
+      body: _buildBody(), // 构建主页面
     );
   }
 
@@ -62,21 +72,25 @@ class _HomeRouteState extends State<HomeRoute> {
     });
   }
 
-  Widget _buildChild() {
-    switch (_index) {
-      case 0:
-        return MessageList();
-      case 1:
-        return FriendRoute();
-      case 2:
-        return GroupRoute();
+  Widget _buildBody() {
+    _buildChild() {
+      switch (_index) {
+        case 0:
+          return SessionList();
+        case 1:
+          return ContactListPage();
+        case 2:
+          return GroupRoute();
+      }
+      return EmptyWidget();
     }
-    return EmptyWidget();
-
+    return RefreshIndicator(
+        child: _buildChild(),
+        onRefresh: _onRefresh
+    );
   }
 
-  Widget _buildGroupList() {
-    // TODO: group list
-    return EmptyWidget();
+  Future<void> _onRefresh() async {
+    // TODO: pull to refresh
   }
-}
+}

+ 2 - 2
lib/view/login.dart

@@ -2,7 +2,7 @@ import 'package:dio/dio.dart';
 import 'package:e2ee_chat/common/global.dart';
 import 'package:e2ee_chat/l10n/localization_intl.dart';
 import 'package:e2ee_chat/presenter/login.dart';
-import 'package:e2ee_chat/presenter/theme_model.dart';
+import 'package:e2ee_chat/presenter/theme.dart';
 import 'package:flutter/material.dart';
 import 'package:provider/provider.dart';
 import 'package:fluttertoast/fluttertoast.dart';
@@ -100,7 +100,7 @@ class _LoginRouteState extends State<LoginRoute> {
     if ((_formKey.currentState as FormState).validate()) {
       Fluttertoast.showToast(msg: GmLocalizations.of(context).loading);
       try {
-        await LoginModel().loginOrRegister(_unameController.text, _pwdController.text);
+        await LoginPresenter().loginOrRegister(_unameController.text, _pwdController.text);
         debug('isLogin: ${Global.profile.isLogin}');
       } on DioError catch (e) {
         //登录失败则提示

+ 11 - 10
lib/view/message.dart → lib/view/session.dart

@@ -4,12 +4,12 @@ import 'package:flutter/material.dart';
 import 'package:flutter_slidable/flutter_slidable.dart';
 import 'package:provider/provider.dart';
 
-class MessageList extends StatefulWidget {
+class SessionList extends StatefulWidget {
   @override
-  _MessageListState createState() => _MessageListState();
+  _SessionListState createState() => _SessionListState();
 }
 
-class _MessageListState extends State<MessageList> {
+class _SessionListState extends State<SessionList> {
   @override
   Widget build(BuildContext context) {
     return ChangeNotifierProvider<SessionListModel>(
@@ -45,7 +45,7 @@ class _MessageListState extends State<MessageList> {
                     child: Row(
                       crossAxisAlignment: CrossAxisAlignment.start,
                       children: <Widget>[
-                        Expanded(child: MessageItem(_item)),
+                        Expanded(child: SessionItem(_item)),
                         _item.isStick
                             ? Container(
                                 width: 40,
@@ -66,13 +66,14 @@ class _MessageListState extends State<MessageList> {
   }
 }
 
-class MessageItem extends StatelessWidget {
+class SessionItem extends StatelessWidget {
   final SessionModel model;
 
-  MessageItem(this.model);
+  SessionItem(this.model);
 
   @override
   Widget build(BuildContext context) {
+    final message = model.lastMessage;
     return Row(
       mainAxisAlignment: MainAxisAlignment.spaceBetween,
       children: <Widget>[
@@ -93,7 +94,7 @@ class MessageItem extends StatelessWidget {
                       ),
                     ),
                     Text(
-                      _formatDate(),
+                      model.lastMessage != null ? _formatDate(model.lastMessage!.time) : "",
                       style: TextStyle(fontSize: 26),
                       overflow: TextOverflow.ellipsis,
                     ),
@@ -119,7 +120,7 @@ class MessageItem extends StatelessWidget {
                           style: TextStyle(fontSize: 28),
                         ),
                         TextSpan(
-                          text: model.lastMessage.content.target!.plaintext,
+                          text: model.lastMessage?.content.target?.plaintext ?? "",
                           style: TextStyle(fontSize: 28),
                         )
                       ]),
@@ -159,8 +160,8 @@ class MessageItem extends StatelessWidget {
     );
   }
 
-  String _formatDate() {
-    DateTime dateTime = DateTime.fromMillisecondsSinceEpoch(model.lastMessage.time);
+  String _formatDate(int time) {
+    DateTime dateTime = DateTime.fromMillisecondsSinceEpoch(time);
     return "${dateTime.hour}:${dateTime.minute}";
   }
 }

+ 1 - 1
lib/view/theme.dart

@@ -2,7 +2,7 @@
 
 import 'package:e2ee_chat/common/global.dart';
 import 'package:e2ee_chat/l10n/localization_intl.dart';
-import 'package:e2ee_chat/presenter/theme_model.dart';
+import 'package:e2ee_chat/presenter/theme.dart';
 import 'package:flutter/material.dart';
 import 'package:provider/provider.dart';
 

+ 1 - 1
lib/widgets/mydrawer.dart

@@ -40,7 +40,7 @@ class MyDrawer extends StatelessWidget {
                   ListTile(
                     leading: const Icon(Icons.logout),
                     title: const Text('logout'),
-                    onTap: () => LoginModel().logout(),
+                    onTap: () => LoginPresenter().logout(),
                   ),
                   ListTile(
                     leading: const Icon(Icons.settings),

+ 98 - 0
lib/widgets/utils.dart

@@ -0,0 +1,98 @@
+import 'package:e2ee_chat/presenter/contact_list.dart';
+import 'package:flutter/material.dart';
+import 'package:common_utils/common_utils.dart';
+
+class Utils {
+  static String getImgPath(String name, {String format: 'png'}) {
+    return 'assets/images/$name.$format';
+  }
+
+  static void showSnackBar(BuildContext context, String msg) {
+    ScaffoldMessenger.of(context).showSnackBar(
+      SnackBar(
+        content: Text(msg),
+        duration: Duration(seconds: 2),
+      ),
+    );
+  }
+
+  static Widget getSusItem(BuildContext context, String tag,
+      {double susHeight = 40}) {
+    if (tag == '★') {
+      tag = '★ 热门城市';
+    }
+    return Container(
+      height: susHeight,
+      width: MediaQuery.of(context).size.width,
+      padding: EdgeInsets.only(left: 16.0),
+      color: Color(0xFFF3F4F5),
+      alignment: Alignment.centerLeft,
+      child: Text(
+        '$tag',
+        softWrap: false,
+        style: TextStyle(
+          fontSize: 14.0,
+          color: Color(0xFF666666),
+        ),
+      ),
+    );
+  }
+
+  static Widget getWeChatListItem(
+    BuildContext context,
+    ContactInfo model, {
+    double susHeight = 40,
+    Color? defHeaderBgColor,
+  }) {
+    return getWeChatItem(context, model, defHeaderBgColor: defHeaderBgColor);
+//    return Column(
+//      mainAxisSize: MainAxisSize.min,
+//      children: <Widget>[
+//        Offstage(
+//          offstage: !(model.isShowSuspension == true),
+//          child: getSusItem(context, model.getSuspensionTag(),
+//              susHeight: susHeight),
+//        ),
+//        getWeChatItem(context, model, defHeaderBgColor: defHeaderBgColor),
+//      ],
+//    );
+  }
+
+  static Widget getWeChatItem(
+    BuildContext context,
+    ContactInfo model, {
+    Color? defHeaderBgColor,
+  }) {
+    DecorationImage? image;
+//    if (model.img != null && model.img.isNotEmpty) {
+//      image = DecorationImage(
+//        image: CachedNetworkImageProvider(model.img),
+//        fit: BoxFit.contain,
+//      );
+//    }
+    return ListTile(
+      leading: Container(
+        width: 36,
+        height: 36,
+        decoration: BoxDecoration(
+          shape: BoxShape.rectangle,
+          borderRadius: BorderRadius.circular(4.0),
+          color: model.bgColor ?? defHeaderBgColor,
+          image: image,
+        ),
+        child: model.iconData == null
+            ? null
+            : Icon(
+                model.iconData,
+                color: Colors.white,
+                size: 20,
+              ),
+      ),
+      title: Text(model.name),
+      onTap: () {
+        LogUtil.e("onItemClick : $model");
+        Utils.showSnackBar(context, 'onItemClick : ${model.name}');
+      },
+    );
+  }
+}

+ 44 - 9
pubspec.lock

@@ -29,13 +29,6 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "2.6.1"
-  azlistview:
-    dependency: "direct main"
-    description:
-      name: azlistview
-      url: "https://pub.flutter-io.cn"
-    source: hosted
-    version: "1.1.1"
   boolean_selector:
     dependency: transitive
     description:
@@ -148,6 +141,13 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.15.0"
+  common_utils:
+    dependency: "direct main"
+    description:
+      name: common_utils
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.0.2"
   convert:
     dependency: transitive
     description:
@@ -155,6 +155,13 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "3.0.1"
+  cookie_jar:
+    dependency: "direct main"
+    description:
+      name: cookie_jar
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "3.0.1"
   crypto:
     dependency: "direct main"
     description:
@@ -176,6 +183,13 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "2.0.1"
+  decimal:
+    dependency: transitive
+    description:
+      name: decimal
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.2.0"
   dio:
     dependency: "direct main"
     description:
@@ -183,6 +197,13 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "4.0.0"
+  dio_cookie_manager:
+    dependency: "direct main"
+    description:
+      name: dio_cookie_manager
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.0.0"
   fake_async:
     dependency: transitive
     description:
@@ -322,6 +343,13 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.0.1"
+  lpinyin:
+    dependency: "direct main"
+    description:
+      name: lpinyin
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.0.3"
   matcher:
     dependency: transitive
     description:
@@ -490,13 +518,20 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.0.0"
-  scrollable_positioned_list:
+  rational:
     dependency: transitive
+    description:
+      name: rational
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.2.1"
+  scrollable_positioned_list:
+    dependency: "direct main"
     description:
       name: scrollable_positioned_list
       url: "https://pub.flutter-io.cn"
     source: hosted
-    version: "0.1.10"
+    version: "0.2.0-nullsafety.0"
   shelf:
     dependency: transitive
     description:

+ 9 - 2
pubspec.yaml

@@ -34,10 +34,14 @@ dependencies:
   # objectbox_sync_flutter_libs: any
 
   dio: ^4.0.0
+  dio_cookie_manager: ^2.0.0
+  cookie_jar: ^3.0.1
   crypto: ^3.0.1
   permission_handler: ^8.1.2
   flutter_slidable: ^0.6.0
-  azlistview: ^1.1.1
+  scrollable_positioned_list: ^0.2.0-nullsafety.0
+  lpinyin: ^2.0.3
+  common_utils: ^2.0.2
 
   # The following adds the Cupertino Icons font to your application.
   # Use with the CupertinoIcons class for iOS style icons.
@@ -64,7 +68,10 @@ flutter:
 
   # To add assets to your application, add an assets section, like this:
   assets:
-    - imgs/avatar.jpg
+    - images/avatar.png
+    - images/ic_favorite.png
+    - images/ic_index_bar_bubble_gray.png
+    - images/ic_index_bar_bubble_white.png
   #   - images/a_dot_burr.jpeg
   #   - images/a_dot_ham.jpeg