春節期間,吃飽喝足,尋思著年前有使用flutter3開發過一款App聊天應用,索性使用flutter3開發一款桌面端仿微信exe聊天專案Flutter_Winchat。
在經過了半個多月的開發,flutter3桌面聊天專案正式開發完畢!
在開發的過程中,確實遇到了一些問題,不過好在最後都解決了。
想著這個專案還算不錯,就和小夥伴們做一些分享。
使用技術
- 編輯器:vscode
- 視窗管理:bitsdojo_window: ^0.1.6
- 托盤圖示:system_tray: ^2.0.3
- 路由/狀態管理:get: ^4.6.6
- 本地儲存:get_storage: ^2.1.1
- 圖片預覽外掛:photo_view: ^0.14.0
- 網址預覽:url_launcher: ^6.2.4
- 影片元件:media_kit: ^1.1.10+1
- 檔案選擇器:file_picker: ^6.1.1
整個專案使用到了上面的一些技術,視窗管理採用的bitsdojo_window
外掛,不過window_manager
這個視窗管理外掛也不錯,不過相對重量級一些。
https://pub-web.flutter-io.cn/packages/bitsdojo_window
https://pub-web.flutter-io.cn/packages/window_manager
專案結構
透過flutter create app_proj
建立一個新專案。
使用flutter run -d windows
來執行到window桌面。
透過 system_tray
外掛管理flutter桌面端工作列托盤圖示。
https://pub-web.flutter-io.cn/packages/system_tray
main.dart入口管理
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:media_kit/media_kit.dart';
import 'package:system_tray/system_tray.dart';
import 'utils/index.dart';
// 引入公共樣式
import 'styles/index.dart';
// 引入公共佈局模板
import 'layouts/index.dart';
// 引入路由配置
import 'router/index.dart';
void main() async {
// 初始化get_storage儲存類
await GetStorage.init();
// 初始化media_kit影片套件
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
initSystemTray();
runApp(const MyApp());
// 初始化bitsdojo_window視窗
doWhenWindowReady(() {
appWindow.size = const Size(850, 620);
appWindow.minSize = const Size(700, 500);
appWindow.alignment = Alignment.center;
appWindow.title = 'Flutter3-WinChat';
appWindow.show();
});
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return GetMaterialApp(
title: 'FLUTTER3 WINCHAT',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primaryColor: FStyle.primaryColor,
useMaterial3: true,
// 修正windows端字型粗細不一致
fontFamily: Platform.isWindows ? 'Microsoft YaHei' : null,
),
home: const Layout(),
// 初始路由
initialRoute: Utils.isLogin() ? '/index' :'/login',
// 路由頁面
getPages: routes,
onInit: () {},
onReady: () {},
);
}
}
// 建立系統托盤圖示
Future<void> initSystemTray() async {
String trayIco = 'assets/images/tray.ico';
SystemTray systemTray = SystemTray();
// 初始化系統托盤
await systemTray.initSystemTray(
title: 'system-tray',
iconPath: trayIco,
);
// 右鍵選單
final Menu menu = Menu();
await menu.buildFrom([
MenuItemLabel(label: 'show', onClicked: (menuItem) => appWindow.show()),
MenuItemLabel(label: 'hide', onClicked: (menuItem) => appWindow.hide()),
MenuItemLabel(label: 'close', onClicked: (menuItem) => appWindow.close()),
]);
await systemTray.setContextMenu(menu);
// 右鍵事件
systemTray.registerSystemTrayEventHandler((eventName) {
debugPrint('eventName: $eventName');
if (eventName == kSystemTrayEventClick) {
Platform.isWindows ? appWindow.show() : systemTray.popUpContextMenu();
} else if (eventName == kSystemTrayEventRightClick) {
Platform.isWindows ? systemTray.popUpContextMenu() : appWindow.show();
}
});
}
flutter3路由/狀態管理
採用 getx
作為路由和狀態管理。將 MaterialApp
替換為GetMaterialApp
元件。
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return GetMaterialApp(
title: 'FLUTTER3 WINCHAT',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primaryColor: FStyle.primaryColor,
useMaterial3: true,
),
home: const Layout(),
// 初始路由
initialRoute: Utils.isLogin() ? '/index' :'/login',
// 路由頁面
getPages: routes,
);
}
}
import 'package:flutter/material.dart';
import 'package:get/get.dart';
// 引入工具類
import '../utils/index.dart';
/* 引入路由頁面 */
import '../views/auth/login.dart';
import '../views/auth/register.dart';
// 首頁
import '../views/index/index.dart';
// 通訊錄
import '../views/contact/index.dart';
import '../views/contact/addfriends.dart';
import '../views/contact/newfriends.dart';
import '../views/contact/uinfo.dart';
// 收藏
import '../views/favor/index.dart';
// 我的
import '../views/my/index.dart';
import '../views/my/setting.dart';
import '../views/my/recharge.dart';
import '../views/my/wallet.dart';
// 朋友圈
import '../views/fzone/index.dart';
import '../views/fzone/publish.dart';
// 短影片
import '../views/fvideo/index.dart';
// 聊天
import '../views/chat/group-chat/chat.dart';
// 路由地址集合
final Map<String, Widget> routeMap = {
'/index': const Index(),
'/contact': const Contact(),
'/addfriends': const AddFriends(),
'/newfriends': const NewFriends(),
'/uinfo': const Uinfo(),
'/favor': const Favor(),
'/my': const My(),
'/setting': const Setting(),
'/recharge': const Recharge(),
'/wallet': const Wallet(),
'/fzone': const Fzone(),
'/publish': const PublishFzone(),
'/fvideo': const Fvideo(),
'/chat': const Chat(),
};
final List<GetPage> patchRoute = routeMap.entries.map((e) => GetPage(
name: e.key, // 路由名稱
page: () => e.value, // 路由頁面
transition: Transition.noTransition, // 跳轉路由動畫
middlewares: [AuthMiddleware()], // 路由中介軟體
)).toList();
final List<GetPage> routes = [
GetPage(name: '/login', page: () => const Login()),
GetPage(name: '/register', page: () => const Register()),
...patchRoute,
];
透過getx
內建的middlewares
中介軟體進行路由跳轉攔截。
// 路由攔截
class AuthMiddleware extends GetMiddleware {
@override
RouteSettings? redirect(String? route) {
return Utils.isLogin() ? null : const RouteSettings(name: '/login');
}
}
flutter自定義最小化/最大化/關閉
採用bitsdojo_window
外掛進行視窗管理。該外掛支援去掉系統導航條,自定義視窗大小、右上角操作按鈕、拖拽視窗等功能。
@override
Widget build(BuildContext context){
return Row(
children: [
Container(
child: widget.leading,
),
Visibility(
visible: widget.minimizable,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: SizedBox(
width: 32.0,
height: 36.0,
child: MinimizeWindowButton(colors: buttonColors, onPressed: handleMinimize,),
)
),
),
Visibility(
visible: widget.maximizable,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: SizedBox(
width: 32.0,
height: 36.0,
child: isMaximized ?
RestoreWindowButton(colors: buttonColors, onPressed: handleMaxRestore,)
:
MaximizeWindowButton(colors: buttonColors, onPressed: handleMaxRestore,),
),
),
),
Visibility(
visible: widget.closable,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: SizedBox(
width: 32.0,
height: 36.0,
child: CloseWindowButton(colors: closeButtonColors, onPressed: handleExit,),
),
),
),
Container(
child: widget.trailing,
),
],
);
}
自定義最大化/最小化/關閉按鈕事件。
// 最小化
void handleMinimize() {
appWindow.minimize();
}
// 設定最大化/恢復
void handleMaxRestore() {
appWindow.maximizeOrRestore();
}
// 關閉
void handleExit() {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
content: const Text('是否最小化至托盤,不退出程式?', style: TextStyle(fontSize: 16.0),),
backgroundColor: Colors.white,
surfaceTintColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(3.0)),
elevation: 3.0,
actionsPadding: const EdgeInsets.all(15.0),
actions: [
TextButton(
onPressed: () {
Get.back();
appWindow.close();
},
child: const Text('退出', style: TextStyle(color: Colors.red),)
),
TextButton(
onPressed: () {
Get.back();
appWindow.hide();
},
child: const Text('最小化至托盤', style: TextStyle(color: Colors.deepPurple),)
),
],
);
}
);
}
透過flutter內建的WidgetsBindingObserver
監聽視窗尺寸變化。
class _WinbtnState extends State<Winbtn> with WidgetsBindingObserver {
// 是否最大化
bool isMaximized = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
// 監聽視窗尺寸變化
@override
void didChangeMetrics() {
super.didChangeMetrics();
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
isMaximized = appWindow.isMaximized;
});
});
}
// ...
}
flutter公共佈局模板
整體專案借鑑了微信桌面端UI介面。
class Layout extends StatefulWidget {
const Layout({
super.key,
this.activitybar = const Activitybar(),
this.sidebar,
this.workbench,
this.showSidebar = true,
});
final Widget? activitybar; // 左側操作欄
final Widget? sidebar; // 側邊欄
final Widget? workbench; // 右側工作皮膚
final bool showSidebar; // 是否顯示側邊欄
@override
State<Layout> createState() => _LayoutState();
}
左側操作欄,無事件操作區域MoveWindow
還支援拖動視窗功能。
return Scaffold(
backgroundColor: Colors.grey[100],
body: Flex(
direction: Axis.horizontal,
children: [
// 左側操作欄
MoveWindow(
child: widget.activitybar,
onDoubleTap: () => {},
),
// 側邊欄
Visibility(
visible: widget.showSidebar,
child: SizedBox(
width: 270.0,
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFEEEBE7), Color(0xFFEEEEEE)
]
),
),
child: widget.sidebar,
),
),
),
// 主體容器
Expanded(
child: Column(
children: [
WindowTitleBarBox(
child: Row(
children: [
Expanded(
child: MoveWindow(),
),
// 右上角操作按鈕組
Winbtn(
leading: Row(
children: [
IconButton(onPressed: () {}, icon: const Icon(Icons.auto_fix_high), iconSize: 14.0,),
IconButton(
onPressed: () {
setState(() {
winTopMost = !winTopMost;
});
},
tooltip: winTopMost ? '取消置頂' : '置頂',
icon: const Icon(Icons.push_pin_outlined),
iconSize: 14.0,
highlightColor: Colors.transparent, // 點選水波紋顏色
isSelected: winTopMost ? true : false, // 是否選中
style: ButtonStyle(
visualDensity: VisualDensity.compact,
backgroundColor: MaterialStateProperty.all(winTopMost ? Colors.grey[300] : Colors.transparent),
shape: MaterialStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(0.0))
),
),
),
],
),
),
],
),
),
// 右側工作皮膚
Expanded(
child: Container(
child: widget.workbench,
),
),
],
),
),
],
),
);
使用 NavigationRail
元件實現側邊導航功能。該元件支援自定義頭部和尾部元件。
@override
Widget build(BuildContext context) {
return Container(
width: 54.0,
decoration: const BoxDecoration(
color: Color(0xFF2E2E2E),
),
child: NavigationRail(
backgroundColor: Colors.transparent,
labelType: NavigationRailLabelType.none, // all 顯示圖示+標籤 selected 只顯示啟用圖示+標籤 none 不顯示標籤
indicatorColor: Colors.transparent, // 去掉選中橢圓背景
indicatorShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(0.0),
),
unselectedIconTheme: const IconThemeData(color: Color(0xFF979797), size: 24.0),
selectedIconTheme: const IconThemeData(color: Color(0xFF07C160), size: 24.0,),
unselectedLabelTextStyle: const TextStyle(color: Color(0xFF979797),),
selectedLabelTextStyle: const TextStyle(color: Color(0xFF07C160),),
// 頭部(影像)
leading: GestureDetector(
onPanStart: (details) => {},
child: Container(
margin: const EdgeInsets.only(top: 30.0, bottom: 10.0),
child: InkWell(
child: Image.asset('assets/images/avatar/uimg1.jpg', height: 36.0, width: 36.0,),
onTapDown: (TapDownDetails details) {
cardDX = details.globalPosition.dx;
cardDY = details.globalPosition.dy;
},
onTap: () {
showCardDialog(context);
},
),
),
),
// 尾部(連結)
trailing: Expanded(
child: Container(
margin: const EdgeInsets.only(bottom: 10.0),
child: GestureDetector(
onPanStart: (details) => {},
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton(icon: Icon(Icons.info_outline, color: Color(0xFF979797), size: 24.0), onPressed:(){showAboutDialog(context);}),
PopupMenuButton(
icon: const Icon(Icons.menu, color: Color(0xFF979797), size: 24.0,),
offset: const Offset(54.0, 0.0),
tooltip: '',
color: const Color(0xFF353535),
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0.0)),
padding: EdgeInsets.zero,
itemBuilder: (BuildContext context) {
return <PopupMenuItem>[
popupMenuItem('我的私密空間', 0),
popupMenuItem('鎖定', 1),
popupMenuItem('意見反饋', 2),
popupMenuItem('設定', 3),
];
},
onSelected: (value) {
switch(value) {
case 0:
Get.toNamed('/my');
break;
case 3:
Get.toNamed('/setting');
break;
}
},
),
],
),
),
),
),
selectedIndex: tabCur,
destinations: [
...tabNavs
],
onDestinationSelected: (index) {
setState(() {
tabCur = index;
if(tabRoute[index] != null && tabRoute[index]?['path'] != null) {
Get.toNamed(tabRoute[index]['path']);
}
});
},
),
);
}
flutter3仿微信朋友圈
@override
Widget build(BuildContext context) {
return Layout(
showSidebar: false,
workbench: CustomScrollView(
slivers: [
SliverAppBar(
backgroundColor: const Color(0xFF224E7F),
foregroundColor: Colors.white,
pinned: true,
elevation: 0.0,
expandedHeight: 200.0,
leading: IconButton(icon: const Icon(Icons.arrow_back,), onPressed: () {Navigator.pop(context);}),
flexibleSpace: FlexibleSpaceBar(
title: Row(
children: <Widget>[
ClipOval(child: Image.asset('assets/images/avatar/uimg1.jpg',height: 36.0,width: 36.0,fit: BoxFit.fill)),
const SizedBox(width: 10.0),
const Text('Andy', style: TextStyle(fontSize: 14.0)),
],
),
titlePadding: const EdgeInsets.fromLTRB(55, 10, 10, 10),
background: InkWell(
child: Image.asset('assets/images/cover.jpg', fit: BoxFit.cover),
onTap: () {changePhotoAlbum(context);},
),
),
actions: <Widget>[
IconButton(icon: const Icon(Icons.favorite_border, size: 18,), onPressed: () {}),
IconButton(icon: const Icon(Icons.share, size: 18,), onPressed: () {}),
IconButton(icon: const Icon(Icons.add_a_photo, size: 18,), onPressed: () {Get.toNamed('/publish');}),
const SizedBox(width: 10.0,),
],
),
SliverToBoxAdapter(
child: UnconstrainedBox(
child: Container(
width: MediaQuery.of(context).size.height * 3 / 4,
decoration: const BoxDecoration(
color: Colors.white,
),
child: Column(
children: uzoneList.map((item) {
return Container(
padding: const EdgeInsets.all(15.0),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: Color(0xFFEEEEEE), width: .5)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Image.asset(item['avatar'],height: 35.0,width: 35.0,fit: BoxFit.cover),
const SizedBox(width: 10.0),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(item['author'], style: TextStyle(color: Colors.indigo[400])),
const SizedBox(height: 2.0),
Text(item['content'], style: const TextStyle(color: Colors.black87, fontSize: 15.0)),
const SizedBox(height: 10.0),
GroupZone(images: item['images']),
const SizedBox(height: 10.0),
Row(
children: <Widget>[
Expanded(child: Text(item['time'], style: const TextStyle(color: Colors.grey, fontSize: 12.0)),),
FStyle.iconfont(0xe653, color: Colors.black54, size: 16.0,),
],
)
],
),
),
],
),
);
}).toList(),
),
),
),
),
],
),
);
}
flutter3短影片模板
短影片模板支援點選播放/暫停,底部mini播放進度條。
// flutter3影片播放模組 Q:282310962
Container(
width: MediaQuery.of(context).size.height * 9 / 16,
decoration: const BoxDecoration(
color: Colors.black,
),
child: Stack(
children: [
// Swiper垂直滾動區域
PageView(
// 自定義滾動行為(支援桌面端滑動、去掉捲軸槽)
scrollBehavior: SwiperScrollBehavior().copyWith(scrollbars: false),
scrollDirection: Axis.vertical,
controller: pageController,
onPageChanged: (index) {
// 暫停(垂直滑動)
controller.player.pause();
},
children: [
Stack(
children: [
// 影片區域
Positioned(
top: 0,
left: 0,
right: 0,
bottom: 0,
child: GestureDetector(
child: Stack(
children: [
// 短影片外掛
Video(
controller: controller,
fit: BoxFit.cover,
// 無控制條
controls: NoVideoControls,
),
// 播放/暫停按鈕
Center(
child: IconButton(
onPressed: () {
controller.player.playOrPause();
},
icon: StreamBuilder(
stream: controller.player.stream.playing,
builder: (context, playing) {
return Visibility(
visible: playing.data == false,
child: Icon(
playing.data == true ? Icons.pause : Icons.play_arrow_rounded,
color: Colors.white70,
size: 50,
),
);
},
),
),
),
],
),
onTap: () {
controller.player.playOrPause();
},
),
),
// 右側操作欄
Positioned(
bottom: 70.0,
right: 10.0,
child: Column(
children: [
// ...
],
),
),
// 底部資訊區域
Positioned(
bottom: 30.0,
left: 15.0,
right: 80.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ...
],
),
),
// 播放mini進度條
Positioned(
bottom: 15.0,
left: 15.0,
right: 15.0,
child: Container(
// ...
),
),
],
),
Container(
color: Colors.black,
child: const Center(child: Text('1', style: TextStyle(color: Colors.white, fontSize: 60),),)
),
Container(
color: Colors.black,
child: const Center(child: Text('2', style: TextStyle(color: Colors.white, fontSize: 60),),)
),
Container(
color: Colors.black,
child: const Center(child: Text('3', style: TextStyle(color: Colors.white, fontSize: 60),),)
),
],
),
// 固定tab選單
Align(
alignment: Alignment.topCenter,
child: DefaultTabController(
length: 3,
child: TabBar(
tabs: const [
Tab(text: '推薦'),
Tab(text: '關注'),
Tab(text: '同城'),
],
tabAlignment: TabAlignment.center,
overlayColor: MaterialStateProperty.all(Colors.transparent),
unselectedLabelColor: Colors.white70,
labelColor: const Color(0xff0091ea),
indicatorColor: const Color(0xff0091ea),
indicatorSize: TabBarIndicatorSize.label,
dividerHeight: 0,
indicatorPadding: const EdgeInsets.all(5),
),
),
),
],
),
),
flutter3聊天模板
聊天模組表情/選擇彈窗採用了dialog展示方式。
// 表情彈窗
void showEmojDialog() {
updateAnchorOffset(anchorEmojKey);
showDialog(
context: context,
barrierColor: Colors.transparent, // 遮罩透明
builder: (context) {
// 解決flutter透過 setState 方法無法更新當前的dialog狀態
// dialog是一個路由頁面,本質跟你當前主頁面是一樣的。在Flutter中它是一個新的路由。所以,你使用當前頁面的 setState 方法當然是沒法更新dialog中內容。
// 如何更新dialog中的內容呢?答案是使用StatefulBuilder。
return StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
setEmojState = setState;
return Stack(
children: [
Positioned(
top: anchorDy - (anchorDy - 100) - 15,
left: anchorDx - 180,
width: 360.0,
height: anchorDy - 100,
child: Material(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
color: Colors.white,
elevation: 1.0,
clipBehavior: Clip.hardEdge,
child: Column(
children: renderEmojWidget(),
),
),
)
],
);
},
);
},
);
}
透過 setState 方法無法更新當前的dialog狀態
showDialog本質上是另一個路由頁面,它的性質跟你當前主頁面是一樣的。在Flutter中它是一個新的路由。所以,你使用當前頁面的 setState 方法當然是沒法更新dialog中內容。如何更新dialog中的內容呢?當然是使用StatefulBuilder
元件。
late StateSetter setEmojState;
// 表情Tab切換
void handleEmojTab(index) {
var emols = emoJson;
for(var i = 0, len = emols.length; i < len; i++) {
emols[i]['selected'] = false;
}
emols[index]['selected'] = true;
setEmojState(() {
emoJson = emols;
});
emojController.jumpTo(0);
}
整個專案涵蓋的flutter知識點還蠻多的,限於篇幅,不都一一詳細介紹了。
希望以上的分享能給大家些許的幫助哈~~
https://segmentfault.com/a/1190000044624387
https://segmentfault.com/a/1190000044519351