2025-12-29 First Commit
@@ -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
|
||||
@@ -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'
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!)}' : ''}";
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: '行事曆');
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('提交申請'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 '草稿';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 '待開始';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: '行事曆');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
After Width: | Height: | Size: 917 B |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||