2025-12-29 First Commit

This commit is contained in:
DATAEXPRESS\4734
2025-12-29 15:20:40 +08:00
commit fb2603c6f0
92 changed files with 6006 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+33
View File
@@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "f5a8537f90d143abd5bb2f658fa69c388da9677b"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b
base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b
- platform: android
create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b
base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b
- platform: web
create_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b
base_revision: f5a8537f90d143abd5bb2f658fa69c388da9677b
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+16
View File
@@ -0,0 +1,16 @@
# learn
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
+28
View File
@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+44
View File
@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.learn"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.learn"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+47
View File
@@ -0,0 +1,47 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="learn"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.example.learn
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.gex.com.tw</domain>
<trust-anchors>
<certificates src="system"/>
</trust-anchors>
</domain-config>
</network-security-config>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

@@ -0,0 +1,18 @@
import './announcement_model.dart';
// 引入剛剛建立的共用服務 (請依實際路徑調整)
import '../services/generic_api_service.dart';
class AnnouncementApiService {
// 實例化共用服務
final GenericApiService _apiService = GenericApiService();
Future<List<Announcement>> fetchAnnouncements() async {
return await _apiService.fetchList<Announcement>(
tableName: "eipbbs_m",
pk: "uniqueno",
queryFilter: "1^10^uniqueno^*^^pmsm02^^",
// 將 Announcement 的轉換方法傳進去
fromJson: (json) => Announcement.fromJson(json),
);
}
}
@@ -0,0 +1,81 @@
import 'package:http/http.dart' as http;
import 'dart:convert';
import './announcement_model.dart';
import '../auth_manager.dart'; // 確保引入 AuthManager
// API 相關常數 (假設與登入 API 使用相同的 Base URL 結構)
const String BASE_IP = "https://api.gex.com.tw:8033";
// **請替換成您實際獲取公告列表的 API 路徑**
const String ANNOUNCEMENT_LIST_ENDPOINT = "xapi/v2/eis_demo/orm_api/3/";
const String ANNOUNCEMENT_LIST_URL = "$BASE_IP/$ANNOUNCEMENT_LIST_ENDPOINT";
class AnnouncementApiService {
Future<List<Announcement>> fetchAnnouncements() async {
// 1. 讀取儲存的 Token
String? token = await AuthManager.getToken();
if (token == null) {
print("錯誤:未找到登入 Token,請重新登入!");
// 這裡通常會導航回登入頁面
// return;
}
// 建立 API 參數 (假設獲取公告列表需要類似的 token/身份驗證參數)
final Map<String, String> params = {
"token": '$token',
//"page": "${page}",
//"perPage": "${perPage}",
//"keywords": "${keywords}",
"_\$_tableName": "eipbbs_m",
"_\$_pk": "uniqueno",
"_\$_action": "P",
"_\$_query_filter": "1^10^uniqueno^*^^pmsm02^^"
};
try {
final response = await http.post(
Uri.parse(ANNOUNCEMENT_LIST_URL),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
);
// 檢查狀態碼
if (response.statusCode == 200) {
// 解析 API 回應
final Map<String, dynamic> responseData = json.decode(response.body);
// 1. 檢查 code 是否為 0
if (responseData['code'] == 0) {
// 2. 根據截圖,'data' 是一個 Map,裡面包著 'items'
final dynamic dataObject = responseData['data'];
// 檢查 dataObject 是否為 Map,且裡面是否有 'items' 並且是 List
if (dataObject is Map && dataObject['items'] is List) {
// 3. 取得真正的列表 'items'
List<dynamic> listJson = dataObject['items'];
// 將 JSON 列表轉換為 Announcement 物件列表
return listJson.map((json) => Announcement.fromJson(json)).toList();
} else {
// 雖然 code=0,但資料結構不符合預期 (例如 data 是 null 或沒有 items)
return []; // 或拋出異常,視您的需求而定
}
} else {
// API 邏輯失敗 (例如 code 不為 0)
throw Exception(responseData['msg'] ?? 'API 呼叫失敗,但連線成功。');
}
} else {
// HTTP 錯誤
throw Exception('HTTP 錯誤: ${response.statusCode}');
}
} catch (e) {
// 網路或解析錯誤
print('公告獲取錯誤: $e');
throw Exception('無法載入公告列表,請檢查網路。');
}
}
}
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import './announcement_model.dart';
// 引入 url_launcher 來處理附件 (可選,需要 pubspec.yaml 增加 url_launcher 套件)
// import 'package:url_launcher/url_launcher.dart';
class AnnouncementDetail extends StatelessWidget {
final Announcement announcement;
const AnnouncementDetail({required this.announcement, super.key});
// 處理附件下載的函數 (需要 url_launcher 套件)
/*
void _launchAttachment(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
// 處理錯誤
print('無法開啟附件連結: $url');
}
}
*/
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(announcement.title, overflow: TextOverflow.ellipsis),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 標題
Text(
announcement.title,
style: const TextStyle(
fontSize: 24.0,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// 資訊列
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('發布日期: ${announcement.formattedBillDate}',
style: const TextStyle(color: Colors.grey, fontSize: 14)),
Text('作者: ${announcement.createdBy}',
style: const TextStyle(color: Colors.grey, fontSize: 14)),
],
),
const Divider(height: 32.0),
// 附件按鈕 (如果有)
if (announcement.attachment != null && announcement.attachment!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: TextButton.icon(
icon: const Icon(Icons.attach_file),
label: const Text('查看附件'),
onPressed: () {
// TODO: 實作附件下載/開啟邏輯
// _launchAttachment(announcement.attachment!);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('附件功能待實作。')),
);
},
),
),
// 公告詳細內容 (使用 Text 顯示)
Text(
announcement.description,
style: const TextStyle(
fontSize: 16.0,
height: 1.5,
),
textAlign: TextAlign.justify,
),
],
),
),
);
}
}
@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import './announcement_api.dart';
import './announcement_model.dart';
import './announcement_detail.dart'; // 稍後創建
class AnnouncementManager extends StatefulWidget {
const AnnouncementManager({super.key});
@override
State<StatefulWidget> createState() {
return _AnnouncementManagerState();
}
}
class _AnnouncementManagerState extends State<AnnouncementManager> {
final AnnouncementApiService _apiService = AnnouncementApiService();
late Future<List<Announcement>> _announcementsFuture;
@override
void initState() {
super.initState();
// 頁面加載時自動開始獲取資料
_announcementsFuture = _apiService.fetchAnnouncements();
}
// 刷新資料的函數
void _refreshAnnouncements() {
setState(() {
_announcementsFuture = _apiService.fetchAnnouncements();
});
}
// 導航回主頁(與您 NewsManager 中的邏輯相似)
void _backToHome() {
// 假設 MainMenu 在 main.dart 中
// import './main.dart';
// Navigator.pushAndRemoveUntil(context, MaterialPageRoute(builder: (context) => const MainMenu()), (route) => false,);
// 在這裡我們假設它作為子頁面,使用 pop 即可返回
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('企業公告'),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.refresh),
tooltip: '刷新列表',
onPressed: _refreshAnnouncements,
),
IconButton(
icon: const Icon(Icons.home),
tooltip: '返回主頁',
onPressed: _backToHome,
),
],
),
body: FutureBuilder<List<Announcement>>(
future: _announcementsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
// 資料載入中
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
// 資料載入失敗 (顯示錯誤訊息)
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('載入失敗: ${snapshot.error}', textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(onPressed: _refreshAnnouncements, child: const Text('重試')),
],
),
);
} else if (snapshot.hasData && snapshot.data!.isNotEmpty) {
// 資料載入成功 (顯示列表)
return AnnouncementList(announcements: snapshot.data!);
} else {
// 沒有資料
return const Center(child: Text('目前沒有企業公告。'));
}
},
),
);
}
}
// -----------------------------------------------------------
// 列表顯示小部件 (取代原有的 News 類別)
// -----------------------------------------------------------
class AnnouncementList extends StatelessWidget {
final List<Announcement> announcements;
const AnnouncementList({required this.announcements, super.key});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: announcements.length,
itemBuilder: (BuildContext context, int index) {
final item = announcements[index];
return Card(
elevation: 4,
margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: InkWell(
onTap: () {
// 點擊項目:導航到詳細頁面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AnnouncementDetail(announcement: item),
),
);
},
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 公告標題
Text(
item.title,
style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
// 發布日期和作者
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.calendar_today, size: 14, color: Colors.grey),
const SizedBox(width: 4),
Text(
item.formattedBillDate,
style: const TextStyle(fontSize: 14.0, color: Colors.grey),
),
],
),
Row(
children: [
const Icon(Icons.person, size: 14, color: Colors.grey),
const SizedBox(width: 4),
Text(
item.createdBy,
style: const TextStyle(fontSize: 14.0, color: Colors.grey),
),
],
),
],
),
],
),
),
),
);
},
);
}
}
@@ -0,0 +1,51 @@
import 'package:intl/intl.dart';
class Announcement {
final int uniqueNo;
final String title; // bbs_title
final String description; // bbs_desc (詳細內容)
final String createdBy; // create_user (作者)
final DateTime billDate; // billdate (發布日期)
final DateTime startDate; // startdate
final DateTime endDate; // end_date
final String? attachment; // bbs_attach
Announcement({
required this.uniqueNo,
required this.title,
required this.description,
required this.createdBy,
required this.billDate,
required this.startDate,
required this.endDate,
this.attachment,
});
// Factory 構造函數:從 API 返回的 JSON (Map) 創建 Announcement 物件
factory Announcement.fromJson(Map<String, dynamic> json) {
// 輔助函數:安全解析日期字串
DateTime? parseDate(dynamic date) {
if (date is String && date.isNotEmpty) {
// 假設日期格式為 YYYY-MM-DD HH:mm:ss.sss
return DateTime.tryParse(date);
}
return DateTime.now(); // 預設為當前時間或您可以選擇 null
}
return Announcement(
uniqueNo: json['uniqueno'] as int? ?? 0,
title: json['bbs_title'] as String? ?? '無標題',
description: json['bbs_desc'] as String? ?? '無詳細內容',
createdBy: json['create_user'] as String? ?? '未知作者',
billDate: parseDate(json['billdate'])!,
startDate: parseDate(json['startdate'])!,
endDate: parseDate(json['end_date'])!,
attachment: json['bbs_attach'] as String?,
);
}
// 格式化日期,用於列表顯示
String get formattedBillDate {
return DateFormat('yyyy/MM/dd HH:mm').format(billDate);
}
}
+66
View File
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
// 1. 確保引入 news_detail.dart
import '../News/news_detail.dart';
class News extends StatelessWidget {
final List<String> news;
// 構造函數保持不變
const News(this.news, {super.key});
@override
Widget build(BuildContext context) {
// 使用 ListView.builder 替代 Column 和 map().toList()
// 這樣可以確保內容可以捲動,並且只構建可見的項目,效率更高。
return ListView.builder(
// 設置列表的長度,即 news 列表的項目數
itemCount: news.length,
// 根據索引來構建每個列表項目
itemBuilder: (BuildContext context, int index) {
final String currentNewsTitle = news[index]; // 提取當前新聞標題
// 使用 news[index] 來取得對應的新聞文本
return Card(
elevation: 5, // 可選: 增加卡片陰影
margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), // 增加邊距
// 在這裡可以選擇添加 Key
key: ValueKey(currentNewsTitle + index.toString()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, // 讓圖片和文字可以寬度延伸
children: <Widget>[
// --- 點擊功能的修改在這裡 ---
GestureDetector(
onTap: () {
// 點擊事件:導航到 NewsDetail 畫面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NewsDetail(
// 2. 將當前新聞的標題/內容傳遞給詳細頁面
newsTitle: currentNewsTitle,
),
),
);
},
child: Image.asset(
'assets/F16-02.jpg',
fit: BoxFit.cover,
height: 200,
),
),
// --------------------------
Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
currentNewsTitle,
style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold),
),
),
],
),
);
},
);
}
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
// 這是一個 StatelessWidget,用於顯示單條新聞的詳細內容
class NewsDetail extends StatelessWidget {
final String newsTitle; // 接收從前一個畫面傳遞過來的新聞標題
// 為了演示,我們假設詳細內容是一個較長的文本
final String newsDetailContent =
"這是新聞的詳細內容。它包含了更深入的分析、更多的數據和完整的背景資訊。點擊圖片後,使用者可以在這個頁面仔細閱讀。由於這是一個範例,我們使用了一些重複的文本來模擬文章長度,以確保頁面可以滾動。在這個頁面,您可以加入更多的元素,例如不同的圖片、作者資訊、發布日期等。";
const NewsDetail({required this.newsTitle, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
// 設置 App Bar,標題可以是傳入的新聞標題
appBar: AppBar(
title: Text(newsTitle, overflow: TextOverflow.ellipsis), // 防止標題過長
),
// 使用 SingleChildScrollView 確保頁面內容超出螢幕時可以滾動
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 再次顯示圖片 (可選)
ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Image.asset(
'assets/F16-02.jpg',
fit: BoxFit.cover,
height: 250,
width: double.infinity, // 佔滿寬度
),
),
const SizedBox(height: 16.0),
// 顯示新聞標題
Text(
newsTitle,
style: const TextStyle(
fontSize: 24.0,
fontWeight: FontWeight.w800,
),
),
const Divider(height: 32.0), // 分隔線
// 顯示新聞詳細內容
Text(
newsDetailContent * 5, // 重複內容 5 次來模擬長文章
style: const TextStyle(
fontSize: 16.0,
height: 1.5, // 增加行高,改善閱讀體驗
),
textAlign: TextAlign.justify, // 內容兩端對齊
),
],
),
),
),
);
}
}
+87
View File
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
// 確保你的 news.dart 文件和 News 類已經被正確引入
import 'news.dart';
// 確保引入 MainMenu 所在的 main.dart 文件,以便導航
import '../main.dart';
class NewsManager extends StatefulWidget {
const NewsManager({super.key});
@override
State<StatefulWidget> createState() {
return _NewsManagerState();
}
}
class _NewsManagerState extends State<NewsManager> {
List<String> news = ['第一筆資料: F-16 戰機升級計畫', '第二筆資料: 台積電研發新突破'];
void _addNews() {
setState(() {
news.add('新增資料:最新動態 ${DateTime.now().second}');
});
}
// 新增的導航函數:返回到主選單
void _backToHome() {
// Navigator.popUntil 會一直彈出 (Pop) 路由堆棧中的頁面,直到遇到一個
// 滿足條件 (predicte) 的路由。在這裡,我們使用 (route) => route.isFirst
// 來返回到路由堆棧中的第一個頁面,也就是 MainMenu (如果您的啟動順序是 Login -> MainMenu)。
// 如果 MainMenu 不是第一個頁面,您可以考慮使用:
// Navigator.popUntil(context, ModalRoute.withName('/main_menu'));
// 但這需要您在 MaterialApp 中為 MainMenu 設定路由名稱。
// 最保險且簡單的方法是直接彈出所有,然後重新推入 MainMenu(確保 MainMenu 不會有歷史記錄)
// 或者我們假設 MainMenu 是 Login 成功後的第一個頁面,使用 pushReplacement 清理過的堆棧。
// 在本例中,我們直接導航回 MainMenu,並清除所有在它之上的頁面。
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (context) => const MainMenu()),
(Route<dynamic> route) => false, // 移除所有前面的路由
);
// 註:若 NewsManager 已經是 Scaffold 的子頁面,使用 Navigator.pop(context)
// 即可返回上一個頁面(即 MainMenu)。但如果想確保是回主頁,用 pushAndRemoveUntil 更明確。
}
@override
Widget build(BuildContext context) {
// *** 關鍵修改:將整個頁面包裹在 Scaffold 中以包含 AppBar ***
return Scaffold(
appBar: AppBar(
title: const Text('公告事項'),
// ----------------------------------------------------
// 新增的返回主頁按鈕
actions: <Widget>[
IconButton(
icon: const Icon(Icons.home), // 使用一個 Home 圖標
tooltip: '返回主頁', // 長按時的提示
onPressed: _backToHome, // 點擊時呼叫導航函數
),
],
// ----------------------------------------------------
),
body: Column(
children: <Widget>[
// 1. 固定高度的按鈕部分
Container(
margin: const EdgeInsets.all(10),
child: ElevatedButton(
onPressed: _addNews,
child: const Text(
'新增資料',
style: TextStyle(fontSize: 20, color: Colors.redAccent),
),
),
),
// 2. 佔據剩餘空間的列表部分
Expanded(
child: News(news),
),
],
),
);
}
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:shared_preferences/shared_preferences.dart';
class AuthManager {
// 定義鍵值常數,避免硬編碼字串出錯
static const String _tokenKey = 'auth_token';
static const String _userIdKey = 'user_id'; // [新增] 用於儲存工號的鍵值
/// 儲存登入資訊:包含 Token 與工號
/// 當登入成功時,同時呼叫此方法
static Future<void> saveLoginInfo(String token, String userId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
await prefs.setString(_userIdKey, userId);
print('Login info saved: UserID=$userId');
}
/// 讀取儲存的工號 (userid)
/// 如果沒找到則回傳 null,可用於判斷是否需重新登入
static Future<String?> getUserId() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_userIdKey);
}
/// 讀取儲存的 Token
static Future<String?> getToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_tokenKey);
}
/// 移除所有登入資訊 (用於登出)
/// 確保 Token 與工號同步清除,保障資安
static Future<void> logout() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
await prefs.remove(_userIdKey);
print('User logged out, auth info cleared.');
}
/// 檢查是否已登入 (Token 與 UserID 皆存在)
static Future<bool> isLoggedIn() async {
final token = await getToken();
final userId = await getUserId();
return token != null && userId != null;
}
}
+39
View File
@@ -0,0 +1,39 @@
import './calendar_model.dart';
import '../services/generic_api_service.dart';
import 'package:intl/intl.dart';
class CalendarApiService {
final GenericApiService _apiService = GenericApiService();
// 獲取指定月份或範圍的行程
Future<List<CalendarEvent>> fetchEvents(String userId, DateTime month) async {
// 企業實作建議:僅抓取特定月份資料,減少手機負載
final sDate = DateTime(month.year, month.month, 1);
final eDate = DateTime(month.year, month.month + 1, 0);
String queryFilter = "1^500^start_time^*^personid^=^$userId";
return await _apiService.fetchList<CalendarEvent>(
tableName: "eip_new_calendar",
pk: "uuid",
queryFilter: queryFilter,
fromJson: (json) => CalendarEvent.fromJson(json),
);
}
// 新增/更新行程
Future<bool> saveEvent(CalendarEvent event) async {
final data = {
"uuid": event.uuid,
"title": event.title,
"cal_description": event.description,
"start_time": event.startTime?.toIso8601String(),
"end_time": event.endTime?.toIso8601String(),
"personid": event.personId,
"cal_finished": event.isFinished ? 'Y' : 'N',
"update_date": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
};
// return await _apiService.saveData("eip_new_calendar", data);
return true;
}
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import './calendar_model.dart';
class CalendarDetail extends StatelessWidget {
final CalendarEvent event;
const CalendarDetail({required this.event, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('行程詳情')),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
Row(
children: [
CircleAvatar(backgroundColor: event.levelColor, radius: 8),
const SizedBox(width: 12),
Expanded(
child: Text(event.title, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
),
],
),
const Divider(height: 40),
_buildDetailItem(Icons.access_time, '時間範圍', event.timeRangeText),
_buildDetailItem(Icons.repeat, '重複設定', event.repeatedFlag ?? '無重複'),
_buildDetailItem(Icons.folder_open, '關聯專案', event.projectId ?? '無相關專案'),
_buildDetailItem(Icons.category_outlined, '類型', event.type ?? '一般行程'),
const SizedBox(height: 24),
const Text('行程描述', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8)),
child: Text(event.description ?? '無詳細描述內容', style: const TextStyle(height: 1.5)),
),
],
),
);
}
Widget _buildDetailItem(IconData icon, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Row(
children: [
Icon(icon, color: Colors.blueGrey, size: 20),
const SizedBox(width: 15),
Text(label, style: const TextStyle(color: Colors.grey)),
const Spacer(),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
),
);
}
}
+169
View File
@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:table_calendar/table_calendar.dart'; // 建議引入此套件
import 'package:intl/intl.dart';
import './calendar_model.dart';
import './calendar_api.dart';
import './calendar_detail.dart';
class CalendarManager extends StatefulWidget {
final String userId;
const CalendarManager({required this.userId, super.key});
@override
State<CalendarManager> createState() => _CalendarManagerState();
}
class _CalendarManagerState extends State<CalendarManager> {
final CalendarApiService _apiService = CalendarApiService();
// 狀態控制
CalendarFormat _calendarFormat = CalendarFormat.month;
DateTime _focusedDay = DateTime.now();
DateTime? _selectedDay;
// 資料儲存:將事件按日期分類,方便日曆標點
Map<DateTime, List<CalendarEvent>> _eventsMap = {};
bool _isLoading = false;
@override
void initState() {
super.initState();
_selectedDay = _focusedDay;
_fetchMonthEvents(_focusedDay);
}
// 核心:抓取整個月的資料並進行分類
Future<void> _fetchMonthEvents(DateTime month) async {
setState(() => _isLoading = true);
try {
final events = await _apiService.fetchEvents(widget.userId, month);
// 將 List 轉換為 Map<DateTime, List<Event>>
Map<DateTime, List<CalendarEvent>> newMap = {};
for (var event in events) {
if (event.startTime != null) {
// 只取日期部分作為 Key
final dateKey = DateTime(event.startTime!.year, event.startTime!.month, event.startTime!.day);
if (newMap[dateKey] == null) newMap[dateKey] = [];
newMap[dateKey]!.add(event);
}
}
setState(() {
_eventsMap = newMap;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("載入失敗: $e")));
}
}
// 獲取選定日期的行程
List<CalendarEvent> _getEventsForDay(DateTime day) {
final dateKey = DateTime(day.year, day.month, day.day);
return _eventsMap[dateKey] ?? [];
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('企業行事曆'),
actions: [
IconButton(icon: const Icon(Icons.today), onPressed: () => setState(() => _focusedDay = DateTime.now())),
],
),
body: Column(
children: [
// 1. 日曆組件
TableCalendar<CalendarEvent>(
firstDay: DateTime.utc(2020, 1, 1),
lastDay: DateTime.utc(2030, 12, 31),
focusedDay: _focusedDay,
calendarFormat: _calendarFormat,
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
eventLoader: _getEventsForDay, // 在有行程的日期顯示小點點
// 樣式設定
calendarStyle: CalendarStyle(
todayDecoration: BoxDecoration(color: Colors.blue.withOpacity(0.5), shape: BoxShape.circle),
selectedDecoration: const BoxDecoration(color: Colors.blue, shape: BoxShape.circle),
markerDecoration: const BoxDecoration(color: Colors.orange, shape: BoxShape.circle),
),
// 互動事件
onDaySelected: (selectedDay, focusedDay) {
setState(() {
_selectedDay = selectedDay;
_focusedDay = focusedDay;
});
},
onFormatChanged: (format) {
setState(() => _calendarFormat = format);
},
onPageChanged: (focusedDay) {
_focusedDay = focusedDay;
_fetchMonthEvents(focusedDay); // 切換月份時自動重新抓取 API
},
),
const Divider(height: 1),
// 2. 下方行程列表
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _buildEventList(),
),
],
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () { /* 實重新增行程邏輯 */ },
),
);
}
Widget _buildEventList() {
final dayEvents = _getEventsForDay(_selectedDay!);
if (dayEvents.isEmpty) {
return Center(
child: Text(
"${DateFormat('MM/dd').format(_selectedDay!)} 沒有行程",
style: const TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: dayEvents.length,
itemBuilder: (context, index) {
final event = dayEvents[index];
return Card(
elevation: 2,
margin: const EdgeInsets.only(bottom: 10),
child: ListTile(
leading: Container(
width: 4,
height: 40,
decoration: BoxDecoration(
color: event.levelColor,
borderRadius: BorderRadius.circular(2),
),
),
title: Text(event.title, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(event.timeRangeText),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => CalendarDetail(event: event))
),
),
);
},
);
}
}
+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class CalendarEvent {
final String uuid;
final String title;
final String? description;
final DateTime? startTime;
final DateTime? endTime;
final String? type; // cal_type
final String? level; // cal_level (重要程度)
final String? personId;
final bool isFinished; // cal_finished: 'Y' or 'N'
final String? repeatedFlag; // cal_repeated_flag
final String? projectId;
CalendarEvent({
required this.uuid,
required this.title,
this.description,
this.startTime,
this.endTime,
this.type,
this.level,
this.personId,
this.isFinished = false,
this.repeatedFlag,
this.projectId,
});
factory CalendarEvent.fromJson(Map<String, dynamic> json) {
return CalendarEvent(
uuid: json['uuid'] as String,
title: json['title'] as String? ?? '未命名行程',
description: json['cal_description'] as String?,
startTime: json['start_time'] != null ? DateTime.tryParse(json['start_time']) : null,
endTime: json['end_time'] != null ? DateTime.tryParse(json['end_time']) : null,
type: json['cal_type'] as String?,
level: json['cal_level'] as String?,
personId: json['personid'] as String?,
isFinished: json['cal_finished'] == 'Y',
repeatedFlag: json['cal_repeated_flag'] as String?,
projectId: json['projectid'] as String?,
);
}
// UI 輔助屬性
Color get levelColor {
switch (level?.toUpperCase()) {
case 'URGENT': return Colors.red;
case 'HIGH': return Colors.orange;
case 'NORMAL': return Colors.blue;
default: return Colors.grey;
}
}
String get timeRangeText {
if (startTime == null) return "未定時";
final df = DateFormat('HH:mm');
return "${df.format(startTime!)}${endTime != null ? ' - ${df.format(endTime!)}' : ''}";
}
}
+48
View File
@@ -0,0 +1,48 @@
import './clock_in_model.dart';
import '../services/generic_api_service.dart';
import 'package:intl/intl.dart';
class ClockInApiService {
final GenericApiService _apiService = GenericApiService();
// 獲取個人打卡歷史 (預設按時間降冪)
Future<List<ClockInRecord>> fetchHistory(String userId) async {
String queryFilter = "1^100^ClockInDateTime^*^ClockInUserId^=^$userId";
return await _apiService.fetchList<ClockInRecord>(
tableName: "hrs_ClockInRecord",
pk: "ClockInId",
queryFilter: queryFilter,
fromJson: (json) => ClockInRecord.fromJson(json),
);
}
// 新增打卡紀錄 (POST)
Future<bool> postClockIn(ClockInRecord record) async {
// 假設後端有一個通用保存 API
final data = {
"ClockInUserId": record.userId,
"ClockInDateTime": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
"ClockInLatitude": record.latitude,
"ClockInLongitude": record.longitude,
"ClockInType": record.type,
"ClockInStoreId": record.storeId,
"CreatorId": record.userId,
"CreateDateTime": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
};
// 呼叫 API 並回傳結果
// return await _apiService.saveData("hrs_ClockInRecord", data);
return true; // 模擬成功
}
// 在 ClockInApiService 類別中新增:
Future<List<ClockInStore>> fetchStores() async {
return await _apiService.fetchList<ClockInStore>(
tableName: "hrs_ClockInStore",
pk: "StoreId",
queryFilter: "1^100^StoreId^*^^^Stat^Y", // 僅抓取啟用狀態為 Y 的店點
fromJson: (json) => ClockInStore.fromJson(json),
);
}
}
+256
View File
@@ -0,0 +1,256 @@
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'dart:async';
import './clock_in_model.dart';
import './clock_in_api.dart';
import 'package:intl/intl.dart';
class ClockInManager extends StatefulWidget {
final String userId;
const ClockInManager({required this.userId, super.key});
@override
State<ClockInManager> createState() => _ClockInManagerState();
}
class _ClockInManagerState extends State<ClockInManager> {
final ClockInApiService _apiService = ClockInApiService();
late Future<List<ClockInRecord>> _historyFuture;
// --- 新增:店點相關變數 ---
List<ClockInStore> _stores = [];
ClockInStore? _selectedStore;
bool _isLoadingStores = true;
Position? _currentPosition;
double _distanceInMeters = -1;
String _currentTime = "";
late Timer _timer;
@override
void initState() {
super.initState();
_initData();
_startClock();
}
// 初始化資料:先抓店點,再抓歷史紀錄
Future<void> _initData() async {
try {
final stores = await _apiService.fetchStores();
setState(() {
_stores = stores;
if (_stores.isNotEmpty) _selectedStore = _stores.first;
_isLoadingStores = false;
});
_refreshHistory();
_getCurrentLocation();
} catch (e) {
_showMsg("初始化店點失敗: $e");
}
}
void _startClock() {
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (mounted) setState(() => _currentTime = DateFormat('HH:mm:ss').format(DateTime.now()));
});
}
// 修改:根據「目前選中店點」計算距離
Future<void> _getCurrentLocation() async {
if (_selectedStore == null) return;
final position = await Geolocator.getCurrentPosition();
// 使用所選店點的經緯度進行計算
final distance = Geolocator.distanceBetween(
position.latitude,
position.longitude,
_selectedStore!.latitude,
_selectedStore!.longitude
);
if (mounted) {
setState(() {
_currentPosition = position;
_distanceInMeters = distance;
});
}
}
void _refreshHistory() {
final future = _apiService.fetchHistory(widget.userId);
setState(() => _historyFuture = future);
}
// 修改:打卡動作加入 StoreId 與動態距離判斷
Future<void> _handleClockIn(String type) async {
if (_selectedStore == null) {
_showMsg("錯誤:請先選擇打卡店點");
return;
}
await _getCurrentLocation();
// 使用資料表中的 Distance 欄位作為判斷標準
if (_distanceInMeters > _selectedStore!.distance || _distanceInMeters == -1) {
_showMsg("打卡失敗:距離 ${_selectedStore!.storeName} 已超過 ${_selectedStore!.distance} 公尺 (目前: ${_distanceInMeters.toInt()}m)");
return;
}
final newRecord = ClockInRecord(
userId: int.tryParse(widget.userId),
type: type,
latitude: _currentPosition?.latitude,
longitude: _currentPosition?.longitude,
storeId: _selectedStore!.storeId, // 使用動態 ID
);
bool success = await _apiService.postClockIn(newRecord);
if (success) {
_showMsg("[$type] 打卡成功!地點: ${_selectedStore!.storeName}");
_refreshHistory();
}
}
void _showMsg(String msg) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
// 動態判斷是否在選中店點的範圍內
bool isWithinRange = _selectedStore != null &&
_distanceInMeters != -1 &&
_distanceInMeters <= _selectedStore!.distance;
return Scaffold(
appBar: AppBar(title: const Text('員工行動打卡')),
body: _isLoadingStores
? const Center(child: CircularProgressIndicator())
: Column(
children: [
// 新增:店點選擇下拉選單區
_buildStoreSelector(),
_buildStatusCard(isWithinRange),
const Divider(height: 1),
// ... 歷史紀錄標題與清單部分維持不變 (同附件)
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('今日打卡紀錄', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
IconButton(onPressed: _refreshHistory, icon: const Icon(Icons.refresh)),
],
),
),
Expanded(
child: FutureBuilder<List<ClockInRecord>>(
future: _historyFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
if (!snapshot.hasData || snapshot.data!.isEmpty) return const Center(child: Text('查無紀錄'));
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (ctx, i) => _buildHistoryItem(snapshot.data![i]),
);
},
),
),
],
),
);
}
// 新增:店點選擇器 UI
Widget _buildStoreSelector() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
color: Colors.white,
child: DropdownButtonFormField<ClockInStore>(
decoration: const InputDecoration(
labelText: "選擇打卡店點",
prefixIcon: Icon(Icons.store),
border: OutlineInputBorder(),
),
value: _selectedStore,
items: _stores.map((s) => DropdownMenuItem(
value: s,
child: Text(s.storeName),
)).toList(),
onChanged: (val) {
setState(() {
_selectedStore = val;
_distanceInMeters = -1; // 切換時重置距離,等待下次定位
});
_getCurrentLocation(); // 切換後立即重新計算距離
},
),
);
}
// 修改:狀態卡片顯示
Widget _buildStatusCard(bool isWithinRange) {
return Container(
padding: const EdgeInsets.all(20),
color: Colors.blue.shade50,
child: Column(
children: [
Text(_currentTime, style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold, color: Colors.blue)),
const SizedBox(height: 10),
if (_selectedStore != null) ...[
Text(_selectedStore!.storeAddress ?? "", style: const TextStyle(color: Colors.blueGrey)),
const SizedBox(height: 5),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.location_on, color: isWithinRange ? Colors.green : Colors.red),
Text(
isWithinRange
? "已進入 ${_selectedStore!.storeName} 範圍"
: "距離過遠: ${_distanceInMeters.toInt()}m (限制: ${_selectedStore!.distance}m)",
style: TextStyle(color: isWithinRange ? Colors.green : Colors.red, fontWeight: FontWeight.bold),
),
],
),
],
const SizedBox(height: 20),
Row(
children: [
Expanded(child: _actionBtn("上班打卡", Colors.blue, () => _handleClockIn("上班"))),
const SizedBox(width: 15),
Expanded(child: _actionBtn("下班打卡", Colors.orange, () => _handleClockIn("下班"))),
],
)
],
),
);
}
// _actionBtn 與 _buildHistoryItem 維持與附件原程式相同...
Widget _actionBtn(String label, Color color, VoidCallback onPressed) {
return ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: color, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 15)),
onPressed: onPressed,
child: Text(label, style: const TextStyle(fontSize: 18)),
);
}
Widget _buildHistoryItem(ClockInRecord record) {
return ListTile(
leading: CircleAvatar(backgroundColor: record.typeColor, child: Text(record.type?[0] ?? '', style: const TextStyle(color: Colors.white))),
title: Text("${record.type} - ${record.formattedTime}"),
subtitle: Text(record.formattedDate),
trailing: const Icon(Icons.check_circle, color: Colors.green, size: 16),
);
}
}
+75
View File
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class ClockInRecord {
final int? clockInId;
final int? userId;
final DateTime? dateTime;
final double? latitude;
final double? longitude;
final String? type; // 上班/下班/加班...
final String? storeId;
ClockInRecord({
this.clockInId,
this.userId,
this.dateTime,
this.latitude,
this.longitude,
this.type,
this.storeId,
});
factory ClockInRecord.fromJson(Map<String, dynamic> json) {
return ClockInRecord(
clockInId: json['ClockInId'] as int?,
userId: json['ClockInUserId'] as int?,
dateTime: json['ClockInDateTime'] != null ? DateTime.tryParse(json['ClockInDateTime']) : null,
latitude: double.tryParse(json['ClockInLatitude']?.toString() ?? '0'),
longitude: double.tryParse(json['ClockInLongitude']?.toString() ?? '0'),
type: json['ClockInType'] as String?,
storeId: json['ClockInStoreId'] as String?,
);
}
// Helper: 格式化顯示時間
String get formattedTime => dateTime != null ? DateFormat('HH:mm:ss').format(dateTime!) : '--:--';
String get formattedDate => dateTime != null ? DateFormat('yyyy-MM-dd').format(dateTime!) : 'N/A';
// Helper: 根據打卡類型回傳顏色
Color get typeColor {
if (type == '上班') return Colors.blue;
if (type == '下班') return Colors.orange;
return Colors.grey;
}
}
class ClockInStore {
final String storeId;
final String storeName;
final String? storeAddress;
final double latitude;
final double longitude;
final int distance; // 允許打卡公尺數
ClockInStore({
required this.storeId,
required this.storeName,
this.storeAddress,
required this.latitude,
required this.longitude,
required this.distance,
});
factory ClockInStore.fromJson(Map<String, dynamic> json) {
return ClockInStore(
storeId: json['StoreId'] as String,
storeName: json['StoreName'] as String? ?? '',
storeAddress: json['StoreAddress'] as String?,
// 強制將 decimal/String 轉為 double
latitude: double.tryParse(json['StoreLatitude'].toString()) ?? 0.0,
longitude: double.tryParse(json['StoreLongitude'].toString()) ?? 0.0,
distance: int.tryParse(json['Distance'].toString()) ?? 100,
);
}
}
+26
View File
@@ -0,0 +1,26 @@
import './person_model.dart';
import '../services/generic_api_service.dart'; // 假設您有這個共用服務
class PersonApiService {
final GenericApiService _apiService = GenericApiService();
// 獲取所有人員列表,增加可選的 searchName 參數
Future<List<Person>> fetchPeople({String? searchName}) async {
// 預設參數:每頁 100 筆,按中文姓名排序
String filterPart = "";
// 如果提供了 searchName,則增加模糊查詢過濾條件
if (searchName != null && searchName.isNotEmpty) {
filterPart = "personcname^%$searchName%";
}
String v_queryFilter = "1^100^personid^*^^^$filterPart";
return await _apiService.fetchList<Person>(
tableName: "basperson", // 對應到 basperson 表格
pk: "personid", // 主鍵為 personid
queryFilter: v_queryFilter,
fromJson: (json) => Person.fromJson(json),
);
}
}
+157
View File
@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import './person_model.dart';
import 'package:url_launcher/url_launcher.dart'; // 用於撥打電話和發送郵件
class PersonDetail extends StatelessWidget {
final Person person;
const PersonDetail({required this.person, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(person.personCName ?? '人員詳細資訊'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
// 姓名與職稱
CircleAvatar(
radius: 50,
backgroundColor: person.sexColor.withOpacity(0.2),
child: Icon(person.sexIcon, size: 60, color: person.sexColor),
),
const SizedBox(height: 10),
Text(
person.personCName ?? 'N/A',
style: const TextStyle(fontSize: 28.0, fontWeight: FontWeight.bold, color: Colors.black87),
),
Text(
person.jobName ?? 'N/A',
style: const TextStyle(fontSize: 18.0, color: Colors.black54),
),
const Divider(height: 30.0, thickness: 1),
// 聯絡資訊列表
_buildInfoTile(
Icons.badge,
'工號 / ID',
person.personId,
),
_buildInfoTile(
Icons.business,
'部門代號',
person.departmentId ?? 'N/A',
),
_buildActionTile(
context,
Icons.phone,
'公司電話',
person.tel ?? 'N/A',
person.tel,
'tel:',
),
_buildActionTile(
context,
Icons.smartphone,
'手機號碼',
person.cellphone ?? 'N/A',
person.cellphone,
'tel:',
),
_buildActionTile(
context,
Icons.email,
'電子郵件',
person.email ?? 'N/A',
person.email,
'mailto:',
),
],
),
),
);
}
// 靜態資訊欄位
Widget _buildInfoTile(IconData icon, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Icon(icon, color: Colors.grey[700], size: 24),
const SizedBox(width: 15),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
Text(
value,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
],
),
),
],
),
);
}
// 可操作(點擊撥號/發送郵件)的欄位
Widget _buildActionTile(
BuildContext context, IconData icon, String label, String displayValue,
String? actionValue, String protocol) {
final canLaunch = actionValue != null && actionValue.isNotEmpty;
Future<void> _launchUrl() async {
if (canLaunch) {
final uri = Uri.parse('$protocol$actionValue');
if (!await launchUrl(uri)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('無法打開 $displayValue')),
);
}
}
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: InkWell(
onTap: canLaunch ? _launchUrl : null,
child: Row(
children: [
Icon(icon, color: canLaunch ? Colors.deepPurple : Colors.grey[700], size: 24),
const SizedBox(width: 15),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
Text(
displayValue,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: canLaunch ? Colors.deepPurple : Colors.black,
decoration: canLaunch ? TextDecoration.underline : TextDecoration.none,
),
),
],
),
),
],
),
),
);
}
}
+181
View File
@@ -0,0 +1,181 @@
import 'package:flutter/material.dart';
import './person_api.dart';
import './person_model.dart';
import './person_detail.dart'; // 稍後創建
class PersonManager extends StatefulWidget {
const PersonManager({super.key});
@override
State<StatefulWidget> createState() {
return _PersonManagerState();
}
}
class _PersonManagerState extends State<PersonManager> {
late PersonApiService _apiService;
late Future<List<Person>> _peopleFuture;
// 新增:搜尋文字控制器
final TextEditingController _searchController = TextEditingController();
// 新增:用於記錄當前的搜尋關鍵字
String _currentSearchTerm = '';
@override
void initState() {
super.initState();
_apiService = PersonApiService();
// 頁面加載時自動開始獲取資料
_peopleFuture = _apiService.fetchPeople();
// 新增:監聽搜尋框文字變更
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
// 釋放資源
_searchController.removeListener(_onSearchChanged);
_searchController.dispose();
super.dispose();
}
// 搜尋文字變更時觸發的邏輯
void _onSearchChanged() {
final newTerm = _searchController.text;
// 只有當關鍵字真正改變時才刷新
if (newTerm != _currentSearchTerm) {
_currentSearchTerm = newTerm;
_refreshPeople();
}
}
// 刷新資料的函數
void _refreshPeople() {
setState(() {
// 調用 API 時傳入目前的搜尋關鍵字
_peopleFuture = _apiService.fetchPeople(searchName: _currentSearchTerm);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('公司通訊錄'),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.refresh),
tooltip: '刷新列表',
onPressed: _refreshPeople,
),
],
// 新增:底部放置搜尋框
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60.0),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '輸入姓名進行查詢...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _currentSearchTerm.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear(); // 清空文字會觸發 _onSearchChanged
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10.0),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Colors.white,
),
),
),
),
),
body: FutureBuilder<List<Person>>(
future: _peopleFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('載入失敗: ${snapshot.error}', textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(onPressed: _refreshPeople, child: const Text('重試')),
],
),
);
} else if (snapshot.hasData && snapshot.data!.isNotEmpty) {
return PersonList(people: snapshot.data!);
} else {
return const Center(child: Text('查無人員資料。'));
}
},
),
);
}
}
// -----------------------------------------------------------
// 列表顯示小部件 (PersonList)
// -----------------------------------------------------------
class PersonList extends StatelessWidget {
final List<Person> people;
const PersonList({required this.people, super.key});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: people.length,
itemBuilder: (BuildContext context, int index) {
final person = people[index];
return Card(
elevation: 3,
margin: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 16.0),
child: InkWell(
onTap: () {
// 點擊項目:導航到詳細頁面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PersonDetail(person: person),
),
);
},
child: ListTile(
// 左側性別指示器
leading: Icon(
person.sexIcon,
color: person.sexColor,
size: 32,
),
title: Text(
person.personCName ?? '姓名未知',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'${person.departmentId ?? '無部門'} | ${person.jobName ?? '無職稱'}',
style: const TextStyle(fontSize: 12),
),
trailing: const Icon(Icons.arrow_forward_ios, size: 16, color: Colors.grey),
),
),
);
},
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
class Person {
final String personId; // personid
final String? personCName; // personcname (中文姓名)
final String? jobName; // jobname (職稱)
final String? departmentId; // departmentid (部門代號)
final String? tel; // tel (公司電話)
final String? cellphone; // cellphone (手機)
final String? email; // email (電子郵件)
final String? sex; // sex (性別)
Person({
required this.personId,
this.personCName,
this.jobName,
this.departmentId,
this.tel,
this.cellphone,
this.email,
this.sex,
});
// Factory 構造函數:從 API 返回的 JSON (Map) 創建 Person 物件
factory Person.fromJson(Map<String, dynamic> json) {
return Person(
personId: json['personid'] as String? ?? 'N/A', //
personCName: json['personcname'] as String?, //
jobName: json['jobname'] as String?, //
departmentId: json['departmentid'] as String?, //
tel: json['tel'] as String?, //
cellphone: json['cellphone'] as String?, //
email: json['email'] as String?, //
sex: json['sex'] as String?, //
);
}
// 輔助屬性:獲取性別圖示
IconData get sexIcon {
switch (sex?.toUpperCase()) {
case 'M':
return Icons.male;
case 'F':
return Icons.female;
default:
return Icons.person;
}
}
// 輔助屬性:獲取性別顏色
Color get sexColor {
switch (sex?.toUpperCase()) {
case 'M':
return Colors.blue;
case 'F':
return Colors.pink;
default:
return Colors.grey;
}
}
}
+173
View File
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:crypto/crypto.dart';
// 引入九宮格主選單,登入成功後跳轉
import 'main.dart';
// API 相關常數
const String BASE_IP = "https://api.gex.com.tw:8033";
const String API_ENDPOINT = "xapi/v1/eis_demo/checklogin/2/";
const String API_URL = "$BASE_IP/$API_ENDPOINT";
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
// 用於獲取輸入框內容
final TextEditingController _userController = TextEditingController(text: 'admin'); // 預設帳號
final TextEditingController _pwdController = TextEditingController(text: 'gex123'); // 預設密碼
bool _isLoading = false;
String? _errorMessage;
// 輔助函數:將字串轉換為 MD5 雜湊值
String _generateMd5(String input) {
return md5.convert(utf8.encode(input)).toString();
}
// 登入邏輯,模擬您的 JS logon() 函數
Future<void> _logon() async {
if (_userController.text.isEmpty || _pwdController.text.isEmpty) {
setState(() {
_errorMessage = '請輸入帳號和密碼';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
final String userId = _userController.text;
final String password = _pwdController.text;
// 對密碼進行 MD5 加密
final String md5Password = _generateMd5(password);
// 建立 API 參數,使用您的邏輯
final Map<String, String> params = {
'token': 'xxx',
'para01': userId,
'para02': md5Password,
'para03': 'web',
};
try {
// 執行 POST 請求。使用 application/x-www-form-urlencoded 格式
final response = await http.post(
Uri.parse(API_URL),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
);
// 解析 API 回應
final Map<String, dynamic> responseData = json.decode(response.body);
// 根據您的 API 邏輯:res.data.code === 0 為成功
if (responseData['code'] == 0) {
// 登入成功:導航至 MainMenu 並替換登入頁面(防止按返回鍵回到登入)
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const MainMenu()),
);
}
} else {
// 登入失敗:顯示錯誤訊息
setState(() {
_errorMessage = '登入失敗,請重新輸入正確的帳號及密碼!';
});
}
} catch (e) {
// 網路或伺服器錯誤
setState(() {
_errorMessage = '網路錯誤或伺服器無法連線。';
});
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('用戶登入')),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const Icon(Icons.lock_open, size: 80, color: Colors.blue),
const SizedBox(height: 48.0),
// 帳號輸入框
TextField(
controller: _userController,
decoration: const InputDecoration(
labelText: '用戶帳號',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
keyboardType: TextInputType.text,
),
const SizedBox(height: 16.0),
// 密碼輸入框
TextField(
controller: _pwdController,
obscureText: true,
decoration: const InputDecoration(
labelText: '密碼',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock),
),
onSubmitted: (_) => _logon(), // 允許按 Enter 鍵登入
),
const SizedBox(height: 24.0),
// 錯誤訊息顯示
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
// 登入按鈕
ElevatedButton(
onPressed: _isLoading ? null : _logon,
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 50),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 3.0),
)
: const Text(
'登入',
style: TextStyle(fontSize: 18),
),
),
],
),
),
),
);
}
}
+232
View File
@@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import './news_manager.dart';
import './placeholder_screens.dart';
import './login_page.dart'; // 確保引入登入頁面
/*
您的 Dart HTTP 客戶端可能無法完成與伺服器的 SSL/TLS 握手。這需要將您的整個 App 結構調整為使用 io.HttpClient
警告: 這是一個臨時且不安全的解決方案。 它會強制 Dart 客戶端信任所有憑證。只建議在無法控制伺服器憑證或開發環境中臨時使用。
還需要找時間查一下錯誤原因 20251214 modify by Gemini 3.0
*/
import 'dart:io';
class MyHttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context) {
return super.createHttpClient(context)
..badCertificateCallback =
(X509Certificate cert, String host, int port) => true; // 總是回傳 true (忽略 SSL 錯誤)
}
}
void main() {
// 在 main 函數中加入這行
HttpOverrides.global = MyHttpOverrides();
runApp(const ZenApp());
}
class ZenApp extends StatelessWidget {
const ZenApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '企業應用主選單',
theme: ThemeData(
primarySwatch: Colors.blue,
// 使用 Material 3 風格
useMaterial3: true,
),
// 設置主選單畫面為首頁
// home: const MainMenu(),
home: const LoginPage(),
);
}
}
// ------------------------------------
// 主選單的資料結構 (Data Model)
// ------------------------------------
class MenuItem {
final String title;
final IconData icon;
final Widget targetScreen;
const MenuItem({
required this.title,
required this.icon,
required this.targetScreen,
});
}
// 所有選單項目的列表
const List<MenuItem> menuItems = [
// 1. 新聞資訊 (使用您的 NewsManager)
MenuItem(
title: '公告事項',
icon: Icons.article,
targetScreen: NewsManager(),
),
// 2. 員工打卡
MenuItem(
title: '員工打卡',
icon: Icons.access_time_filled,
targetScreen: PlaceholderScreen(title: '員工打卡'),
),
// 2. 員工打卡
MenuItem(
title: '請假作業',
icon: Icons.access_time_filled,
targetScreen: PlaceholderScreen(title: '請假作業'),
), // 3. 待簽核事項
MenuItem(
title: '待簽核事項',
icon: Icons.pending_actions,
targetScreen: PlaceholderScreen(title: '待簽核事項'),
),
// 4. 待辦事項
MenuItem(
title: '待辦事項',
icon: Icons.checklist,
targetScreen: PlaceholderScreen(title: '待辦事項'),
),
// 5. 行事曆
MenuItem(
title: '行事曆',
icon: Icons.calendar_today,
targetScreen: PlaceholderScreen(title: '行事曆'),
),
// 6. 其他功能 (佔位)
MenuItem(
title: '會議通知',
icon: Icons.folder,
targetScreen: PlaceholderScreen(title: '會議通知'),
),
// 6. 其他功能 (佔位)
MenuItem(
title: '產品型錄',
icon: Icons.folder,
targetScreen: PlaceholderScreen(title: '產品型錄'),
),
// 6. 其他功能 (佔位)
MenuItem(
title: '通訊錄',
icon: Icons.folder,
targetScreen: PlaceholderScreen(title: '通訊錄'),
),
MenuItem(
title: '業績查詢',
icon: Icons.bar_chart,
targetScreen: PlaceholderScreen(title: '報告查詢'),
),
MenuItem(
title: 'Issue List',
icon: Icons.bar_chart,
targetScreen: PlaceholderScreen(title: 'Issue List'),
),
MenuItem(
title: '設定',
icon: Icons.settings,
targetScreen: PlaceholderScreen(title: '設定'),
),
];
// ------------------------------------
// 主選單畫面 (MainMenu) - 九宮格實作
// ------------------------------------
class MainMenu extends StatelessWidget {
const MainMenu({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('企業應用主選單'),
// 增加 App Bar 的高度,使其更符合 Material 3 風格 (可選)
toolbarHeight: 70,
// 可以在這裡加入登出按鈕
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () {
// 登出:導航回登入頁面並清除所有歷史堆棧
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const LoginPage()),
(Route<dynamic> route) => false,
);
},
),
],
),
// 使用 Padding 讓 GridView 內容與邊緣保持距離
body: Padding(
padding: const EdgeInsets.all(12.0),
// 核心:使用 GridView.count 建立九宮格佈局
child: GridView.count(
// 每行顯示 3 個項目(實現九宮格的第一條件)
crossAxisCount: 3,
// 項目間的間距
crossAxisSpacing: 10.0,
mainAxisSpacing: 10.0,
// 遍歷 menuItems 列表來構建每個選單項目
children: menuItems.map((item) {
return MainMenuItemTile(item: item);
}).toList(),
),
),
);
}
}
// ------------------------------------
// 單個選單項目的小部件 (MainMenuItemTile)
// ------------------------------------
class MainMenuItemTile extends StatelessWidget {
final MenuItem item;
const MainMenuItemTile({required this.item, super.key});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4.0, // 增加陰影,更有質感
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0)),
child: InkWell(
// 使用 InkWell 獲得點擊時的漣漪效果
onTap: () {
// 點擊後導航到對應的目標畫面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => item.targetScreen,
),
);
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// 選單圖標
Icon(
item.icon,
size: 48.0,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 8.0),
// 選單名稱
Text(
item.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14.0,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
+66
View File
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
// 1. 確保引入 news_detail.dart
import './news_detail.dart';
class News extends StatelessWidget {
final List<String> news;
// 構造函數保持不變
const News(this.news, {super.key});
@override
Widget build(BuildContext context) {
// 使用 ListView.builder 替代 Column 和 map().toList()
// 這樣可以確保內容可以捲動,並且只構建可見的項目,效率更高。
return ListView.builder(
// 設置列表的長度,即 news 列表的項目數
itemCount: news.length,
// 根據索引來構建每個列表項目
itemBuilder: (BuildContext context, int index) {
final String currentNewsTitle = news[index]; // 提取當前新聞標題
// 使用 news[index] 來取得對應的新聞文本
return Card(
elevation: 5, // 可選: 增加卡片陰影
margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), // 增加邊距
// 在這裡可以選擇添加 Key
key: ValueKey(currentNewsTitle + index.toString()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, // 讓圖片和文字可以寬度延伸
children: <Widget>[
// --- 點擊功能的修改在這裡 ---
GestureDetector(
onTap: () {
// 點擊事件:導航到 NewsDetail 畫面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NewsDetail(
// 2. 將當前新聞的標題/內容傳遞給詳細頁面
newsTitle: currentNewsTitle,
),
),
);
},
child: Image.asset(
'assets/F16-02.jpg',
fit: BoxFit.cover,
height: 200,
),
),
// --------------------------
Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
currentNewsTitle,
style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold),
),
),
],
),
);
},
);
}
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
// 這是一個 StatelessWidget,用於顯示單條新聞的詳細內容
class NewsDetail extends StatelessWidget {
final String newsTitle; // 接收從前一個畫面傳遞過來的新聞標題
// 為了演示,我們假設詳細內容是一個較長的文本
final String newsDetailContent =
"這是新聞的詳細內容。它包含了更深入的分析、更多的數據和完整的背景資訊。點擊圖片後,使用者可以在這個頁面仔細閱讀。由於這是一個範例,我們使用了一些重複的文本來模擬文章長度,以確保頁面可以滾動。在這個頁面,您可以加入更多的元素,例如不同的圖片、作者資訊、發布日期等。";
const NewsDetail({required this.newsTitle, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
// 設置 App Bar,標題可以是傳入的新聞標題
appBar: AppBar(
title: Text(newsTitle, overflow: TextOverflow.ellipsis), // 防止標題過長
),
// 使用 SingleChildScrollView 確保頁面內容超出螢幕時可以滾動
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 再次顯示圖片 (可選)
ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Image.asset(
'assets/F16-02.jpg',
fit: BoxFit.cover,
height: 250,
width: double.infinity, // 佔滿寬度
),
),
const SizedBox(height: 16.0),
// 顯示新聞標題
Text(
newsTitle,
style: const TextStyle(
fontSize: 24.0,
fontWeight: FontWeight.w800,
),
),
const Divider(height: 32.0), // 分隔線
// 顯示新聞詳細內容
Text(
newsDetailContent * 5, // 重複內容 5 次來模擬長文章
style: const TextStyle(
fontSize: 16.0,
height: 1.5, // 增加行高,改善閱讀體驗
),
textAlign: TextAlign.justify, // 內容兩端對齊
),
],
),
),
),
);
}
}
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
// 確保你的 news.dart 文件和 News 類已經被正確引入
import './news.dart';
// 確保引入 MainMenu 所在的 main.dart 文件,以便導航
import './main.dart';
class NewsManager extends StatefulWidget {
const NewsManager({super.key});
@override
State<StatefulWidget> createState() {
return _NewsManagerState();
}
}
class _NewsManagerState extends State<NewsManager> {
List<String> news = ['第一筆資料: F-16 戰機升級計畫', '第二筆資料: 台積電研發新突破'];
void _addNews() {
setState(() {
news.add('新增資料:最新動態 ${DateTime.now().second}');
});
}
// 新增的導航函數:返回到主選單
void _backToHome() {
// Navigator.popUntil 會一直彈出 (Pop) 路由堆棧中的頁面,直到遇到一個
// 滿足條件 (predicte) 的路由。在這裡,我們使用 (route) => route.isFirst
// 來返回到路由堆棧中的第一個頁面,也就是 MainMenu (如果您的啟動順序是 Login -> MainMenu)。
// 如果 MainMenu 不是第一個頁面,您可以考慮使用:
// Navigator.popUntil(context, ModalRoute.withName('/main_menu'));
// 但這需要您在 MaterialApp 中為 MainMenu 設定路由名稱。
// 最保險且簡單的方法是直接彈出所有,然後重新推入 MainMenu(確保 MainMenu 不會有歷史記錄)
// 或者我們假設 MainMenu 是 Login 成功後的第一個頁面,使用 pushReplacement 清理過的堆棧。
// 在本例中,我們直接導航回 MainMenu,並清除所有在它之上的頁面。
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (context) => const MainMenu()),
(Route<dynamic> route) => false, // 移除所有前面的路由
);
// 註:若 NewsManager 已經是 Scaffold 的子頁面,使用 Navigator.pop(context)
// 即可返回上一個頁面(即 MainMenu)。但如果想確保是回主頁,用 pushAndRemoveUntil 更明確。
}
@override
Widget build(BuildContext context) {
// *** 關鍵修改:將整個頁面包裹在 Scaffold 中以包含 AppBar ***
return Scaffold(
appBar: AppBar(
title: const Text('新聞管理'),
// ----------------------------------------------------
// 新增的返回主頁按鈕
actions: <Widget>[
IconButton(
icon: const Icon(Icons.home), // 使用一個 Home 圖標
tooltip: '返回主頁', // 長按時的提示
onPressed: _backToHome, // 點擊時呼叫導航函數
),
],
// ----------------------------------------------------
),
body: Column(
children: <Widget>[
// 1. 固定高度的按鈕部分
Container(
margin: const EdgeInsets.all(10),
child: ElevatedButton(
onPressed: _addNews,
child: const Text(
'新增資料',
style: TextStyle(fontSize: 20, color: Colors.redAccent),
),
),
),
// 2. 佔據剩餘空間的列表部分
Expanded(
child: News(news),
),
],
),
);
}
}
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
// 通用的佔位畫面
class PlaceholderScreen extends StatelessWidget {
final String title;
const PlaceholderScreen({required this.title, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Center(
child: Text(
'$title 功能正在開發中...',
style: const TextStyle(fontSize: 24, color: Colors.grey),
),
),
);
}
}
// 各功能頁面 (繼承自 PlaceholderScreen,方便未來替換成真實頁面)
class AttendanceScreen extends PlaceholderScreen {
AttendanceScreen({super.key}) : super(title: '員工打卡');
}
class ApprovalScreen extends PlaceholderScreen {
ApprovalScreen({super.key}) : super(title: '待簽核事項');
}
class TodoScreen extends PlaceholderScreen {
TodoScreen({super.key}) : super(title: '待辦事項');
}
class CalendarScreen extends PlaceholderScreen {
CalendarScreen({super.key}) : super(title: '行事曆');
}
+28
View File
@@ -0,0 +1,28 @@
import './issue_model.dart';
import '../services/generic_api_service.dart'; // 引入共用服務
import '../auth_manager.dart'; // 確保引入 AuthManager
class IssueApiService {
final GenericApiService _apiService = GenericApiService();
final String currentUserId; // 應由登入頁面傳入
IssueApiService({this.currentUserId = 'admin'});
Future<List<Issue>> fetchIssues() async {
// 篩選條件:指派給當前使用者 (responsible = currentUserId) 且狀態非 '已結案' (Status != 4)
// 格式: 1^100^raised_date^*^responsible^=^$currentUserId^issue_status^!=^4
// 讀取 user_id
String? user_id = await AuthManager.getUserId();
String filterPart = "responsible^$user_id";
// 完整的 queryFilter 格式:Page^PageSize^SortColumn^SortOrder^Filter...
String queryFilter = "1^100^issueid^*^^^$filterPart";
return await _apiService.fetchList<Issue>(
tableName: "pms_issuelog", // 對應到問題追蹤表格
pk: "issueid", // 主鍵為 issueid
queryFilter: queryFilter,
fromJson: (json) => Issue.fromJson(json),
);
}
}
+156
View File
@@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
import './issue_model.dart';
import 'package:intl/intl.dart';
class IssueDetail extends StatelessWidget {
final Issue issue;
const IssueDetail({required this.issue, super.key});
// 輔助函式:建立屬性列
Widget _buildAttributeRow(BuildContext context, String label, String? value, {Color color = Colors.black}) {
// 處理日期欄位
String displayValue = value ?? 'N/A';
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black54),
),
),
Expanded(
child: Text(
displayValue,
style: TextStyle(fontWeight: FontWeight.w600, color: color),
),
),
],
),
);
}
// 輔助函式:格式化日期時間
String _formatDateTime(DateTime? dateTime) {
if (dateTime == null) return 'N/A';
return DateFormat('yyyy/MM/dd HH:mm').format(dateTime);
}
@override
Widget build(BuildContext context) {
// 完整描述 (Model中可能被截斷,這裡使用原始欄位)
final fullDescription = issue.description ?? '無詳細描述。';
final hasSolution = issue.solution != null && issue.solution!.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: Text('問題 #${issue.issueId}'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 標題與 ID
Text(
'問題 ID: #${issue.issueId}',
style: const TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold, color: Colors.black87),
),
const Divider(height: 24.0),
// 核心屬性
_buildAttributeRow(context, '狀態', issue.statusText, color: issue.statusColor),
_buildAttributeRow(context, '優先級', issue.priority, color: issue.priorityColor),
_buildAttributeRow(context, '預計完成日', issue.formattedExpectedDate, color: Colors.blue),
_buildAttributeRow(context, '指派對象', issue.responsible),
_buildAttributeRow(context, '提出人', issue.raisedBy),
_buildAttributeRow(context, '專案 ID', issue.projectId),
_buildAttributeRow(context, '功能碼', issue.functionCode),
_buildAttributeRow(context, '提出時間', _formatDateTime(issue.raisedDate)),
_buildAttributeRow(context, '解決說明', hasSolution ? '詳見下方' : '尚未解決', color: hasSolution ? Colors.green : Colors.red),
const Divider(height: 32.0),
// 問題詳細描述
const Text(
'問題描述 (Issue Description):',
style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w600, color: Colors.black87),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Text(
fullDescription,
style: const TextStyle(fontSize: 16.0, height: 1.5, color: Colors.black87),
textAlign: TextAlign.justify,
),
),
const Divider(height: 32.0),
// 解決說明/備註
Text(
'解決說明 (Solution Explanation):',
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.w600,
color: hasSolution ? Colors.black87 : Colors.grey.shade500
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
width: double.infinity,
decoration: BoxDecoration(
color: hasSolution ? Colors.lightGreen.shade50 : Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: hasSolution ? Colors.green.shade200 : Colors.transparent)
),
child: Text(
issue.solution ?? '尚無解決說明或備註。',
style: TextStyle(
fontSize: 16.0,
height: 1.5,
color: hasSolution ? Colors.black87 : Colors.grey.shade600
),
textAlign: TextAlign.justify,
),
),
const SizedBox(height: 40),
// 底部按鈕
Center(
child: ElevatedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('待實作處理問題流程(例如:變更狀態)。')),
);
},
icon: const Icon(Icons.build),
label: const Text('處理問題'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
textStyle: const TextStyle(fontSize: 18),
),
),
),
],
),
),
);
}
}
+156
View File
@@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
import './issue_api.dart';
import './issue_model.dart';
import './issue_detail.dart';
class IssueManager extends StatefulWidget {
// 實際應用中,這裡應該傳入當前用戶 ID
final String currentUserId;
const IssueManager({this.currentUserId = 'admin', super.key});
@override
State<StatefulWidget> createState() {
return _IssueManagerState();
}
}
class _IssueManagerState extends State<IssueManager> {
late IssueApiService _apiService;
late Future<List<Issue>> _issuesFuture;
@override
void initState() {
super.initState();
_apiService = IssueApiService(currentUserId: widget.currentUserId);
_issuesFuture = _apiService.fetchIssues();
}
void _refreshIssues() {
setState(() {
_issuesFuture = _apiService.fetchIssues();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('指派給我的問題清單 (${widget.currentUserId})'),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.refresh),
tooltip: '刷新列表',
onPressed: _refreshIssues,
),
IconButton(
icon: const Icon(Icons.home),
tooltip: '返回主頁',
onPressed: () => Navigator.pop(context),
),
],
),
body: FutureBuilder<List<Issue>>(
future: _issuesFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('載入失敗: ${snapshot.error}', textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(onPressed: _refreshIssues, child: const Text('重試')),
],
),
);
} else if (snapshot.hasData && snapshot.data!.isNotEmpty) {
return IssueList(issues: snapshot.data!);
} else {
return const Center(child: Text('目前沒有指派給您的問題。'));
}
},
),
// 底部浮動按鈕:新增問題
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('待實作新增問題頁面。')),
);
},
icon: const Icon(Icons.add),
label: const Text('新增問題'),
backgroundColor: Colors.redAccent,
),
);
}
}
// -----------------------------------------------------------
// 列表顯示小部件 (IssueList)
// -----------------------------------------------------------
class IssueList extends StatelessWidget {
final List<Issue> issues;
const IssueList({required this.issues, super.key});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: issues.length,
itemBuilder: (BuildContext context, int index) {
final item = issues[index];
return Card(
elevation: 3,
margin: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 16.0),
child: InkWell(
onTap: () {
// 點擊項目:導航到詳細頁面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => IssueDetail(issue: item),
),
);
},
child: ListTile(
// 左側顯示優先級
leading: Icon(
item.priorityIcon,
color: item.priorityColor,
size: 30,
),
title: Text(
'#${item.issueId} ${item.description ?? '無描述'}',
style: const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'專案: ${item.projectId ?? 'N/A'} | 提出人: ${item.raisedBy ?? 'N/A'}',
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
item.statusText,
style: TextStyle(fontSize: 12, color: item.statusColor, fontWeight: FontWeight.bold),
),
Text(
item.formattedExpectedDate,
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
],
),
),
),
);
},
);
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class Issue {
final int issueId; // issueid (Primary Key)
final String? projectId; // projectid
final String? functionCode; // functioncode
final String? description; // issue_description
final String? priority; // issue_priority (High, Medium, Low)
final int? status; // issue_status (1=New, 2=InProgress, 3=Resolved, 4=Closed)
final String? raisedBy; // raised_by
final String? responsible; // responsible
final DateTime? raisedDate; // raised_date
final DateTime? expectedDate; // excepted_date
final String? solution; // solution_explanation
Issue({
required this.issueId,
this.projectId,
this.functionCode,
this.description,
this.priority,
this.status,
this.raisedBy,
this.responsible,
this.raisedDate,
this.expectedDate,
this.solution,
});
factory Issue.fromJson(Map<String, dynamic> json) {
DateTime? parseDate(dynamic date) {
if (date is String && date.isNotEmpty) {
return DateTime.tryParse(date);
}
return null;
}
// 由於 issue_priority 是 VARCHAR,我們假設它直接就是 'High', 'Medium', 'Low'
// 或是一個代號,此處保留為 String
final rawPriority = json['issue_priority'] as String?;
// 假設 issue_description 欄位是問題標題/簡述
final rawDescription = json['issue_description'] as String?;
final descriptionLength = rawDescription?.length ?? 0;
final truncatedDescription = (descriptionLength > 50)
? rawDescription!.substring(0, 50) + '...' // 列表截斷
: rawDescription;
return Issue(
issueId: json['issueid'] as int? ?? 0,
projectId: json['projectid'] as String?,
functionCode: json['functioncode'] as String?,
description: truncatedDescription, // 使用修正後的變數
priority: rawPriority,
status: json['issue_status'] as int?,
raisedBy: json['raised_by'] as String?,
responsible: json['responsible'] as String?,
raisedDate: parseDate(json['raised_date']),
expectedDate: parseDate(json['excepted_date']),
solution: json['solution_explanation'] as String?,
);
}
// Helper: 格式化預計完成日期
String get formattedExpectedDate {
if (expectedDate == null) return 'N/A';
return DateFormat('yyyy/MM/dd').format(expectedDate!);
}
// Helper: 獲取狀態文字
String get statusText {
switch (status) {
case 1:
return '新建';
case 2:
return '進行中';
case 3:
return '已解決';
case 4:
return '已結案';
default:
return '未知';
}
}
// Helper: 獲取狀態顏色
Color get statusColor {
switch (status) {
case 1:
return Colors.red; // 新建
case 2:
return Colors.orange; // 進行中
case 3:
return Colors.blue; // 已解決
case 4:
return Colors.green; // 已結案
default:
return Colors.grey;
}
}
// Helper: 獲取優先級圖示
IconData get priorityIcon {
switch (priority?.toLowerCase()) {
case 'high':
return Icons.arrow_upward;
case 'medium':
return Icons.remove;
case 'low':
return Icons.arrow_downward;
default:
return Icons.sort;
}
}
// Helper: 獲取優先級顏色
Color get priorityColor {
switch (priority?.toLowerCase()) {
case 'high':
return Colors.red.shade700;
case 'medium':
return Colors.orange.shade700;
case 'low':
return Colors.blue.shade700;
default:
return Colors.grey;
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import './leave_model.dart';
import '../services/generic_api_service.dart';
import 'package:intl/intl.dart';
class LeaveApiService {
final GenericApiService _apiService = GenericApiService();
// 獲取個人請假紀錄
Future<List<Leave>> fetchLeaves(String personId) async {
// 排序:按單據日期降冪
String queryFilter = "1^100^billdate^*^personid^=^$personId";
return await _apiService.fetchList<Leave>(
tableName: "hrs_leave",
pk: "billno",
queryFilter: queryFilter,
fromJson: (json) => Leave.fromJson(json),
);
}
// 提交請假單 (新增)
Future<bool> createLeave(Leave leave) async {
final Map<String, dynamic> data = {
"billdate": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
"billno": "LV${DateTime.now().millisecondsSinceEpoch}", // 範例編號
"personid": leave.personId,
"agentid": leave.agentId,
"leavetype": leave.leaveType,
"starttime": DateFormat('yyyy-MM-dd HH:mm:ss').format(leave.startTime!),
"endtime": DateFormat('yyyy-MM-dd HH:mm:ss').format(leave.endTime!),
"days": leave.days,
"hours": leave.hours,
"leave_note": leave.leaveNote,
"flow_status": "1", // 提交即進入審核中
"create_user": leave.personId,
"create_date": DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
};
// 呼叫底層 saveData 實作
// return await _apiService.saveData("hrs_leave", data);
print("提交假單: $data");
return true;
}
}
+149
View File
@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import './leave_model.dart';
class LeaveDetail extends StatelessWidget {
final Leave leave;
const LeaveDetail({required this.leave, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('請假單詳情'),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0.5,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 頂部狀態區塊
_buildHeaderStatus(),
const SizedBox(height: 24),
// 主要資訊區塊 (使用卡片包裝)
_buildInfoCard(context),
const SizedBox(height: 24),
// 請假事由區塊
const Text(
'請假事由',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.blueGrey),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade300),
),
child: Text(
leave.leaveNote ?? '未填寫事由',
style: const TextStyle(fontSize: 15, height: 1.5),
),
),
const SizedBox(height: 32),
// 底部操作按鈕 (例如:若為草稿可編輯,或撤回)
if (leave.flowStatus == '0' || leave.flowStatus == '1')
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
// 實作撤回或取消邏輯
},
icon: const Icon(Icons.history_outlined),
label: const Text('撤回申請'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
side: const BorderSide(color: Colors.red),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
),
);
}
// 頂部狀態顯示:呈現單號與醒目的狀態標籤
Widget _buildHeaderStatus() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'單號: ${leave.billNo}',
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 4),
Text(
leave.leaveType,
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
],
),
Chip(
backgroundColor: leave.statusColor.withOpacity(0.1),
side: BorderSide(color: leave.statusColor),
label: Text(
leave.statusText,
style: TextStyle(color: leave.statusColor, fontWeight: FontWeight.bold),
),
),
],
);
}
// 核心資訊卡片
Widget _buildInfoCard(BuildContext context) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
side: BorderSide(color: Colors.grey.shade200),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
_buildDetailRow(Icons.calendar_month, '請假期間', leave.formattedRange),
const Divider(height: 30),
_buildDetailRow(Icons.timer_outlined, '請假時數', '${leave.days}${leave.hours} 小時'),
const Divider(height: 30),
_buildDetailRow(Icons.person_outline, '代理人', leave.agentId),
const Divider(height: 30),
_buildDetailRow(Icons.edit_calendar, '申請日期',
leave.billDate != null ? DateFormat('yyyy-MM-dd').format(leave.billDate!) : 'N/A'),
],
),
),
);
}
// 輔助元件:建立細節列
Widget _buildDetailRow(IconData icon, String label, String value) {
return Row(
children: [
Icon(icon, size: 20, color: Colors.blueAccent),
const SizedBox(width: 12),
Text(label, style: const TextStyle(color: Colors.grey, fontSize: 14)),
const Spacer(),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
),
],
);
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import './leave_model.dart';
import './leave_api.dart';
import 'package:intl/intl.dart';
class LeaveForm extends StatefulWidget {
final String userId;
const LeaveForm({required this.userId, super.key});
@override
State<LeaveForm> createState() => _LeaveFormState();
}
class _LeaveFormState extends State<LeaveForm> {
final _formKey = GlobalKey<FormState>();
String _selectedType = '事假';
String _agentId = '';
String _note = '';
DateTime _start = DateTime.now();
DateTime _end = DateTime.now().add(const Duration(hours: 8));
final List<String> _types = ['事假', '病假', '特休', '婚假', '喪假'];
void _submit() async {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
final newLeave = Leave(
billNo: '', // API 端生成
personId: widget.userId,
agentId: _agentId,
leaveType: _selectedType,
startTime: _start,
endTime: _end,
days: 1.0, // 簡化處理,實際可依 start/end 計算
hours: 8.0,
leaveNote: _note,
);
final success = await LeaveApiService().createLeave(newLeave);
if (success && mounted) {
Navigator.pop(context, true);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('新增請假申請')),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
DropdownButtonFormField<String>(
value: _selectedType,
decoration: const InputDecoration(labelText: '請假類別'),
items: _types.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
onChanged: (v) => setState(() => _selectedType = v!),
),
const SizedBox(height: 16),
TextFormField(
decoration: const InputDecoration(labelText: '代理人工號'),
validator: (v) => v!.isEmpty ? '必填' : null,
onSaved: (v) => _agentId = v!,
),
const SizedBox(height: 16),
ListTile(
title: const Text('開始時間'),
subtitle: Text(DateFormat('yyyy/MM/dd HH:mm').format(_start)),
trailing: const Icon(Icons.calendar_today),
onTap: () async {
// 這裡簡化,實務上可串接 showDatePicker + showTimePicker
},
),
const SizedBox(height: 16),
TextFormField(
decoration: const InputDecoration(labelText: '事由說明'),
maxLines: 3,
onSaved: (v) => _note = v!,
),
const SizedBox(height: 30),
ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 50)),
child: const Text('提交申請'),
),
],
),
),
);
}
}
+85
View File
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import './leave_model.dart';
import './leave_api.dart';
import './leave_form.dart'; // 稍後定義的新增頁面
import './leave_detail.dart';
class LeaveManager extends StatefulWidget {
final String currentUserId;
const LeaveManager({required this.currentUserId, super.key});
@override
State<LeaveManager> createState() => _LeaveManagerState();
}
class _LeaveManagerState extends State<LeaveManager> {
late LeaveApiService _apiService;
late Future<List<Leave>> _leaveFuture;
@override
void initState() {
super.initState();
_apiService = LeaveApiService();
_refreshList();
}
void _refreshList() {
setState(() {
_leaveFuture = _apiService.fetchLeaves(widget.currentUserId);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('請假紀錄')),
body: FutureBuilder<List<Leave>>(
future: _leaveFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const Center(child: Text('尚無請假紀錄'));
}
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (ctx, i) => _buildLeaveCard(snapshot.data![i]),
);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (context) => LeaveForm(userId: widget.currentUserId)),
);
if (result == true) _refreshList();
},
label: const Text('申請請假'),
icon: const Icon(Icons.add),
),
);
}
Widget _buildLeaveCard(Leave item) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => LeaveDetail(leave: item)),
);
},
title: Text('${item.leaveType} (${item.days}${item.hours}時)'),
subtitle: Text(item.formattedRange),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: item.statusColor, borderRadius: BorderRadius.circular(5)),
child: Text(item.statusText, style: const TextStyle(color: Colors.white, fontSize: 12)),
),
),
);
}
}
+72
View File
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class Leave {
final String billNo; // billno (Primary Key)
final DateTime? billDate; // billdate
final String personId; // personid
final String agentId; // agentid (代理人)
final String leaveType; // leavetype (假別:事假、病假等)
final DateTime? startTime; // starttime
final DateTime? endTime; // endtime
final double days; // days
final double hours; // hours
final String? leaveNote; // leave_note
final String? flowStatus; // flow_status (0:草稿, 1:審核中, 2:已核准, X:駁回)
Leave({
required this.billNo,
this.billDate,
required this.personId,
required this.agentId,
required this.leaveType,
this.startTime,
this.endTime,
this.days = 0,
this.hours = 0,
this.leaveNote,
this.flowStatus,
});
factory Leave.fromJson(Map<String, dynamic> json) {
return Leave(
billNo: json['billno'] as String? ?? '',
billDate: json['billdate'] != null ? DateTime.tryParse(json['billdate']) : null,
personId: json['personid'] as String? ?? '',
agentId: json['agentid'] as String? ?? '',
leaveType: json['leavetype'] as String? ?? '',
startTime: json['starttime'] != null ? DateTime.tryParse(json['starttime']) : null,
endTime: json['endtime'] != null ? DateTime.tryParse(json['endtime']) : null,
days: double.tryParse(json['days']?.toString() ?? '0') ?? 0,
hours: double.tryParse(json['hours']?.toString() ?? '0') ?? 0,
leaveNote: json['leave_note'] as String?,
flowStatus: json['flow_status'] as String?,
);
}
// 格式化顯示
String get formattedRange {
if (startTime == null || endTime == null) return '時間未定';
final df = DateFormat('yyyy/MM/dd HH:mm');
return '${df.format(startTime!)} ~ ${df.format(endTime!)}';
}
// 狀態顏色映射
Color get statusColor {
switch (flowStatus) {
case '1': return Colors.orange; // 審核中
case '2': return Colors.green; // 已核准
case 'X': return Colors.red; // 駁回
default: return Colors.grey; // 草稿
}
}
String get statusText {
switch (flowStatus) {
case '1': return '審核中';
case '2': return '已核准';
case 'X': return '已駁回';
default: return '草稿';
}
}
}
+188
View File
@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:crypto/crypto.dart';
// 引入九宮格主選單,登入成功後跳轉
import 'main.dart';
import './auth_manager.dart'; // 確保路徑正確
// API 相關常數
const String BASE_IP = "https://api.gex.com.tw:8033";
const String API_ENDPOINT = "xapi/v1/eis_demo/checklogin/2/";
const String API_URL = "$BASE_IP/$API_ENDPOINT";
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
// 用於獲取輸入框內容
final TextEditingController _userController = TextEditingController(text: 'admin'); // 預設帳號
final TextEditingController _pwdController = TextEditingController(text: 'gex123'); // 預設密碼
bool _isLoading = false;
String? _errorMessage;
// 輔助函數:將字串轉換為 MD5 雜湊值
String _generateMd5(String input) {
return md5.convert(utf8.encode(input)).toString();
}
// 登入邏輯,模擬您的 JS logon() 函數
Future<void> _logon() async {
if (_userController.text.isEmpty || _pwdController.text.isEmpty) {
setState(() {
_errorMessage = '請輸入帳號和密碼';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
final String userId = _userController.text;
final String password = _pwdController.text;
// 對密碼進行 MD5 加密
final String md5Password = _generateMd5(password);
// 建立 API 參數,使用您的邏輯
final Map<String, String> params = {
'token': 'xxx',
'para01': userId,
'para02': md5Password,
'para03': 'web',
};
try {
// 執行 POST 請求。使用 application/x-www-form-urlencoded 格式
final response = await http.post(
Uri.parse(API_URL),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
);
// 解析 API 回應
final Map<String, dynamic> responseData = json.decode(response.body);
// 根據您的 API 邏輯:res.data.code === 0 為成功
if (responseData['code'] == 0) {
// 1. 預期 responseData['data'] 是一個 List
final List<dynamic>? dataList = responseData['data'] as List<dynamic>?;
// 2. 檢查 List 不為空,並從第一個元素中提取 'token'
final String? token = (dataList != null && dataList.isNotEmpty)
? (dataList[0] as Map<String, dynamic>)['token'] as String?
: null;
if (token != null && token.isNotEmpty) {
await AuthManager.saveLoginInfo(token, userId); // 呼叫 AuthManager 儲存 Token
} else {
print("警告: 登入成功但未收到 Token,將不儲存。");
}
// 登入成功:導航至 MainMenu 並替換登入頁面(防止按返回鍵回到登入)
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const MainMenu()),
);
}
} else {
// 登入失敗:顯示錯誤訊息
setState(() {
_errorMessage = '登入失敗,請重新輸入正確的帳號及密碼!';
});
}
} catch (e) {
// 網路或伺服器錯誤
setState(() {
_errorMessage = '網路錯誤或伺服器無法連線。';
});
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('用戶登入')),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const Icon(Icons.lock_open, size: 80, color: Colors.blue),
const SizedBox(height: 48.0),
// 帳號輸入框
TextField(
controller: _userController,
decoration: const InputDecoration(
labelText: '用戶帳號',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
keyboardType: TextInputType.text,
),
const SizedBox(height: 16.0),
// 密碼輸入框
TextField(
controller: _pwdController,
obscureText: true,
decoration: const InputDecoration(
labelText: '密碼',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock),
),
onSubmitted: (_) => _logon(), // 允許按 Enter 鍵登入
),
const SizedBox(height: 24.0),
// 錯誤訊息顯示
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
// 登入按鈕
ElevatedButton(
onPressed: _isLoading ? null : _logon,
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 50),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 3.0),
)
: const Text(
'登入',
style: TextStyle(fontSize: 18),
),
),
],
),
),
),
);
}
}
+309
View File
@@ -0,0 +1,309 @@
import 'package:flutter/material.dart';
import 'News/news_manager.dart';
import './placeholder_screens.dart';
import './login_page.dart'; // 確保引入登入頁面
import './auth_manager.dart';
/*
您的 Dart HTTP 客戶端可能無法完成與伺服器的 SSL/TLS 握手。這需要將您的整個 App 結構調整為使用 io.HttpClient
警告: 這是一個臨時且不安全的解決方案。 它會強制 Dart 客戶端信任所有憑證。只建議在無法控制伺服器憑證或開發環境中臨時使用。
還需要找時間查一下錯誤原因 20251214 modify by Gemini 3.0
*/
import 'dart:io';
// 確保引入新的公告管理器
import './AnnouncementManager/announcement_manager.dart';
// 確保引入新的待辦事項管理器
import './todo/todo_manager.dart';
// 通訊錄
import './employee/person_manager.dart';
// 會議通知
import './meeting/meeting_manager.dart';
// Issue
import './issue/issue_manager.dart';
// setting
import './setup/settings_page.dart';
import './clockin/clock_in_manager.dart';
import './leave/leave_manager.dart';
import './calendar/calendar_manager.dart';
class MyHttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context) {
return super.createHttpClient(context)
..badCertificateCallback =
(X509Certificate cert, String host, int port) => true; // 總是回傳 true (忽略 SSL 錯誤)
}
}
void main() {
// 在 main 函數中加入這行
HttpOverrides.global = MyHttpOverrides();
runApp(const ZenApp());
}
class ZenApp extends StatelessWidget {
const ZenApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '企業應用主選單',
theme: ThemeData(
primarySwatch: Colors.blue,
// 使用 Material 3 風格
useMaterial3: true,
),
// 設置主選單畫面為首頁
// home: const MainMenu(),
home: const LoginPage(),
);
}
}
// ------------------------------------
// 主選單的資料結構 (Data Model)
// ------------------------------------
class MenuItem {
final String title;
final IconData icon;
final Widget targetScreen;
const MenuItem({
required this.title,
required this.icon,
required this.targetScreen,
});
}
// 所有選單項目的列表
const List<MenuItem> menuItems = [
// 1. 新聞資訊 (使用您的 NewsManager)
MenuItem(
title: '公告事項',
icon: Icons.article,
//targetScreen: NewsManager(),
targetScreen: AnnouncementManager(),
),
// 2. 員工打卡
MenuItem(
title: '員工打卡',
icon: Icons.access_time_filled,
targetScreen: ClockInManager(userId: "admin"),
),
// 2. 員工打卡
MenuItem(
title: '請假作業',
icon: Icons.event_available,
targetScreen: LeaveManager(currentUserId: "admin"),
), // 3. 待簽核事項
MenuItem(
title: '待簽核事項',
icon: Icons.pending_actions,
targetScreen: PlaceholderScreen(title: '待簽核事項'),
),
// 4. 待辦事項
MenuItem(
title: '待辦事項',
icon: Icons.checklist,
targetScreen: TodoManager(),
),
// 5. 行事曆
MenuItem(
title: '行事曆',
icon: Icons.calendar_today,
targetScreen: CalendarManager(userId: "120102"),
),
// 6. 其他功能 (佔位)
MenuItem(
title: '會議通知',
icon: Icons.people_alt,
targetScreen: MeetingManager(),
),
// 6. 其他功能 (佔位)
MenuItem(
title: '產品型錄',
icon: Icons.local_mall,
targetScreen: PlaceholderScreen(title: '產品型錄'),
),
// 6. 其他功能 (佔位)
MenuItem(
title: '通訊錄',
icon: Icons.contact_phone,
targetScreen: PersonManager(),
),
MenuItem(
title: '業績查詢',
icon: Icons.query_stats,
targetScreen: PlaceholderScreen(title: '報告查詢'),
),
MenuItem(
title: 'Issue List',
icon: Icons.bug_report,
targetScreen: IssueManager(),
),
MenuItem(
title: '設定',
icon: Icons.settings,
targetScreen: SettingsPage(),
),
MenuItem(
title: '新聞(Demo)',
icon: Icons.article,
targetScreen: NewsManager(),
//targetScreen: AnnouncementManager(),
),
];
// ------------------------------------
// 主選單畫面 (MainMenu) - 九宮格實作
// ------------------------------------
class MainMenu extends StatelessWidget {
const MainMenu({super.key});
// 公司資訊變數
final String companyName = "中華開放原始碼應用推廣協會";
final String slogan = "推動開源 × 數位轉型 × 企業創新";
final String copyrightYear = "2024";
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('企業應用主選單'),
// 增加 App Bar 的高度,使其更符合 Material 3 風格 (可選)
toolbarHeight: 70,
// 可以在這裡加入登出按鈕
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () async {
await AuthManager.logout(); // <-- 記得加入此行
// 登出:導航回登入頁面並清除所有歷史堆棧
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const LoginPage()),
(Route<dynamic> route) => false,
);
},
),
],
),
// 使用 Column 將 GridView 和底部資訊垂直堆疊
body: Column(
children: <Widget>[
// 1. 主要內容區 (Grid View) - 使用 Expanded 佔滿剩餘空間
Expanded(
child: Padding(
padding: const EdgeInsets.all(12.0),
// 核心:使用 GridView.count 建立九宮格佈局
child: GridView.count(
// 每行顯示 3 個項目(實現九宮格的第一條件)
crossAxisCount: 3,
// 項目間的間距
crossAxisSpacing: 10.0,
mainAxisSpacing: 10.0,
// 遍歷 menuItems 列表來構建每個選單項目
children: menuItems.map((item) {
return MainMenuItemTile(item: item);
}).toList(),
),
),
),
// 2. 底部公司資訊、Slogan 和版權宣告 (固定在最下方)
Container(
padding: const EdgeInsets.only(top: 8.0, bottom: 16.0),
width: double.infinity, // 佔滿寬度
child: Column(
children: <Widget>[
// 公司名稱
Text(
companyName,
style: TextStyle(
fontSize: 14.0,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4.0),
// Slogan
Text(
slogan,
style: TextStyle(
fontSize: 12.0,
fontStyle: FontStyle.italic,
color: Colors.grey[600],
),
),
const SizedBox(height: 8.0),
// 版權宣告
/*
Text(
'Copyright © $copyrightYear $companyName. All rights reserved.',
style: TextStyle(
fontSize: 10.0,
color: Colors.grey[500],
),
),
*/
],
),
),
],
),
);
}
}
// ------------------------------------
// 單個選單項目的小部件 (MainMenuItemTile)
// ------------------------------------
class MainMenuItemTile extends StatelessWidget {
final MenuItem item;
const MainMenuItemTile({required this.item, super.key});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4.0, // 增加陰影,更有質感
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0)),
child: InkWell(
// 使用 InkWell 獲得點擊時的漣漪效果
onTap: () {
// 點擊後導航到對應的目標畫面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => item.targetScreen,
),
);
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// 選單圖標
Icon(
item.icon,
size: 48.0,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 8.0),
// 選單名稱
Text(
item.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14.0,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
+28
View File
@@ -0,0 +1,28 @@
import './meeting_model.dart';
import '../services/generic_api_service.dart'; // 引入共用服務
class MeetingApiService {
final GenericApiService _apiService = GenericApiService();
final String currentUserId; // 應由登入頁面傳入
MeetingApiService({this.currentUserId = 'admin'});
Future<List<Meeting>> fetchMeetings() async {
// 目標:獲取與當前用戶 (currentUserId) 相關的會議通知。
// 採用過濾邏輯:篩選參與者名單 (meeting_users) 中包含 currentUserId 的記錄。
// 預設參數:頁碼1,每頁100筆,按 startdate 降冪排序 (*表示降冪)
// 格式: 1^100^startdate^*^meeting_users^LIKE^%currentUserId%
String filterPart = "startdate^*^meeting_users^LIKE^%$currentUserId%";
// 完整的 queryFilter 格式:Page^PageSize^SortColumn^SortOrder^FilterColumn^Operator^FilterValue...
String queryFilter = "1^100^$filterPart";
return await _apiService.fetchList<Meeting>(
tableName: "eipmeetingrec_m", // 對應到會議通知表格
pk: "uniqueno", // 主鍵為 uniqueno
queryFilter: queryFilter,
fromJson: (json) => Meeting.fromJson(json),
);
}
}
+138
View File
@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import './meeting_model.dart';
import 'package:intl/intl.dart';
class MeetingDetail extends StatelessWidget {
final Meeting meeting;
const MeetingDetail({required this.meeting, super.key});
// 輔助函式:建立屬性列 (從 todo_detail.dart 最佳化而來)
Widget _buildAttributeRow(BuildContext context, String label, String value, {Color color = Colors.black}) {
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black54),
),
),
Expanded(
child: Text(
value,
style: TextStyle(fontWeight: FontWeight.w600, color: color),
),
),
],
),
);
}
// 輔助函式:格式化日期時間範圍
String _formatDateRange() {
final dateFormat = DateFormat('yyyy/MM/dd');
final startDateStr = meeting.startDate != null ? dateFormat.format(meeting.startDate!) : 'N/A';
final endDateStr = meeting.endDate != null ? dateFormat.format(meeting.endDate!) : startDateStr;
// 檢查日期是否相同
final isSameDate = meeting.startDate != null && meeting.endDate != null &&
meeting.startDate!.day == meeting.endDate!.day &&
meeting.startDate!.month == meeting.endDate!.month &&
meeting.startDate!.year == meeting.endDate!.year;
String dateRange;
if (isSameDate) {
dateRange = startDateStr;
} else {
dateRange = '$startDateStr$endDateStr';
}
final timeRange = (meeting.startTime ?? '') + ' ~ ' + (meeting.endTime ?? '');
return '$dateRange ${timeRange.trim()}';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(meeting.meetingTitle, overflow: TextOverflow.ellipsis),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 會議標題
Text(
meeting.meetingTitle,
style: const TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold, color: Colors.black87),
),
const Divider(height: 24.0),
// 會議屬性表格
_buildAttributeRow(context, '狀態', meeting.statusText, color: meeting.statusColor),
_buildAttributeRow(context, '地點', meeting.meetingPlace ?? '待定'),
_buildAttributeRow(context, '時間範圍', _formatDateRange(), color: Colors.blue),
_buildAttributeRow(context, '主持人 ID', meeting.bossPersonId ?? 'N/A'),
_buildAttributeRow(context, '記錄人 ID', meeting.recPersonId ?? 'N/A'),
_buildAttributeRow(context, '參與者名單', meeting.meetingUsers ?? ''),
const Divider(height: 32.0),
// 會議說明
const Text(
'會議說明:',
style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w600, color: Colors.black87),
),
const SizedBox(height: 8),
Text(
meeting.meetingDesc ?? '無詳細說明。',
style: const TextStyle(fontSize: 16.0, height: 1.5, color: Colors.black54),
textAlign: TextAlign.justify,
),
const Divider(height: 32.0),
// 會議決議
const Text(
'會議決議:',
style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w600, color: Colors.black87),
),
const SizedBox(height: 8),
Text(
meeting.resolution ?? '無決議事項。',
style: const TextStyle(fontSize: 16.0, height: 1.5, color: Colors.black54),
textAlign: TextAlign.justify,
),
const SizedBox(height: 40),
// 底部按鈕 (範例:下載文件)
Center(
child: ElevatedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('待實作下載會議文件功能。')),
);
},
icon: const Icon(Icons.file_download),
label: const Text('下載會議文件'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
textStyle: const TextStyle(fontSize: 18),
),
),
),
],
),
),
);
}
}
+147
View File
@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import './meeting_api.dart';
import './meeting_model.dart';
import './meeting_detail.dart';
class MeetingManager extends StatefulWidget {
// 實際應用中,這裡應該傳入當前用戶 ID
final String currentUserId;
const MeetingManager({this.currentUserId = 'admin', super.key});
@override
State<StatefulWidget> createState() {
return _MeetingManagerState();
}
}
class _MeetingManagerState extends State<MeetingManager> {
late MeetingApiService _apiService;
late Future<List<Meeting>> _meetingsFuture;
@override
void initState() {
super.initState();
// 服務初始化時傳入當前用戶 ID
_apiService = MeetingApiService(currentUserId: widget.currentUserId);
_meetingsFuture = _apiService.fetchMeetings();
}
// 刷新資料的函數
void _refreshMeetings() {
setState(() {
_meetingsFuture = _apiService.fetchMeetings();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('我的會議通知'),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.refresh),
tooltip: '刷新列表',
onPressed: _refreshMeetings,
),
IconButton(
icon: const Icon(Icons.home),
tooltip: '返回主頁',
onPressed: () => Navigator.pop(context),
),
],
),
body: FutureBuilder<List<Meeting>>(
future: _meetingsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('載入失敗: ${snapshot.error}', textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(onPressed: _refreshMeetings, child: const Text('重試')),
],
),
);
} else if (snapshot.hasData && snapshot.data!.isNotEmpty) {
return MeetingList(meetings: snapshot.data!);
} else {
return const Center(child: Text('目前沒有相關會議通知。'));
}
},
),
);
}
}
// -----------------------------------------------------------
// 列表顯示小部件 (MeetingList)
// -----------------------------------------------------------
class MeetingList extends StatelessWidget {
final List<Meeting> meetings;
const MeetingList({required this.meetings, super.key});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: meetings.length,
itemBuilder: (BuildContext context, int index) {
final item = meetings[index];
return Card(
elevation: 3,
margin: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 16.0),
child: InkWell(
onTap: () {
// 點擊項目:導航到詳細頁面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MeetingDetail(meeting: item),
),
);
},
child: ListTile(
// 左側圖示顯示
leading: Icon(
Icons.calendar_month,
color: item.statusColor,
size: 32,
),
title: Text(
item.meetingTitle,
style: const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'地點: ${item.meetingPlace ?? '待定'} | 主持: ${item.bossPersonId ?? 'N/A'}',
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
item.statusText,
style: TextStyle(fontSize: 10, color: item.statusColor),
),
Text(
item.formattedStartDateTime,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
],
),
),
),
);
},
);
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class Meeting {
final int uniqueNo; // uniqueno (Primary Key)
final String meetingTitle; // meeting_title (會議標題)
final String? meetingPlace; // meeting_place (會議地點)
final String? meetingDesc; // meeting_desc (會議說明)
final String? bossPersonId; // boss_personid (主持人 ID)
final String? recPersonId; // rec_personid (記錄人 ID)
final DateTime? startDate; // startdate (開始日期)
final String? startTime; // starttime (開始時間, e.g., '14:00')
final DateTime? endDate; // end_date (結束日期)
final String? endTime; // end_time (結束時間, e.g., '16:00')
final String? resolution; // meeting_resolution (會議決議)
final String? meetingUsers; // meeting_users (參與者名單)
final String? flowFlag; // flowflag (狀態/流程旗標)
Meeting({
required this.uniqueNo,
required this.meetingTitle,
this.meetingPlace,
this.meetingDesc,
this.bossPersonId,
this.recPersonId,
this.startDate,
this.startTime,
this.endDate,
this.endTime,
this.resolution,
this.meetingUsers,
this.flowFlag,
});
// Factory 構造函數:從 API 返回的 JSON (Map) 創建 Meeting 物件
factory Meeting.fromJson(Map<String, dynamic> json) {
DateTime? parseDate(dynamic date) {
if (date is String && date.isNotEmpty) {
// 假設日期格式為 YYYY-MM-DD HH:mm:ss.sss 或 YYYY-MM-DD
return DateTime.tryParse(date);
}
return null;
}
return Meeting(
uniqueNo: json['uniqueno'] as int? ?? 0,
meetingTitle: json['meeting_title'] as String? ?? '無標題會議',
meetingPlace: json['meeting_place'] as String?,
meetingDesc: json['meeting_desc'] as String?,
bossPersonId: json['boss_personid'] as String?,
recPersonId: json['rec_personid'] as String?,
startDate: parseDate(json['startdate']),
startTime: json['starttime'] as String?,
endDate: parseDate(json['end_date']),
endTime: json['end_time'] as String?,
resolution: json['meeting_resolution'] as String?,
meetingUsers: json['meeting_users'] as String?,
flowFlag: json['flowflag'] as String?,
);
}
// Helper: 格式化開始日期和時間
String get formattedStartDateTime {
if (startDate == null) return 'N/A';
final datePart = DateFormat('MM/dd').format(startDate!);
final timePart = startTime ?? '';
return '$datePart ${timePart.isNotEmpty ? timePart : ''}'.trim();
}
// Helper: 獲取會議狀態顏色
Color get statusColor {
// 假設 '1' 表示已定案/已批准, '0' 表示審核中, 其他表示草稿/待開始
switch (flowFlag) {
case '1':
return Colors.green; // 已定案
case '0':
return Colors.orange; // 審核中
default:
return Colors.blue; // 待開始
}
}
// Helper: 獲取會議狀態文字
String get statusText {
switch (flowFlag) {
case '1':
return '已定案';
case '0':
return '審核中';
default:
return '待開始';
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
// 通用的佔位畫面
class PlaceholderScreen extends StatelessWidget {
final String title;
const PlaceholderScreen({required this.title, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Center(
child: Text(
'$title 功能正在開發中...',
style: const TextStyle(fontSize: 24, color: Colors.grey),
),
),
);
}
}
// 各功能頁面 (繼承自 PlaceholderScreen,方便未來替換成真實頁面)
class AttendanceScreen extends PlaceholderScreen {
AttendanceScreen({super.key}) : super(title: '員工打卡');
}
class ApprovalScreen extends PlaceholderScreen {
ApprovalScreen({super.key}) : super(title: '待簽核事項');
}
class TodoScreen extends PlaceholderScreen {
TodoScreen({super.key}) : super(title: '待辦事項');
}
class CalendarScreen extends PlaceholderScreen {
CalendarScreen({super.key}) : super(title: '行事曆');
}
+84
View File
@@ -0,0 +1,84 @@
import 'package:http/http.dart' as http;
import 'dart:convert';
import '../auth_manager.dart'; // 確保引入 AuthManager
// 共用 API 常數
const String BASE_IP = "https://api.gex.com.tw:8033";
const String COMMON_API_ENDPOINT = "xapi/v2/eis_demo/orm_api_v2/3/";
const String COMMON_API_URL = "$BASE_IP/$COMMON_API_ENDPOINT";
class GenericApiService {
/// 通用的獲取列表方法
/// [T] : 目標資料模型 (例如 Announcement 或 Todo)
/// [tableName] : 資料庫表名 (API 參數)
/// [pk] : 主鍵欄位名稱 (API 參數)
/// [queryFilter] : 查詢過濾條件 (API 參數)
/// [fromJson] : 將 Map 轉換為物件的工廠方法 (例如 Announcement.fromJson)
Future<List<T>> fetchList<T>({
required String tableName,
required String pk,
required String queryFilter,
required T Function(Map<String, dynamic>) fromJson,
Map<String, String>? additionalParams, // 允許傳入額外參數 (如分頁)
}) async {
// 1. 讀取 Token
String? token = await AuthManager.getToken();
if (token == null) {
print("錯誤:未找到登入 Token");
// 根據需求決定是否拋出錯誤或回傳空陣列
throw Exception('未登入');
}
// 2. 建立 API 參數
final Map<String, String> params = {
"token": token,
"_\$_tableName": tableName,
"_\$_pk": pk,
"_\$_action": "P",
"_\$_query_filter": queryFilter,
};
// 如果有額外參數,合併進去
if (additionalParams != null) {
params.addAll(additionalParams);
}
try {
final response = await http.post(
Uri.parse(COMMON_API_URL),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
);
if (response.statusCode == 200) {
final Map<String, dynamic> responseData = json.decode(response.body);
// 檢查 code
if (responseData['code'] == 0) {
final dynamic dataObject = responseData['data'];
// 檢查結構是否正確 (data -> items List)
if (dataObject is Map && dataObject['items'] is List) {
List<dynamic> listJson = dataObject['items'];
// 核心魔法:使用傳入的 fromJson 函數將資料轉換為 T 類型
return listJson.map((json) => fromJson(json as Map<String, dynamic>)).toList();
} else {
return []; // 結構不符或無資料
}
} else {
throw Exception(responseData['msg'] ?? 'API 邏輯錯誤');
}
} else {
throw Exception('HTTP 錯誤: ${response.statusCode}');
}
} catch (e) {
print('API 請求錯誤 ($tableName): $e');
throw Exception('無法載入資料: $e');
}
}
}
+157
View File
@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import '../auth_manager.dart'; // 引入剛才修正的 AuthManager
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
String _currentUserId = '載入中...';
bool _isNotificationEnabled = true;
@override
void initState() {
super.initState();
_loadUserInfo();
}
// 從本地儲存載入使用者工號
Future<void> _loadUserInfo() async {
final userId = await AuthManager.getUserId();
setState(() {
_currentUserId = userId ?? '未登入';
});
}
// 處理登出邏輯
void _handleLogout() async {
// 顯示確認對話框
bool? confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('確認登出'),
content: const Text('您確定要登出系統嗎?登出後將清除本地緩存資訊。'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('取消')),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('確定', style: TextStyle(color: Colors.red))
),
],
),
);
if (confirm == true) {
await AuthManager.logout();
// 導向登入頁面並清空路由棧
if (mounted) {
Navigator.of(context).pushNamedAndRemoveUntil('/login', (route) => false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('系統設定'),
centerTitle: true,
),
body: ListView(
children: [
// 區塊一:個人資訊
_buildSectionHeader('個人帳戶'),
ListTile(
leading: const CircleAvatar(child: Icon(Icons.person)),
title: const Text('當前使用者'),
subtitle: Text('工號:$_currentUserId'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () { /* 導向個人詳細資料 */ },
),
const Divider(),
// 區塊二:系統設定
_buildSectionHeader('偏好設定'),
SwitchListTile(
secondary: const Icon(Icons.notifications_active_outlined),
title: const Text('推播通知'),
subtitle: const Text('接收即時會議與 Issue 通知'),
value: _isNotificationEnabled,
onChanged: (bool value) {
setState(() {
_isNotificationEnabled = value;
});
},
),
ListTile(
leading: const Icon(Icons.dark_mode_outlined),
title: const Text('深色模式'),
trailing: const Text('跟隨系統'),
onTap: () { /* 實作切換主題邏輯 */ },
),
ListTile(
leading: const Icon(Icons.language),
title: const Text('語系設定'),
trailing: const Text('繁體中文'),
onTap: () { /* 實作切換語言邏輯 */ },
),
const Divider(),
// 區塊三:關於與支援
_buildSectionHeader('其他'),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('版本資訊'),
trailing: const Text('v1.0.2'),
),
ListTile(
leading: const Icon(Icons.help_outline),
title: const Text('使用手冊'),
onTap: () { /* 開啟 PDF 或網頁 */ },
),
const SizedBox(height: 30),
// 登出按鈕
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ElevatedButton.icon(
onPressed: _handleLogout,
icon: const Icon(Icons.logout),
label: const Text('安全登出'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade50,
foregroundColor: Colors.red,
side: BorderSide(color: Colors.red.shade200),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))
),
),
),
const SizedBox(height: 20),
],
),
);
}
// 輔助函式:建立區塊標題
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
);
}
}
+22
View File
@@ -0,0 +1,22 @@
import './todo_model.dart';
import '../services/generic_api_service.dart'; // 引入共用服務
class TodoApiService {
final GenericApiService _apiService = GenericApiService();
final String currentUserId;
TodoApiService({this.currentUserId = 'admin'});
Future<List<Todo>> fetchTodos() async {
return await _apiService.fetchList<Todo>(
tableName: "eip_todolist",
pk: "id",
// 如果 queryFilter 需要動態包含使用者 ID,可以在這裡字串插值
// 假設原程式碼邏輯是 "1^10^id^*^^pmsm02^^"
queryFilter: "1^10^id^*^^pmsm02^^",
fromJson: (json) => Todo.fromJson(json),
// 如果未來需要傳遞其他參數 (如 userID),可以用 additionalParams
// additionalParams: { "userId": currentUserId },
);
}
}
+97
View File
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import './todo_model.dart';
class TodoDetail extends StatelessWidget {
final Todo todo;
const TodoDetail({required this.todo, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(todo.taskName, overflow: TextOverflow.ellipsis),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 任務標題
Text(
todo.taskName,
style: const TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold, color: Colors.black87),
),
const Divider(height: 24.0),
// 任務屬性表格 (簡潔顯示)
_buildAttributeRow(context, '狀態', todo.status ?? 'N/A', todo.statusColor),
_buildAttributeRow(context, '優先級', todo.priority ?? 'N/A', Colors.red),
_buildAttributeRow(context, '截止日期', todo.formattedEndDate, Colors.blue),
_buildAttributeRow(context, '建立者', todo.createdBy ?? 'N/A', Colors.grey),
_buildAttributeRow(context, '分類', todo.className, Colors.purple),
const Divider(height: 32.0),
// 詳細說明
const Text(
'詳細說明:',
style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w600, color: Colors.black87),
),
const SizedBox(height: 8),
Text(
todo.description ?? '無詳細說明。',
style: const TextStyle(fontSize: 16.0, height: 1.5, color: Colors.black54),
textAlign: TextAlign.justify,
),
const SizedBox(height: 40),
// 底部按鈕 (例如:標記完成)
Center(
child: ElevatedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已標記任務 "${todo.taskName}" 待實作更新狀態。')),
);
// 實際應用中,這裡會呼叫 API 更新 pbi_status 為 'DONE'
},
icon: const Icon(Icons.check_circle_outline),
label: const Text('標記為已完成'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
backgroundColor: Colors.green,
foregroundColor: Colors.white,
textStyle: const TextStyle(fontSize: 18),
),
),
),
],
),
),
);
}
Widget _buildAttributeRow(BuildContext context, String label, String value, Color color) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black),
),
),
Expanded(
child: Text(
value,
style: TextStyle(fontWeight: FontWeight.w600, color: color),
),
),
],
),
);
}
}
+149
View File
@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import './todo_api.dart';
import './todo_model.dart';
import './todo_detail.dart'; // 稍後創建
// import './main.dart'; // 確保可以訪問 MainMenu
class TodoManager extends StatefulWidget {
// 實際應用中,這裡應該傳入當前用戶 ID
final String currentUserId;
const TodoManager({this.currentUserId = 'admin', super.key});
@override
State<StatefulWidget> createState() {
return _TodoManagerState();
}
}
class _TodoManagerState extends State<TodoManager> {
late TodoApiService _apiService;
late Future<List<Todo>> _todosFuture;
@override
void initState() {
super.initState();
_apiService = TodoApiService(currentUserId: widget.currentUserId);
// 頁面加載時自動開始獲取資料
_todosFuture = _apiService.fetchTodos();
}
// 刷新資料的函數
void _refreshTodos() {
setState(() {
_todosFuture = _apiService.fetchTodos();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('我的待辦事項'),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.refresh),
tooltip: '刷新列表',
onPressed: _refreshTodos,
),
// 假設這裡使用 Navigator.pop(context) 即可返回 MainMenu
IconButton(
icon: const Icon(Icons.home),
tooltip: '返回主頁',
onPressed: () => Navigator.pop(context),
),
],
),
body: FutureBuilder<List<Todo>>(
future: _todosFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('載入失敗: ${snapshot.error}', textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(onPressed: _refreshTodos, child: const Text('重試')),
],
),
);
} else if (snapshot.hasData && snapshot.data!.isNotEmpty) {
return TodoList(todos: snapshot.data!);
} else {
return const Center(child: Text('目前沒有待辦事項。工作很輕鬆!'));
}
},
),
);
}
}
// -----------------------------------------------------------
// 列表顯示小部件 (TodoList)
// -----------------------------------------------------------
class TodoList extends StatelessWidget {
final List<Todo> todos;
const TodoList({required this.todos, super.key});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: todos.length,
itemBuilder: (BuildContext context, int index) {
final item = todos[index];
return Card(
elevation: 3,
margin: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 16.0),
child: InkWell(
onTap: () {
// 點擊項目:導航到詳細頁面
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TodoDetail(todo: item),
),
);
},
child: ListTile(
// 左側狀態指示器
leading: Container(
width: 10,
decoration: BoxDecoration(
color: item.statusColor,
borderRadius: BorderRadius.circular(5),
),
),
title: Text(
item.taskName,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'分類: ${item.className} | 優先級: ${item.priority ?? '一般'}',
style: const TextStyle(fontSize: 12),
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'截止日',
style: TextStyle(fontSize: 10, color: item.statusColor),
),
Text(
item.formattedEndDate,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
],
),
),
),
);
},
);
}
}
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class Todo {
final int id;
final String className; // todolist_class (類別/分類)
final String taskName; // task_name (任務名稱/標題)
final String? description; // task_desc (詳細說明)
final String? priority; // issue_priority (優先級)
final String? status; // pbi_status (狀態)
final DateTime? endDate; // end_date (預計完成日期)
final String? createdBy; // create_user (建立者)
final DateTime? createDate; // create_date (建立日期)
Todo({
required this.id,
required this.className,
required this.taskName,
this.description,
this.priority,
this.status,
this.endDate,
this.createdBy,
this.createDate,
});
// Factory 構造函數:從 API 返回的 JSON (Map) 創建 Todo 物件
factory Todo.fromJson(Map<String, dynamic> json) {
// 輔助函數:安全解析日期字串
DateTime? parseDate(dynamic date) {
if (date is String && date.isNotEmpty) {
// 假設日期格式為 YYYY-MM-DD HH:mm:ss.sss 或 YYYY-MM-DD
return DateTime.tryParse(date);
}
return null;
}
return Todo(
id: json['id'] as int? ?? 0,
className: json['todolist_class'] as String? ?? '未分類',
taskName: json['task_name'] as String? ?? '無任務標題',
description: json['task_desc'] as String?,
priority: json['issue_priority'] as String?,
status: json['pbi_status'] as String?,
endDate: parseDate(json['end_date']),
createdBy: json['create_user'] as String?,
createDate: parseDate(json['create_date']),
);
}
// 格式化日期,用於列表顯示
String get formattedEndDate {
if (endDate == null) return 'N/A';
return DateFormat('yyyy/MM/dd').format(endDate!);
}
// 根據狀態獲取顏色 (例如:已完成/進行中)
Color get statusColor {
switch (status?.toUpperCase()) {
case 'WIP': // Work In Progress
return Colors.blue;
case 'DONE': // Completed
return Colors.green;
case 'HOLD': // On Hold
return Colors.orange;
default:
return Colors.grey;
}
}
}
+530
View File
@@ -0,0 +1,530 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.0"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crypto:
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
url: "https://pub.dev"
source: hosted
version: "1.0.8"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
geolocator:
dependency: "direct main"
description:
name: geolocator
sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2
url: "https://pub.dev"
source: hosted
version: "13.0.4"
geolocator_android:
dependency: transitive
description:
name: geolocator_android
sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d
url: "https://pub.dev"
source: hosted
version: "4.6.2"
geolocator_apple:
dependency: transitive
description:
name: geolocator_apple
sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22
url: "https://pub.dev"
source: hosted
version: "2.3.13"
geolocator_platform_interface:
dependency: transitive
description:
name: geolocator_platform_interface
sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67"
url: "https://pub.dev"
source: hosted
version: "4.2.6"
geolocator_web:
dependency: transitive
description:
name: geolocator_web
sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172
url: "https://pub.dev"
source: hosted
version: "4.1.3"
geolocator_windows:
dependency: transitive
description:
name: geolocator_windows
sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6"
url: "https://pub.dev"
source: hosted
version: "0.2.5"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
intl:
dependency: "direct main"
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
url: "https://pub.dev"
source: hosted
version: "0.19.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0
url: "https://pub.dev"
source: hosted
version: "6.0.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc"
url: "https://pub.dev"
source: hosted
version: "2.4.18"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
simple_gesture_detector:
dependency: transitive
description:
name: simple_gesture_detector
sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3
url: "https://pub.dev"
source: hosted
version: "0.2.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
table_calendar:
dependency: "direct main"
description:
name: table_calendar
sha256: b2896b7c86adf3a4d9c911d860120fe3dbe03c85db43b22fd61f14ee78cdbb63
url: "https://pub.dev"
source: hosted
version: "3.1.3"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.7"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
url: "https://pub.dev"
source: hosted
version: "6.3.28"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
url: "https://pub.dev"
source: hosted
version: "6.3.6"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid:
dependency: transitive
description:
name: uuid
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
url: "https://pub.dev"
source: hosted
version: "4.5.2"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks:
dart: ">=3.10.0 <4.0.0"
flutter: ">=3.35.0"
+103
View File
@@ -0,0 +1,103 @@
name: learn
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.10.0
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
# 新增 toast
# fluttertoast:
# 新增 HTTP 請求套件
http: ^1.1.0
# 新增 MD5 加密套件 (用於密碼加密)
crypto: ^3.0.3
# 公告新增
intl: ^0.19.0
shared_preferences: ^2.2.2 # 請檢查最新的穩定版本
url_launcher: ^6.2.2 # 請使用當前最新的穩定版本
geolocator: ^13.0.1
table_calendar: ^3.1.2
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- assets/F16-02.jpg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
+30
View File
@@ -0,0 +1,30 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:learn/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const ZenApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="learn">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>learn</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "learn",
"short_name": "learn",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}