From 2f614fc5993de62fe2f9c45246afad8c14d9b3e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Sat, 12 Nov 2022 23:00:55 +0100 Subject: [PATCH 01/66] Extracted common snippet model extensions --- .../snipmeandroid/bridge/Bridge.java | 169 ++++++++++++++++++ .../snipmeandroid/bridge/ModelPlugin.kt | 83 ++++++++- .../bridge/detail/DetailModelPlugin.kt | 34 ++++ .../bridge/main/MainModelPlugin.kt | 72 +------- flutter_module/bridge/main_model.dart | 12 ++ flutter_module/lib/model/main_model.dart | 134 ++++++++++++++ 6 files changed, 432 insertions(+), 72 deletions(-) create mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java index 0914b54..650c2f2 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java @@ -698,6 +698,84 @@ public static final class Builder { return pigeonResult; } } + + /** Generated class from Pigeon that represents data sent in messages. */ + public static class DetailModelStateData { + private @Nullable ModelState state; + public @Nullable ModelState getState() { return state; } + public void setState(@Nullable ModelState setterArg) { + this.state = setterArg; + } + + private @Nullable Boolean is_loading; + public @Nullable Boolean getIs_loading() { return is_loading; } + public void setIs_loading(@Nullable Boolean setterArg) { + this.is_loading = setterArg; + } + + private @Nullable Snippet data; + public @Nullable Snippet getData() { return data; } + public void setData(@Nullable Snippet setterArg) { + this.data = setterArg; + } + + private @Nullable String error; + public @Nullable String getError() { return error; } + public void setError(@Nullable String setterArg) { + this.error = setterArg; + } + + public static final class Builder { + private @Nullable ModelState state; + public @NonNull Builder setState(@Nullable ModelState setterArg) { + this.state = setterArg; + return this; + } + private @Nullable Boolean is_loading; + public @NonNull Builder setIs_loading(@Nullable Boolean setterArg) { + this.is_loading = setterArg; + return this; + } + private @Nullable Snippet data; + public @NonNull Builder setData(@Nullable Snippet setterArg) { + this.data = setterArg; + return this; + } + private @Nullable String error; + public @NonNull Builder setError(@Nullable String setterArg) { + this.error = setterArg; + return this; + } + public @NonNull DetailModelStateData build() { + DetailModelStateData pigeonReturn = new DetailModelStateData(); + pigeonReturn.setState(state); + pigeonReturn.setIs_loading(is_loading); + pigeonReturn.setData(data); + pigeonReturn.setError(error); + return pigeonReturn; + } + } + @NonNull Map toMap() { + Map toMapResult = new HashMap<>(); + toMapResult.put("state", state == null ? null : state.index); + toMapResult.put("is_loading", is_loading); + toMapResult.put("data", (data == null) ? null : data.toMap()); + toMapResult.put("error", error); + return toMapResult; + } + static @NonNull DetailModelStateData fromMap(@NonNull Map map) { + DetailModelStateData pigeonResult = new DetailModelStateData(); + Object state = map.get("state"); + pigeonResult.setState(state == null ? null : ModelState.values()[(int)state]); + Object is_loading = map.get("is_loading"); + pigeonResult.setIs_loading((Boolean)is_loading); + Object data = map.get("data"); + pigeonResult.setData((data == null) ? null : Snippet.fromMap((Map)data)); + Object error = map.get("error"); + pigeonResult.setError((String)error); + return pigeonResult; + } + } private static class MainModelBridgeCodec extends StandardMessageCodec { public static final MainModelBridgeCodec INSTANCE = new MainModelBridgeCodec(); private MainModelBridgeCodec() {} @@ -934,6 +1012,97 @@ static void setup(BinaryMessenger binaryMessenger, MainModelBridge api) { } } } + private static class DetailModelBridgeCodec extends StandardMessageCodec { + public static final DetailModelBridgeCodec INSTANCE = new DetailModelBridgeCodec(); + private DetailModelBridgeCodec() {} + @Override + protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) { + switch (type) { + case (byte)128: + return DetailModelStateData.fromMap((Map) readValue(buffer)); + + case (byte)129: + return Owner.fromMap((Map) readValue(buffer)); + + case (byte)130: + return Snippet.fromMap((Map) readValue(buffer)); + + case (byte)131: + return SnippetCode.fromMap((Map) readValue(buffer)); + + case (byte)132: + return SnippetLanguage.fromMap((Map) readValue(buffer)); + + case (byte)133: + return SyntaxToken.fromMap((Map) readValue(buffer)); + + default: + return super.readValueOfType(type, buffer); + + } + } + @Override + protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) { + if (value instanceof DetailModelStateData) { + stream.write(128); + writeValue(stream, ((DetailModelStateData) value).toMap()); + } else + if (value instanceof Owner) { + stream.write(129); + writeValue(stream, ((Owner) value).toMap()); + } else + if (value instanceof Snippet) { + stream.write(130); + writeValue(stream, ((Snippet) value).toMap()); + } else + if (value instanceof SnippetCode) { + stream.write(131); + writeValue(stream, ((SnippetCode) value).toMap()); + } else + if (value instanceof SnippetLanguage) { + stream.write(132); + writeValue(stream, ((SnippetLanguage) value).toMap()); + } else + if (value instanceof SyntaxToken) { + stream.write(133); + writeValue(stream, ((SyntaxToken) value).toMap()); + } else +{ + super.writeValue(stream, value); + } + } + } + + /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ + public interface DetailModelBridge { + @NonNull DetailModelStateData getState(); + + /** The codec used by DetailModelBridge. */ + static MessageCodec getCodec() { + return DetailModelBridgeCodec.INSTANCE; } + /**Sets up an instance of `DetailModelBridge` to handle messages through the `binaryMessenger`. */ + static void setup(BinaryMessenger binaryMessenger, DetailModelBridge api) { + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.getState", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + DetailModelStateData output = api.getState(); + wrapped.put("result", output); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + } + } @NonNull private static Map wrapError(@NonNull Throwable exception) { Map errorMap = new HashMap<>(); errorMap.put("message", exception.toString()); diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/ModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/ModelPlugin.kt index 8d8a356..089045f 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/ModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/ModelPlugin.kt @@ -1,8 +1,15 @@ package pl.tkadziolka.snipmeandroid.bridge +import android.text.Spanned +import android.text.format.DateUtils +import android.text.style.ForegroundColorSpan +import androidx.core.text.getSpans import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.BinaryMessenger import org.koin.core.component.KoinComponent +import pl.tkadziolka.snipmeandroid.domain.reaction.UserReaction +import pl.tkadziolka.snipmeandroid.domain.snippets.* +import java.util.* /* flutter pub run pigeon \ @@ -12,7 +19,7 @@ import org.koin.core.component.KoinComponent --java_package "pl.tkadziolka.snipmeandroid.bridge" */ -abstract class ModelPlugin: FlutterPlugin, KoinComponent { +abstract class ModelPlugin : FlutterPlugin, KoinComponent { abstract fun onSetup(messenger: BinaryMessenger, bridge: T?) @@ -23,4 +30,76 @@ abstract class ModelPlugin: FlutterPlugin, KoinComponent { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { onSetup(binding.binaryMessenger, null) } -} \ No newline at end of file +} + +fun Snippet.toModelData(): Bridge.Snippet { + val it = this + return Bridge.Snippet().apply { + uuid = it.uuid + title = it.title + code = it.code.toModelSnippetCode() + language = it.language.toModelSnippetLanguage() + owner = it.owner.toModelOwner() + isOwner = it.isOwner + voteResult = (it.numberOfLikes - it.numberOfDislikes).toLong() + userReaction = it.userReaction.toModelUserReaction() + isLiked = it.userReaction.toModelReactionState(UserReaction.LIKE) + isDisliked = it.userReaction.toModelReactionState(UserReaction.DISLIKE) + isSaved = calculateSavedState(it.isOwner, it.visibility) + timeAgo = DateUtils.getRelativeTimeSpanString( + it.modifiedAt.time, + Date().time, + DateUtils.SECOND_IN_MILLIS + ).toString() + } +} + +private fun Owner.toModelOwner() = + Bridge.Owner().let { + it.id = id.toLong() + it.login = login + it + } + +private fun SnippetCode.toModelSnippetCode() = + Bridge.SnippetCode().let { + it.raw = raw + it.tokens = highlighted.getSpans().map { span -> + span.toSyntaxToken(highlighted) + } + it + } + +private fun SnippetLanguage.toModelSnippetLanguage() = + Bridge.SnippetLanguage().let { + it.raw = raw + it.type = Bridge.SnippetLanguageType.valueOf(type.name) + it + } + +private fun UserReaction.toModelUserReaction(): Bridge.UserReaction = + when (this) { + UserReaction.LIKE -> Bridge.UserReaction.LIKE + UserReaction.DISLIKE -> Bridge.UserReaction.DISLIKE + else -> Bridge.UserReaction.NONE + } + +private fun UserReaction.toModelReactionState(reaction: UserReaction) = + if (this == UserReaction.NONE) null else this == reaction + +private fun calculateSavedState( + isOwner: Boolean, + visibility: SnippetVisibility +): Boolean? = when { + isOwner && visibility == SnippetVisibility.PUBLIC -> false + isOwner && visibility == SnippetVisibility.PRIVATE -> true + else -> null +} + +private fun ForegroundColorSpan.toSyntaxToken(spannable: Spanned) = + Bridge.SyntaxToken().let { + it.start = spannable.getSpanStart(this).toLong() + it.end = spannable.getSpanEnd(this).toLong() + it.color = foregroundColor.toLong() + it + } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt new file mode 100644 index 0000000..872fe69 --- /dev/null +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt @@ -0,0 +1,34 @@ +package pl.tkadziolka.snipmeandroid.bridge.detail + +import io.flutter.plugin.common.BinaryMessenger +import org.koin.core.component.inject +import pl.tkadziolka.snipmeandroid.bridge.Bridge +import pl.tkadziolka.snipmeandroid.bridge.ModelPlugin +import pl.tkadziolka.snipmeandroid.bridge.toModelData +import pl.tkadziolka.snipmeandroid.ui.detail.DetailViewState +import pl.tkadziolka.snipmeandroid.ui.detail.Loaded +import pl.tkadziolka.snipmeandroid.ui.detail.Loading + +class DetailModelPlugin: ModelPlugin(), Bridge.DetailModelBridge { + + private val model: DetailModel by inject() + + override fun getState(): Bridge.DetailModelStateData = getData(model.state.value) + + override fun onSetup(messenger: BinaryMessenger, bridge: Bridge.DetailModelBridge?) { + Bridge.DetailModelBridge.setup(messenger, bridge) + } + + private fun getData(viewState: DetailViewState) = Bridge.DetailModelStateData().apply { + state = viewState.toModelState() + is_loading = viewState is Loading + data = (viewState as? Loaded)?.snippet?.toModelData() + } + + private fun DetailViewState.toModelState() = + when (this) { + Loading -> Bridge.ModelState.LOADING + is Loaded -> Bridge.ModelState.LOADED + else -> Bridge.ModelState.ERROR + } +} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt index bda4a14..b38d183 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt @@ -8,6 +8,7 @@ import io.flutter.plugin.common.BinaryMessenger import org.koin.core.component.inject import pl.tkadziolka.snipmeandroid.bridge.Bridge import pl.tkadziolka.snipmeandroid.bridge.ModelPlugin +import pl.tkadziolka.snipmeandroid.bridge.toModelData import pl.tkadziolka.snipmeandroid.domain.reaction.UserReaction import pl.tkadziolka.snipmeandroid.domain.snippets.* import pl.tkadziolka.snipmeandroid.ui.main.* @@ -76,74 +77,5 @@ class MainModelPlugin : ModelPlugin(), Bridge.MainModelB is Error -> Bridge.ModelState.ERROR } - private fun List.toModelData() = map { - Bridge.Snippet().apply { - uuid = it.uuid - title = it.title - code = it.code.toModelSnippetCode() - language = it.language.toModelSnippetLanguage() - owner = it.owner.toModelOwner() - isOwner = it.isOwner - voteResult = (it.numberOfLikes - it.numberOfDislikes).toLong() - userReaction = it.userReaction.toModelUserReaction() - isLiked = it.userReaction.toModelReactionState(UserReaction.LIKE) - isDisliked = it.userReaction.toModelReactionState(UserReaction.DISLIKE) - isSaved = calculateSavedState(it.isOwner, it.visibility) - timeAgo = DateUtils.getRelativeTimeSpanString( - it.modifiedAt.time, - Date().time, - DateUtils.SECOND_IN_MILLIS - ).toString() - } - } - - private fun Owner.toModelOwner() = - Bridge.Owner().let { - it.id = id.toLong() - it.login = login - it - } - - private fun SnippetCode.toModelSnippetCode() = - Bridge.SnippetCode().let { - it.raw = raw - it.tokens = highlighted.getSpans().map { span -> - span.toSyntaxToken(highlighted) - } - it - } - - private fun SnippetLanguage.toModelSnippetLanguage() = - Bridge.SnippetLanguage().let { - it.raw = raw - it.type = Bridge.SnippetLanguageType.valueOf(type.name) - it - } - - private fun ForegroundColorSpan.toSyntaxToken(spannable: Spanned) = - Bridge.SyntaxToken().let { - it.start = spannable.getSpanStart(this).toLong() - it.end = spannable.getSpanEnd(this).toLong() - it.color = foregroundColor.toLong() - it - } - - private fun UserReaction.toModelUserReaction(): Bridge.UserReaction = - when (this) { - UserReaction.LIKE -> Bridge.UserReaction.LIKE - UserReaction.DISLIKE -> Bridge.UserReaction.DISLIKE - else -> Bridge.UserReaction.NONE - } - - private fun UserReaction.toModelReactionState(reaction: UserReaction) = - if (this == UserReaction.NONE) null else this == reaction - - private fun calculateSavedState( - isOwner: Boolean, - visibility: SnippetVisibility - ): Boolean? = when { - isOwner && visibility == SnippetVisibility.PUBLIC -> false - isOwner && visibility == SnippetVisibility.PRIVATE -> true - else -> null - } + private fun List.toModelData() = map { it.toModelData() } } diff --git a/flutter_module/bridge/main_model.dart b/flutter_module/bridge/main_model.dart index 8893b2c..2f5d9a4 100644 --- a/flutter_module/bridge/main_model.dart +++ b/flutter_module/bridge/main_model.dart @@ -117,6 +117,13 @@ class MainModelEventData { String? message; } +class DetailModelStateData { + ModelState? state; + bool? is_loading; + Snippet? data; + String? error; +} + // Api @HostApi() @@ -140,3 +147,8 @@ abstract class MainModelBridge { @TaskQueue(type: TaskQueueType.serialBackgroundThread) void refreshSnippetUpdates(); } + +@HostApi() +abstract class DetailModelBridge { + DetailModelStateData getState(); +} \ No newline at end of file diff --git a/flutter_module/lib/model/main_model.dart b/flutter_module/lib/model/main_model.dart index 3177f09..ada18b3 100644 --- a/flutter_module/lib/model/main_model.dart +++ b/flutter_module/lib/model/main_model.dart @@ -347,6 +347,43 @@ class MainModelEventData { } } +class DetailModelStateData { + DetailModelStateData({ + this.state, + this.is_loading, + this.data, + this.error, + }); + + ModelState? state; + bool? is_loading; + Snippet? data; + String? error; + + Object encode() { + final Map pigeonMap = {}; + pigeonMap['state'] = state?.index; + pigeonMap['is_loading'] = is_loading; + pigeonMap['data'] = data?.encode(); + pigeonMap['error'] = error; + return pigeonMap; + } + + static DetailModelStateData decode(Object message) { + final Map pigeonMap = message as Map; + return DetailModelStateData( + state: pigeonMap['state'] != null + ? ModelState.values[pigeonMap['state']! as int] + : null, + is_loading: pigeonMap['is_loading'] as bool?, + data: pigeonMap['data'] != null + ? Snippet.decode(pigeonMap['data']!) + : null, + error: pigeonMap['error'] as String?, + ); + } +} + class _MainModelBridgeCodec extends StandardMessageCodec{ const _MainModelBridgeCodec(); @override @@ -594,3 +631,100 @@ class MainModelBridge { } } } + +class _DetailModelBridgeCodec extends StandardMessageCodec{ + const _DetailModelBridgeCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is DetailModelStateData) { + buffer.putUint8(128); + writeValue(buffer, value.encode()); + } else + if (value is Owner) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else + if (value is Snippet) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else + if (value is SnippetCode) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else + if (value is SnippetLanguage) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); + } else + if (value is SyntaxToken) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); + } else +{ + super.writeValue(buffer, value); + } + } + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 128: + return DetailModelStateData.decode(readValue(buffer)!); + + case 129: + return Owner.decode(readValue(buffer)!); + + case 130: + return Snippet.decode(readValue(buffer)!); + + case 131: + return SnippetCode.decode(readValue(buffer)!); + + case 132: + return SnippetLanguage.decode(readValue(buffer)!); + + case 133: + return SyntaxToken.decode(readValue(buffer)!); + + default: + return super.readValueOfType(type, buffer); + + } + } +} + +class DetailModelBridge { + /// Constructor for [DetailModelBridge]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + DetailModelBridge({BinaryMessenger? binaryMessenger}) : _binaryMessenger = binaryMessenger; + final BinaryMessenger? _binaryMessenger; + + static const MessageCodec codec = _DetailModelBridgeCodec(); + + Future getState() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.DetailModelBridge.getState', codec, binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send(null) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else if (replyMap['result'] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (replyMap['result'] as DetailModelStateData?)!; + } + } +} From 7de3ff4491e14b2b1cfd64f6bf39dc120d777b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Sat, 12 Nov 2022 23:43:37 +0100 Subject: [PATCH 02/66] Finished detail model --- .../snipmeandroid/bridge/Bridge.java | 132 ++++++++++++++++++ .../bridge/detail/DetailModelPlugin.kt | 24 ++++ .../bridge/main/MainModelPlugin.kt | 8 +- .../snipmeandroid/ui/main/MainActivity.kt | 4 +- flutter_module/bridge/main_model.dart | 18 +++ flutter_module/lib/main.dart | 6 +- flutter_module/lib/model/main_model.dart | 132 ++++++++++++++++++ .../navigation/details/details_navigator.dart | 9 +- .../presentation/screens/details_screen.dart | 128 +++++++++++------ .../lib/presentation/screens/main_screen.dart | 16 +-- .../utils/extensions/state_extensions.dart | 19 +++ 11 files changed, 430 insertions(+), 66 deletions(-) create mode 100644 flutter_module/lib/utils/extensions/state_extensions.dart diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java index 650c2f2..6290a9d 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java @@ -1076,6 +1076,12 @@ protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ public interface DetailModelBridge { @NonNull DetailModelStateData getState(); + void load(@NonNull String uuid); + void like(); + void dislike(); + void save(); + void copyToClipboard(); + void share(); /** The codec used by DetailModelBridge. */ static MessageCodec getCodec() { @@ -1101,6 +1107,132 @@ static void setup(BinaryMessenger binaryMessenger, DetailModelBridge api) { channel.setMessageHandler(null); } } + { + BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.load", getCodec(), taskQueue); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList)message; + assert args != null; + String uuidArg = (String)args.get(0); + if (uuidArg == null) { + throw new NullPointerException("uuidArg unexpectedly null."); + } + api.load(uuidArg); + wrapped.put("result", null); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.like", getCodec(), taskQueue); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + api.like(); + wrapped.put("result", null); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.dislike", getCodec(), taskQueue); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + api.dislike(); + wrapped.put("result", null); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.save", getCodec(), taskQueue); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + api.save(); + wrapped.put("result", null); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.copyToClipboard", getCodec(), taskQueue); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + api.copyToClipboard(); + wrapped.put("result", null); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.share", getCodec(), taskQueue); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + api.share(); + wrapped.put("result", null); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } } } @NonNull private static Map wrapError(@NonNull Throwable exception) { diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt index 872fe69..6e0f4ae 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt @@ -19,6 +19,30 @@ class DetailModelPlugin: ModelPlugin(), Bridge.DetailM Bridge.DetailModelBridge.setup(messenger, bridge) } + override fun load(uuid: String) { + model.load(uuid) + } + + override fun like() { + model.like() + } + + override fun dislike() { + model.dislike() + } + + override fun save() { + TODO("Not yet implemented") + } + + override fun copyToClipboard() { + model.copyToClipboard() + } + + override fun share() { + TODO("Not yet implemented") + } + private fun getData(viewState: DetailViewState) = Bridge.DetailModelStateData().apply { state = viewState.toModelState() is_loading = viewState is Loading diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt index b38d183..5962b71 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt @@ -1,19 +1,13 @@ package pl.tkadziolka.snipmeandroid.bridge.main -import android.text.Spanned -import android.text.format.DateUtils -import android.text.style.ForegroundColorSpan -import androidx.core.text.getSpans import io.flutter.plugin.common.BinaryMessenger import org.koin.core.component.inject import pl.tkadziolka.snipmeandroid.bridge.Bridge import pl.tkadziolka.snipmeandroid.bridge.ModelPlugin import pl.tkadziolka.snipmeandroid.bridge.toModelData -import pl.tkadziolka.snipmeandroid.domain.reaction.UserReaction -import pl.tkadziolka.snipmeandroid.domain.snippets.* +import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet import pl.tkadziolka.snipmeandroid.ui.main.* import pl.tkadziolka.snipmeandroid.util.view.SnippetFilter -import java.util.* class MainModelPlugin : ModelPlugin(), Bridge.MainModelBridge { diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainActivity.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainActivity.kt index 11459af..b3f7902 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainActivity.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainActivity.kt @@ -8,11 +8,12 @@ import io.flutter.embedding.engine.FlutterEngineCache import io.flutter.embedding.engine.dart.DartExecutor import pl.tkadziolka.snipmeandroid.bridge.main.MainModelPlugin import pl.tkadziolka.snipmeandroid.R +import pl.tkadziolka.snipmeandroid.bridge.detail.DetailModelPlugin class MainActivity : AppCompatActivity() { private lateinit var flutterEngine : FlutterEngine - private val cachedEngineId = "ENGINE_1" + private val cachedEngineId = "ID_CACHED_FLUTTER_ENGINE" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -27,6 +28,7 @@ class MainActivity : AppCompatActivity() { ) flutterEngine.plugins.add(MainModelPlugin()) + flutterEngine.plugins.add(DetailModelPlugin()) // Cache the FlutterEngine to be used by FlutterActivity. FlutterEngineCache diff --git a/flutter_module/bridge/main_model.dart b/flutter_module/bridge/main_model.dart index 2f5d9a4..fb21b70 100644 --- a/flutter_module/bridge/main_model.dart +++ b/flutter_module/bridge/main_model.dart @@ -151,4 +151,22 @@ abstract class MainModelBridge { @HostApi() abstract class DetailModelBridge { DetailModelStateData getState(); + + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + void load(String uuid); + + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + void like(); + + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + void dislike(); + + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + void save(); + + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + void copyToClipboard(); + + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + void share(); } \ No newline at end of file diff --git a/flutter_module/lib/main.dart b/flutter_module/lib/main.dart index a3bbbdc..08479bf 100644 --- a/flutter_module/lib/main.dart +++ b/flutter_module/lib/main.dart @@ -14,6 +14,7 @@ class MyApp extends StatelessWidget { MyApp({Key? key}) : super(key: key); final mainModel = MainModelBridge(); + final detailModel = DetailModelBridge(); @override Widget build(BuildContext context) { @@ -27,7 +28,10 @@ class MyApp extends StatelessWidget { detailsNavigator: detailsNavigator, model: mainModel, ), - DetailsScreen(detailsNavigator) + DetailsScreen( + navigator: detailsNavigator, + model: detailModel, + ) ], redirectors: [ ScreenRedirector(), diff --git a/flutter_module/lib/model/main_model.dart b/flutter_module/lib/model/main_model.dart index ada18b3..8ae0a04 100644 --- a/flutter_module/lib/model/main_model.dart +++ b/flutter_module/lib/model/main_model.dart @@ -727,4 +727,136 @@ class DetailModelBridge { return (replyMap['result'] as DetailModelStateData?)!; } } + + Future load(String arg_uuid) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.DetailModelBridge.load', codec, binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_uuid]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future like() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.DetailModelBridge.like', codec, binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send(null) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future dislike() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.DetailModelBridge.dislike', codec, binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send(null) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future save() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.DetailModelBridge.save', codec, binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send(null) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future copyToClipboard() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.DetailModelBridge.copyToClipboard', codec, binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send(null) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future share() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.DetailModelBridge.share', codec, binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send(null) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } } diff --git a/flutter_module/lib/presentation/navigation/details/details_navigator.dart b/flutter_module/lib/presentation/navigation/details/details_navigator.dart index 41d38bb..f937a15 100644 --- a/flutter_module/lib/presentation/navigation/details/details_navigator.dart +++ b/flutter_module/lib/presentation/navigation/details/details_navigator.dart @@ -1,18 +1,17 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_module/model/main_model.dart'; import 'package:flutter_module/presentation/navigation/screen_navigator.dart'; import 'package:flutter_module/presentation/screens/details_screen.dart'; import 'package:flutter_module/utils/extensions/text_extensions.dart'; import 'package:go_router/go_router.dart'; class DetailsNavigator extends ScreenNavigator { - Snippet? _snippet; + String? _snippetId; - Snippet? get snippet => _snippet; + String? get snippetId => _snippetId; - void goToDetails(BuildContext context, Snippet snippet) { - _snippet = snippet; + void goToDetails(BuildContext context, String snippetId) { + _snippetId = snippetId; router.push(DetailsScreen.name.route); } } diff --git a/flutter_module/lib/presentation/screens/details_screen.dart b/flutter_module/lib/presentation/screens/details_screen.dart index 0018e35..de50e16 100644 --- a/flutter_module/lib/presentation/screens/details_screen.dart +++ b/flutter_module/lib/presentation/screens/details_screen.dart @@ -9,21 +9,30 @@ import 'package:flutter_module/presentation/styles/padding_styles.dart'; import 'package:flutter_module/presentation/widgets/code_text_view.dart'; import 'package:flutter_module/presentation/widgets/snippet_action_bar.dart'; import 'package:flutter_module/presentation/widgets/snippet_details_bar.dart'; +import 'package:flutter_module/presentation/widgets/view_state_wrapper.dart'; +import 'package:flutter_module/utils/extensions/state_extensions.dart'; import 'package:flutter_module/utils/hooks/use_navigator.dart'; +import 'package:flutter_module/utils/hooks/use_observable_state_hook.dart'; import 'package:go_router/go_router.dart'; class DetailsScreen extends NamedScreen { - DetailsScreen(this.navigator) : super(name); + DetailsScreen({ + required this.navigator, + required this.model, + }) : super(name); static String name = 'details'; final DetailsNavigator navigator; - - // final DetailsModel + final DetailModelBridge model; @override Widget builder(BuildContext context, GoRouterState state) { - return _DetailsPage(navigator: navigator, snippet: navigator.snippet); + return _DetailsPage( + navigator: navigator, + model: model, + snippetId: navigator.snippetId!, + ); } } @@ -31,69 +40,108 @@ class _DetailsPage extends HookWidget { const _DetailsPage({ Key? key, required this.navigator, - required this.snippet, + required this.model, + required this.snippetId, }) : super(key: key); final DetailsNavigator navigator; - final Snippet? snippet; + final DetailModelBridge model; + final String snippetId; @override Widget build(BuildContext context) { useNavigator([navigator]); + final stateChange = useObservableState( + DetailModelStateData(), + () => model.getState(), + (current, newState) => (current as DetailModelStateData).equals(newState), + ); + + final state = stateChange.value; + + useEffect(() { + model.load(snippetId); + return null; + }, []); + // TODO Add view state wrapper and show error for null snippet return Scaffold( backgroundColor: ColorStyles.surfacePrimary(), appBar: AppBar( - elevation: 0, - title: Text(snippet?.title ?? 'Details'), + title: Text(state.data!.title ?? 'Details'), backgroundColor: ColorStyles.surfacePrimary(), foregroundColor: Colors.black, + elevation: 0, leading: BackButton( onPressed: navigator.back, color: Colors.black, ), - actions: snippet?.isPrivate == true + actions: state.data?.isPrivate == true ? [const PaddingStyles.regular(Icon(Icons.visibility_off_outlined))] : null, ), - body: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - PaddingStyles.regular(SnippetDetailsBar(snippet: snippet!)), - Expanded( - child: ColoredBox( - color: ColorStyles.codeBackground(), - child: NotificationListener( - onNotification: (overScroll) { - overScroll.disallowIndicator(); - return true; - }, - child: SingleChildScrollView( - padding: const EdgeInsets.all(Dimens.l), - physics: const ClampingScrollPhysics(), - child: CodeTextView( - code: snippet!.code!.raw!, - tokens: snippet?.code?.tokens, - ), + body: ViewStateWrapper( + isLoading: state.state == ModelState.loading || state.is_loading == true, + error: state.error, + data: state.data, + builder: (_, snippet) => _DetailPageData( + model: model, + state: state, + ), + ), + ); + } +} + +class _DetailPageData extends StatelessWidget { + const _DetailPageData({ + Key? key, + required this.model, + required this.state, + }) : super(key: key); + + final DetailModelBridge model; + final DetailModelStateData state; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PaddingStyles.regular(SnippetDetailsBar(snippet: state.data!)), + Expanded( + child: ColoredBox( + color: ColorStyles.codeBackground(), + child: NotificationListener( + onNotification: (overScroll) { + overScroll.disallowIndicator(); + return true; + }, + child: SingleChildScrollView( + padding: const EdgeInsets.all(Dimens.l), + physics: const ClampingScrollPhysics(), + child: CodeTextView( + code: state.data!.code!.raw!, + tokens: state.data?.code?.tokens, ), ), ), ), - PaddingStyles.regular( - Center( - child: SnippetActionBar( - snippet: snippet!, - onLikeTap: () {}, - onDislikeTap: () {}, - onSaveTap: () {}, - onCopyTap: () {}, - onShareTap: () {}, - ), + ), + PaddingStyles.regular( + Center( + child: SnippetActionBar( + snippet: state.data!, + onLikeTap: model.like, + onDislikeTap: model.dislike, + onSaveTap: model.save, + onCopyTap: model.copyToClipboard, + onShareTap: model.share, ), ), - ], - ), + ), + ], ); } } diff --git a/flutter_module/lib/presentation/screens/main_screen.dart b/flutter_module/lib/presentation/screens/main_screen.dart index 0269bcb..dc944e1 100644 --- a/flutter_module/lib/presentation/screens/main_screen.dart +++ b/flutter_module/lib/presentation/screens/main_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter_module/presentation/styles/color_styles.dart'; import 'package:flutter_module/presentation/styles/dimens.dart'; import 'package:flutter_module/presentation/widgets/snippet_list_item.dart'; import 'package:flutter_module/presentation/widgets/view_state_wrapper.dart'; +import 'package:flutter_module/utils/extensions/state_extensions.dart'; import 'package:flutter_module/utils/hooks/use_navigator.dart'; import 'package:flutter_module/utils/hooks/use_observable_state_hook.dart'; import 'package:flutter_module/utils/mock/mocks.dart'; @@ -54,7 +55,7 @@ class _MainPage extends HookWidget { final state = useObservableState( MainModelStateData(), () => model.getState(), - (oldState, newState) => _equalState(oldState, newState), + (current, newState) => (current as MainModelStateData).equals(newState), ); final data = state.value; @@ -92,21 +93,12 @@ class _MainPage extends HookWidget { }, ), floatingActionButton: FloatingActionButton( - onPressed: () => detailsNavigator.goToDetails(context, Mocks.snippet), + onPressed: () => detailsNavigator.goToDetails(context, Mocks.snippet.uuid!), tooltip: 'Increment', child: const Icon(Icons.add), ), ); } - - bool _equalState(Object oldState, Object newState) { - final oldData = (oldState as MainModelStateData); - final newData = (newState as MainModelStateData); - - return oldData.state == newState.state && - oldData.is_loading == newData.is_loading && - oldData.error == newData.error; - } } class _MainPageData extends StatelessWidget { @@ -133,7 +125,7 @@ class _MainPageData extends StatelessWidget { child: SnippetListTile( snippet: snippet, onTap: () { - navigator.goToDetails(context, snippet); + navigator.goToDetails(context, snippet.uuid!); }, ), ); diff --git a/flutter_module/lib/utils/extensions/state_extensions.dart b/flutter_module/lib/utils/extensions/state_extensions.dart new file mode 100644 index 0000000..8b64582 --- /dev/null +++ b/flutter_module/lib/utils/extensions/state_extensions.dart @@ -0,0 +1,19 @@ +import 'package:flutter_module/model/main_model.dart'; + +extension MainModelStateDataExtension on MainModelStateData { + bool equals(Object other) { + if (other is! MainModelStateData) return false; + return state == other.state && + is_loading == other.is_loading && + error == other.error; + } +} + +extension DetailModelStateDataExtension on DetailModelStateData { + bool equals(Object other) { + if (other is! DetailModelStateData) return false; + return state == other.state && + is_loading == other.is_loading && + error == other.error; + } +} From b1624a03326ca29a75fd35dba1e82ec0110b4cbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Sun, 13 Nov 2022 13:55:15 +0100 Subject: [PATCH 03/66] Corrected reacting on user action --- .../snipmeandroid/bridge/Bridge.java | 60 +++++++++++++++++++ .../bridge/detail/DetailModel.kt | 10 ++-- .../bridge/detail/DetailModelPlugin.kt | 15 +++-- .../snipmeandroid/di/ModelModule.kt | 12 ++++ .../tkadziolka/snipmeandroid/di/UtilModule.kt | 3 +- .../snipmeandroid/di/ViewModelModule.kt | 12 ---- flutter_module/bridge/main_model.dart | 4 ++ flutter_module/lib/model/main_model.dart | 16 +++++ .../presentation/screens/details_screen.dart | 19 +++--- .../utils/extensions/state_extensions.dart | 4 +- 10 files changed, 119 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java index 6290a9d..9cf2fa0 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java @@ -599,6 +599,18 @@ public void setError(@Nullable String setterArg) { this.error = setterArg; } + private @Nullable Long oldHash; + public @Nullable Long getOldHash() { return oldHash; } + public void setOldHash(@Nullable Long setterArg) { + this.oldHash = setterArg; + } + + private @Nullable Long newHash; + public @Nullable Long getNewHash() { return newHash; } + public void setNewHash(@Nullable Long setterArg) { + this.newHash = setterArg; + } + public static final class Builder { private @Nullable ModelState state; public @NonNull Builder setState(@Nullable ModelState setterArg) { @@ -620,12 +632,24 @@ public static final class Builder { this.error = setterArg; return this; } + private @Nullable Long oldHash; + public @NonNull Builder setOldHash(@Nullable Long setterArg) { + this.oldHash = setterArg; + return this; + } + private @Nullable Long newHash; + public @NonNull Builder setNewHash(@Nullable Long setterArg) { + this.newHash = setterArg; + return this; + } public @NonNull MainModelStateData build() { MainModelStateData pigeonReturn = new MainModelStateData(); pigeonReturn.setState(state); pigeonReturn.setIs_loading(is_loading); pigeonReturn.setData(data); pigeonReturn.setError(error); + pigeonReturn.setOldHash(oldHash); + pigeonReturn.setNewHash(newHash); return pigeonReturn; } } @@ -635,6 +659,8 @@ public static final class Builder { toMapResult.put("is_loading", is_loading); toMapResult.put("data", data); toMapResult.put("error", error); + toMapResult.put("oldHash", oldHash); + toMapResult.put("newHash", newHash); return toMapResult; } static @NonNull MainModelStateData fromMap(@NonNull Map map) { @@ -647,6 +673,10 @@ public static final class Builder { pigeonResult.setData((List)data); Object error = map.get("error"); pigeonResult.setError((String)error); + Object oldHash = map.get("oldHash"); + pigeonResult.setOldHash((oldHash == null) ? null : ((oldHash instanceof Integer) ? (Integer)oldHash : (Long)oldHash)); + Object newHash = map.get("newHash"); + pigeonResult.setNewHash((newHash == null) ? null : ((newHash instanceof Integer) ? (Integer)newHash : (Long)newHash)); return pigeonResult; } } @@ -725,6 +755,18 @@ public void setError(@Nullable String setterArg) { this.error = setterArg; } + private @Nullable Long oldHash; + public @Nullable Long getOldHash() { return oldHash; } + public void setOldHash(@Nullable Long setterArg) { + this.oldHash = setterArg; + } + + private @Nullable Long newHash; + public @Nullable Long getNewHash() { return newHash; } + public void setNewHash(@Nullable Long setterArg) { + this.newHash = setterArg; + } + public static final class Builder { private @Nullable ModelState state; public @NonNull Builder setState(@Nullable ModelState setterArg) { @@ -746,12 +788,24 @@ public static final class Builder { this.error = setterArg; return this; } + private @Nullable Long oldHash; + public @NonNull Builder setOldHash(@Nullable Long setterArg) { + this.oldHash = setterArg; + return this; + } + private @Nullable Long newHash; + public @NonNull Builder setNewHash(@Nullable Long setterArg) { + this.newHash = setterArg; + return this; + } public @NonNull DetailModelStateData build() { DetailModelStateData pigeonReturn = new DetailModelStateData(); pigeonReturn.setState(state); pigeonReturn.setIs_loading(is_loading); pigeonReturn.setData(data); pigeonReturn.setError(error); + pigeonReturn.setOldHash(oldHash); + pigeonReturn.setNewHash(newHash); return pigeonReturn; } } @@ -761,6 +815,8 @@ public static final class Builder { toMapResult.put("is_loading", is_loading); toMapResult.put("data", (data == null) ? null : data.toMap()); toMapResult.put("error", error); + toMapResult.put("oldHash", oldHash); + toMapResult.put("newHash", newHash); return toMapResult; } static @NonNull DetailModelStateData fromMap(@NonNull Map map) { @@ -773,6 +829,10 @@ public static final class Builder { pigeonResult.setData((data == null) ? null : Snippet.fromMap((Map)data)); Object error = map.get("error"); pigeonResult.setError((String)error); + Object oldHash = map.get("oldHash"); + pigeonResult.setOldHash((oldHash == null) ? null : ((oldHash instanceof Integer) ? (Integer)oldHash : (Long)oldHash)); + Object newHash = map.get("newHash"); + pigeonResult.setNewHash((newHash == null) ? null : ((newHash instanceof Integer) ? (Integer)newHash : (Long)newHash)); return pigeonResult; } } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt index de9a827..1655cb6 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt @@ -6,6 +6,7 @@ import io.reactivex.rxkotlin.plusAssign import io.reactivex.rxkotlin.subscribeBy import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.flow.MutableStateFlow +import pl.tkadziolka.snipmeandroid.bridge.session.SessionModel import pl.tkadziolka.snipmeandroid.domain.clipboard.AddToClipboardUseCase import pl.tkadziolka.snipmeandroid.domain.error.exception.* import pl.tkadziolka.snipmeandroid.domain.message.ErrorMessages @@ -16,16 +17,15 @@ import pl.tkadziolka.snipmeandroid.domain.snippet.GetSingleSnippetUseCase import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet import pl.tkadziolka.snipmeandroid.ui.detail.* import pl.tkadziolka.snipmeandroid.ui.error.ErrorParsable -import pl.tkadziolka.snipmeandroid.ui.session.SessionViewModel import timber.log.Timber class DetailModel( private val errorMessages: ErrorMessages, private val getSnippet: GetSingleSnippetUseCase, - private val clipboard: AddToClipboardUseCase, +// private val clipboard: AddToClipboardUseCase, private val getTargetReaction: GetTargetUserReactionUseCase, private val setUserReaction: SetUserReactionUseCase, - private val session: SessionViewModel + private val session: SessionModel ) : ErrorParsable { private val disposables = CompositeDisposable() @@ -52,7 +52,6 @@ class DetailModel( setState(Loading) getSnippet(uuid) .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) .subscribeBy( onSuccess = { setState(Loaded(it)) }, onError = { @@ -72,7 +71,7 @@ class DetailModel( fun copyToClipboard() { getSnippet()?.let { - clipboard(it.title, it.code.raw) +// clipboard(it.title, it.code.raw) } } @@ -84,7 +83,6 @@ class DetailModel( setUserReaction(previousState.snippet, newReaction) .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) .subscribeBy( onSuccess = { snippet -> mutableState.value = Loaded(snippet) }, onError = { diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt index 6e0f4ae..4e75c3f 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt @@ -10,8 +10,8 @@ import pl.tkadziolka.snipmeandroid.ui.detail.Loaded import pl.tkadziolka.snipmeandroid.ui.detail.Loading class DetailModelPlugin: ModelPlugin(), Bridge.DetailModelBridge { - private val model: DetailModel by inject() + private var oldState: DetailViewState? = null override fun getState(): Bridge.DetailModelStateData = getData(model.state.value) @@ -43,10 +43,15 @@ class DetailModelPlugin: ModelPlugin(), Bridge.DetailM TODO("Not yet implemented") } - private fun getData(viewState: DetailViewState) = Bridge.DetailModelStateData().apply { - state = viewState.toModelState() - is_loading = viewState is Loading - data = (viewState as? Loaded)?.snippet?.toModelData() + private fun getData(viewState: DetailViewState): Bridge.DetailModelStateData { + oldState = viewState + return Bridge.DetailModelStateData().apply { + state = viewState.toModelState() + is_loading = viewState is Loading + data = (viewState as? Loaded)?.snippet?.toModelData() + oldHash = oldState?.hashCode()?.toLong() + newHash = viewState.hashCode().toLong() + } } private fun DetailViewState.toModelState() = diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt new file mode 100644 index 0000000..4ce984e --- /dev/null +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt @@ -0,0 +1,12 @@ +package pl.tkadziolka.snipmeandroid.di + +import org.koin.dsl.module +import pl.tkadziolka.snipmeandroid.bridge.detail.DetailModel +import pl.tkadziolka.snipmeandroid.bridge.main.MainModel +import pl.tkadziolka.snipmeandroid.bridge.session.SessionModel + +internal val modelModule = module { + single { SessionModel(get()) } + single { MainModel(get(), get(), get(), get(), get(), get(), get()) } + single { DetailModel(get(), get(), get(), get(), get()) } +} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UtilModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UtilModule.kt index 20dfeff..16ce397 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UtilModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UtilModule.kt @@ -2,6 +2,7 @@ package pl.tkadziolka.snipmeandroid.di import android.content.ClipboardManager import android.content.Context.CLIPBOARD_SERVICE +import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext import org.koin.dsl.module import pl.tkadziolka.snipmeandroid.BuildConfig @@ -17,5 +18,5 @@ internal val utilModule = module { factory { ErrorMessages(get()) } factory { RealValidationMessages(get()) } factory { Dialogs(get()) } - factory { androidContext().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager } + factory { androidApplication().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt index deb1ac9..407d0a9 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt @@ -24,16 +24,4 @@ internal val viewModelModule = module { viewModel { EditViewModel(get(), get(), get(), get(), get(), get()) } viewModel { ShareViewModel(get(), get(), get()) } viewModel { DonateViewModel(get()) } -} - -internal val modelModule = module { -// viewModel { SplashViewModel(get(), get()) } -// viewModel { LoginViewModel(get(), get(), get(), get()) } - single { SessionModel(get()) } - single { MainModel(get(), get(), get(), get(), get(), get(), get()) } -// viewModel { PreviewViewModel(get(), get()) } -// viewModel { DetailViewModel(get(), get(), get(), get(), get(), get(), get()) } -// viewModel { EditViewModel(get(), get(), get(), get(), get(), get()) } -// viewModel { ShareViewModel(get(), get(), get()) } -// viewModel { DonateViewModel(get()) } } \ No newline at end of file diff --git a/flutter_module/bridge/main_model.dart b/flutter_module/bridge/main_model.dart index fb21b70..59ad3fb 100644 --- a/flutter_module/bridge/main_model.dart +++ b/flutter_module/bridge/main_model.dart @@ -110,6 +110,8 @@ class MainModelStateData { bool? is_loading; List? data; String? error; + int? oldHash; + int? newHash; } class MainModelEventData { @@ -122,6 +124,8 @@ class DetailModelStateData { bool? is_loading; Snippet? data; String? error; + double? oldHash; + int? newHash; } // Api diff --git a/flutter_module/lib/model/main_model.dart b/flutter_module/lib/model/main_model.dart index 8ae0a04..6ab4331 100644 --- a/flutter_module/lib/model/main_model.dart +++ b/flutter_module/lib/model/main_model.dart @@ -291,12 +291,16 @@ class MainModelStateData { this.is_loading, this.data, this.error, + this.oldHash, + this.newHash, }); ModelState? state; bool? is_loading; List? data; String? error; + int? oldHash; + int? newHash; Object encode() { final Map pigeonMap = {}; @@ -304,6 +308,8 @@ class MainModelStateData { pigeonMap['is_loading'] = is_loading; pigeonMap['data'] = data; pigeonMap['error'] = error; + pigeonMap['oldHash'] = oldHash; + pigeonMap['newHash'] = newHash; return pigeonMap; } @@ -316,6 +322,8 @@ class MainModelStateData { is_loading: pigeonMap['is_loading'] as bool?, data: (pigeonMap['data'] as List?)?.cast(), error: pigeonMap['error'] as String?, + oldHash: pigeonMap['oldHash'] as int?, + newHash: pigeonMap['newHash'] as int?, ); } } @@ -353,12 +361,16 @@ class DetailModelStateData { this.is_loading, this.data, this.error, + this.oldHash, + this.newHash, }); ModelState? state; bool? is_loading; Snippet? data; String? error; + int? oldHash; + int? newHash; Object encode() { final Map pigeonMap = {}; @@ -366,6 +378,8 @@ class DetailModelStateData { pigeonMap['is_loading'] = is_loading; pigeonMap['data'] = data?.encode(); pigeonMap['error'] = error; + pigeonMap['oldHash'] = oldHash; + pigeonMap['newHash'] = newHash; return pigeonMap; } @@ -380,6 +394,8 @@ class DetailModelStateData { ? Snippet.decode(pigeonMap['data']!) : null, error: pigeonMap['error'] as String?, + oldHash: pigeonMap['oldHash'] as int?, + newHash: pigeonMap['newHash'] as int?, ); } } diff --git a/flutter_module/lib/presentation/screens/details_screen.dart b/flutter_module/lib/presentation/screens/details_screen.dart index de50e16..9a13186 100644 --- a/flutter_module/lib/presentation/screens/details_screen.dart +++ b/flutter_module/lib/presentation/screens/details_screen.dart @@ -57,7 +57,6 @@ class _DetailsPage extends HookWidget { () => model.getState(), (current, newState) => (current as DetailModelStateData).equals(newState), ); - final state = stateChange.value; useEffect(() { @@ -69,7 +68,7 @@ class _DetailsPage extends HookWidget { return Scaffold( backgroundColor: ColorStyles.surfacePrimary(), appBar: AppBar( - title: Text(state.data!.title ?? 'Details'), + title: Text(state.data?.title ?? ''), backgroundColor: ColorStyles.surfacePrimary(), foregroundColor: Colors.black, elevation: 0, @@ -87,7 +86,7 @@ class _DetailsPage extends HookWidget { data: state.data, builder: (_, snippet) => _DetailPageData( model: model, - state: state, + snippet: snippet, ), ), ); @@ -98,18 +97,20 @@ class _DetailPageData extends StatelessWidget { const _DetailPageData({ Key? key, required this.model, - required this.state, + required this.snippet, }) : super(key: key); final DetailModelBridge model; - final DetailModelStateData state; + final Snippet? snippet; @override Widget build(BuildContext context) { + if (snippet == null) return const SizedBox(); + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - PaddingStyles.regular(SnippetDetailsBar(snippet: state.data!)), + PaddingStyles.regular(SnippetDetailsBar(snippet: snippet!)), Expanded( child: ColoredBox( color: ColorStyles.codeBackground(), @@ -122,8 +123,8 @@ class _DetailPageData extends StatelessWidget { padding: const EdgeInsets.all(Dimens.l), physics: const ClampingScrollPhysics(), child: CodeTextView( - code: state.data!.code!.raw!, - tokens: state.data?.code?.tokens, + code: snippet!.code!.raw!, + tokens: snippet!.code?.tokens, ), ), ), @@ -132,7 +133,7 @@ class _DetailPageData extends StatelessWidget { PaddingStyles.regular( Center( child: SnippetActionBar( - snippet: state.data!, + snippet: snippet!, onLikeTap: model.like, onDislikeTap: model.dislike, onSaveTap: model.save, diff --git a/flutter_module/lib/utils/extensions/state_extensions.dart b/flutter_module/lib/utils/extensions/state_extensions.dart index 8b64582..9479451 100644 --- a/flutter_module/lib/utils/extensions/state_extensions.dart +++ b/flutter_module/lib/utils/extensions/state_extensions.dart @@ -12,8 +12,6 @@ extension MainModelStateDataExtension on MainModelStateData { extension DetailModelStateDataExtension on DetailModelStateData { bool equals(Object other) { if (other is! DetailModelStateData) return false; - return state == other.state && - is_loading == other.is_loading && - error == other.error; + return other.oldHash != other.newHash; } } From ae4dccc0ecb03a280ce03e02779fb5e86525fc1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Wed, 16 Nov 2022 07:21:00 +0100 Subject: [PATCH 04/66] Corrected injecting clipboard use case --- .../java/pl/tkadziolka/snipmeandroid/App.kt | 1 + .../snipmeandroid/bridge/Bridge.java | 18 ++++++------------ .../snipmeandroid/bridge/detail/DetailModel.kt | 5 ++--- .../tkadziolka/snipmeandroid/di/ModelModule.kt | 2 +- .../snipmeandroid/di/UseCaseModule.kt | 2 +- .../tkadziolka/snipmeandroid/di/UtilModule.kt | 2 +- flutter_module/bridge/main_model.dart | 8 +------- 7 files changed, 13 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/App.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/App.kt index a4a4773..357af30 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/App.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/App.kt @@ -5,6 +5,7 @@ import io.github.kbiakov.codeview.classifier.CodeProcessor import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin +import org.koin.dsl.koinApplication import pl.tkadziolka.snipmeandroid.di.koinModules import pl.tkadziolka.snipmeandroid.util.CrashReportingTree import timber.log.Timber diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java index 9cf2fa0..cbc7113 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java @@ -1168,9 +1168,8 @@ static void setup(BinaryMessenger binaryMessenger, DetailModelBridge api) { } } { - BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); BasicMessageChannel channel = - new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.load", getCodec(), taskQueue); + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.load", getCodec()); if (api != null) { channel.setMessageHandler((message, reply) -> { Map wrapped = new HashMap<>(); @@ -1194,9 +1193,8 @@ static void setup(BinaryMessenger binaryMessenger, DetailModelBridge api) { } } { - BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); BasicMessageChannel channel = - new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.like", getCodec(), taskQueue); + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.like", getCodec()); if (api != null) { channel.setMessageHandler((message, reply) -> { Map wrapped = new HashMap<>(); @@ -1214,9 +1212,8 @@ static void setup(BinaryMessenger binaryMessenger, DetailModelBridge api) { } } { - BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); BasicMessageChannel channel = - new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.dislike", getCodec(), taskQueue); + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.dislike", getCodec()); if (api != null) { channel.setMessageHandler((message, reply) -> { Map wrapped = new HashMap<>(); @@ -1234,9 +1231,8 @@ static void setup(BinaryMessenger binaryMessenger, DetailModelBridge api) { } } { - BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); BasicMessageChannel channel = - new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.save", getCodec(), taskQueue); + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.save", getCodec()); if (api != null) { channel.setMessageHandler((message, reply) -> { Map wrapped = new HashMap<>(); @@ -1254,9 +1250,8 @@ static void setup(BinaryMessenger binaryMessenger, DetailModelBridge api) { } } { - BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); BasicMessageChannel channel = - new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.copyToClipboard", getCodec(), taskQueue); + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.copyToClipboard", getCodec()); if (api != null) { channel.setMessageHandler((message, reply) -> { Map wrapped = new HashMap<>(); @@ -1274,9 +1269,8 @@ static void setup(BinaryMessenger binaryMessenger, DetailModelBridge api) { } } { - BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); BasicMessageChannel channel = - new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.share", getCodec(), taskQueue); + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.share", getCodec()); if (api != null) { channel.setMessageHandler((message, reply) -> { Map wrapped = new HashMap<>(); diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt index 1655cb6..c36935b 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt @@ -1,6 +1,5 @@ package pl.tkadziolka.snipmeandroid.bridge.detail -import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.CompositeDisposable import io.reactivex.rxkotlin.plusAssign import io.reactivex.rxkotlin.subscribeBy @@ -22,7 +21,7 @@ import timber.log.Timber class DetailModel( private val errorMessages: ErrorMessages, private val getSnippet: GetSingleSnippetUseCase, -// private val clipboard: AddToClipboardUseCase, + private val clipboard: AddToClipboardUseCase, private val getTargetReaction: GetTargetUserReactionUseCase, private val setUserReaction: SetUserReactionUseCase, private val session: SessionModel @@ -71,7 +70,7 @@ class DetailModel( fun copyToClipboard() { getSnippet()?.let { -// clipboard(it.title, it.code.raw) + clipboard(it.title, it.code.raw) } } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt index 4ce984e..76b6437 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt @@ -8,5 +8,5 @@ import pl.tkadziolka.snipmeandroid.bridge.session.SessionModel internal val modelModule = module { single { SessionModel(get()) } single { MainModel(get(), get(), get(), get(), get(), get(), get()) } - single { DetailModel(get(), get(), get(), get(), get()) } + single { DetailModel(get(), get(), get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt index b3d9f3e..4a971cb 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt @@ -46,7 +46,7 @@ internal val useCaseModule = module { factory { ClearCachedShareUsersUseCase(get()) } factory { ShareSnippetUseCase(get(), get(), get(), get()) } // Clipboard - factory { AddToClipboardUseCase(get()) } + single { AddToClipboardUseCase(get()) } factory { GetFromClipboardUseCase(get()) } } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UtilModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UtilModule.kt index 16ce397..979d45e 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UtilModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UtilModule.kt @@ -18,5 +18,5 @@ internal val utilModule = module { factory { ErrorMessages(get()) } factory { RealValidationMessages(get()) } factory { Dialogs(get()) } - factory { androidApplication().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager } + single { androidApplication().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager } } \ No newline at end of file diff --git a/flutter_module/bridge/main_model.dart b/flutter_module/bridge/main_model.dart index 59ad3fb..60cf63c 100644 --- a/flutter_module/bridge/main_model.dart +++ b/flutter_module/bridge/main_model.dart @@ -124,7 +124,7 @@ class DetailModelStateData { bool? is_loading; Snippet? data; String? error; - double? oldHash; + int? oldHash; int? newHash; } @@ -156,21 +156,15 @@ abstract class MainModelBridge { abstract class DetailModelBridge { DetailModelStateData getState(); - @TaskQueue(type: TaskQueueType.serialBackgroundThread) void load(String uuid); - @TaskQueue(type: TaskQueueType.serialBackgroundThread) void like(); - @TaskQueue(type: TaskQueueType.serialBackgroundThread) void dislike(); - @TaskQueue(type: TaskQueueType.serialBackgroundThread) void save(); - @TaskQueue(type: TaskQueueType.serialBackgroundThread) void copyToClipboard(); - @TaskQueue(type: TaskQueueType.serialBackgroundThread) void share(); } \ No newline at end of file From dbb607cdd3b75d17373b89c0635bb1708a6a2801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Sat, 26 Nov 2022 13:49:33 +0100 Subject: [PATCH 05/66] Created save snippet use case --- .../bridge/detail/DetailModel.kt | 20 +++++++++++++++++++ .../bridge/detail/DetailModelPlugin.kt | 2 +- .../domain/snippet/SaveSnippetUseCase.kt | 13 ++++++++++++ .../ui/detail/DetailViewModel.kt | 3 ++- 4 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/SaveSnippetUseCase.kt diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt index c36935b..b65b37d 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt @@ -13,6 +13,7 @@ import pl.tkadziolka.snipmeandroid.domain.reaction.GetTargetUserReactionUseCase import pl.tkadziolka.snipmeandroid.domain.reaction.SetUserReactionUseCase import pl.tkadziolka.snipmeandroid.domain.reaction.UserReaction import pl.tkadziolka.snipmeandroid.domain.snippet.GetSingleSnippetUseCase +import pl.tkadziolka.snipmeandroid.domain.snippet.SaveSnippetUseCase import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet import pl.tkadziolka.snipmeandroid.ui.detail.* import pl.tkadziolka.snipmeandroid.ui.error.ErrorParsable @@ -24,6 +25,7 @@ class DetailModel( private val clipboard: AddToClipboardUseCase, private val getTargetReaction: GetTargetUserReactionUseCase, private val setUserReaction: SetUserReactionUseCase, + private val saveSnippet: SaveSnippetUseCase, private val session: SessionModel ) : ErrorParsable { private val disposables = CompositeDisposable() @@ -74,6 +76,24 @@ class DetailModel( } } + fun save() { + getSnippet()?.let { + setState(Loading) + saveSnippet(it) + .subscribeOn(Schedulers.io()) + .subscribeBy( + onSuccess = { + setState(Loaded(it)) + mutableEvent.value = Saved(it.uuid) + }, + onError = { + Timber.e("Couldn't load snippets, error = $it") + parseError(it) + } + ).also { disposables += it } + } + } + private fun changeReaction(newReaction: UserReaction) { // Immediately show change to user val previousState = getLoaded() ?: return diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt index 4e75c3f..8a314fc 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt @@ -32,7 +32,7 @@ class DetailModelPlugin: ModelPlugin(), Bridge.DetailM } override fun save() { - TODO("Not yet implemented") + model.save() } override fun copyToClipboard() { diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/SaveSnippetUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/SaveSnippetUseCase.kt new file mode 100644 index 0000000..8e69279 --- /dev/null +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/SaveSnippetUseCase.kt @@ -0,0 +1,13 @@ +package pl.tkadziolka.snipmeandroid.domain.snippet + +import io.reactivex.Single +import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet + +class SaveSnippetUseCase( + private val createSnippet: CreateSnippetUseCase +) { + operator fun invoke(snippet: Snippet): Single { + if (snippet.isOwner) return Single.just(snippet) + return createSnippet(snippet.title, snippet.code.raw, snippet.language.raw) + } +} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailViewModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailViewModel.kt index 6b53bfe..0a3ff75 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailViewModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailViewModel.kt @@ -125,6 +125,7 @@ data class Loaded(val snippet: Snippet) : DetailViewState() data class Error(val error: String?) : DetailViewState() sealed class DetailEvent -object Idle: DetailEvent() +object Idle : DetailEvent() data class Alert(val message: String) : DetailEvent() +data class Saved(val uuid: String) : DetailEvent() object Logout : DetailEvent() \ No newline at end of file From 05e40032dce813e6fd5833feaa1c31b7179ea222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Sun, 27 Nov 2022 13:28:31 +0100 Subject: [PATCH 06/66] Corrected visibility in use cases --- .../snipmeandroid/bridge/Bridge.java | 157 ++++++++++++++++-- .../bridge/detail/DetailModelPlugin.kt | 29 +++- .../snipmeandroid/di/ModelModule.kt | 2 +- .../snipmeandroid/di/UseCaseModule.kt | 2 + .../repository/snippet/SnippetRepository.kt | 18 +- .../snippet/SnippetRepositoryReal.kt | 25 +-- .../snippet/SnippetRepositoryTest.kt | 6 +- .../domain/snippet/CreateSnippetUseCase.kt | 22 ++- .../domain/snippet/EditInteractor.kt | 10 +- .../domain/snippet/SaveSnippetUseCase.kt | 9 +- .../domain/snippet/UpdateSnippetUseCase.kt | 22 ++- .../model/request/CreateSnippetRequest.kt | 3 +- .../ui/detail/DetailViewModel.kt | 2 +- .../snipmeandroid/ui/edit/EditViewModel.kt | 9 +- flutter_module/bridge/main_model.dart | 13 ++ flutter_module/lib/model/main_model.dart | 118 +++++++++++-- .../presentation/screens/details_screen.dart | 29 +++- .../utils/extensions/state_extensions.dart | 7 + 18 files changed, 411 insertions(+), 72 deletions(-) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java index cbc7113..471bfd3 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java @@ -120,6 +120,16 @@ private MainModelEvent(final int index) { } } + public enum DetailModelEvent { + NONE(0), + SAVED(1); + + private int index; + private DetailModelEvent(final int index) { + this.index = index; + } + } + /** Generated class from Pigeon that represents data sent in messages. */ public static class Snippet { private @Nullable String uuid; @@ -836,6 +846,84 @@ public static final class Builder { return pigeonResult; } } + + /** Generated class from Pigeon that represents data sent in messages. */ + public static class DetailModelEventData { + private @Nullable DetailModelEvent event; + public @Nullable DetailModelEvent getEvent() { return event; } + public void setEvent(@Nullable DetailModelEvent setterArg) { + this.event = setterArg; + } + + private @Nullable String value; + public @Nullable String getValue() { return value; } + public void setValue(@Nullable String setterArg) { + this.value = setterArg; + } + + private @Nullable Long oldHash; + public @Nullable Long getOldHash() { return oldHash; } + public void setOldHash(@Nullable Long setterArg) { + this.oldHash = setterArg; + } + + private @Nullable Long newHash; + public @Nullable Long getNewHash() { return newHash; } + public void setNewHash(@Nullable Long setterArg) { + this.newHash = setterArg; + } + + public static final class Builder { + private @Nullable DetailModelEvent event; + public @NonNull Builder setEvent(@Nullable DetailModelEvent setterArg) { + this.event = setterArg; + return this; + } + private @Nullable String value; + public @NonNull Builder setValue(@Nullable String setterArg) { + this.value = setterArg; + return this; + } + private @Nullable Long oldHash; + public @NonNull Builder setOldHash(@Nullable Long setterArg) { + this.oldHash = setterArg; + return this; + } + private @Nullable Long newHash; + public @NonNull Builder setNewHash(@Nullable Long setterArg) { + this.newHash = setterArg; + return this; + } + public @NonNull DetailModelEventData build() { + DetailModelEventData pigeonReturn = new DetailModelEventData(); + pigeonReturn.setEvent(event); + pigeonReturn.setValue(value); + pigeonReturn.setOldHash(oldHash); + pigeonReturn.setNewHash(newHash); + return pigeonReturn; + } + } + @NonNull Map toMap() { + Map toMapResult = new HashMap<>(); + toMapResult.put("event", event == null ? null : event.index); + toMapResult.put("value", value); + toMapResult.put("oldHash", oldHash); + toMapResult.put("newHash", newHash); + return toMapResult; + } + static @NonNull DetailModelEventData fromMap(@NonNull Map map) { + DetailModelEventData pigeonResult = new DetailModelEventData(); + Object event = map.get("event"); + pigeonResult.setEvent(event == null ? null : DetailModelEvent.values()[(int)event]); + Object value = map.get("value"); + pigeonResult.setValue((String)value); + Object oldHash = map.get("oldHash"); + pigeonResult.setOldHash((oldHash == null) ? null : ((oldHash instanceof Integer) ? (Integer)oldHash : (Long)oldHash)); + Object newHash = map.get("newHash"); + pigeonResult.setNewHash((newHash == null) ? null : ((newHash instanceof Integer) ? (Integer)newHash : (Long)newHash)); + return pigeonResult; + } + } private static class MainModelBridgeCodec extends StandardMessageCodec { public static final MainModelBridgeCodec INSTANCE = new MainModelBridgeCodec(); private MainModelBridgeCodec() {} @@ -1079,21 +1167,24 @@ private DetailModelBridgeCodec() {} protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) { switch (type) { case (byte)128: - return DetailModelStateData.fromMap((Map) readValue(buffer)); + return DetailModelEventData.fromMap((Map) readValue(buffer)); case (byte)129: - return Owner.fromMap((Map) readValue(buffer)); + return DetailModelStateData.fromMap((Map) readValue(buffer)); case (byte)130: - return Snippet.fromMap((Map) readValue(buffer)); + return Owner.fromMap((Map) readValue(buffer)); case (byte)131: - return SnippetCode.fromMap((Map) readValue(buffer)); + return Snippet.fromMap((Map) readValue(buffer)); case (byte)132: - return SnippetLanguage.fromMap((Map) readValue(buffer)); + return SnippetCode.fromMap((Map) readValue(buffer)); case (byte)133: + return SnippetLanguage.fromMap((Map) readValue(buffer)); + + case (byte)134: return SyntaxToken.fromMap((Map) readValue(buffer)); default: @@ -1103,28 +1194,32 @@ protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) { } @Override protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) { - if (value instanceof DetailModelStateData) { + if (value instanceof DetailModelEventData) { stream.write(128); + writeValue(stream, ((DetailModelEventData) value).toMap()); + } else + if (value instanceof DetailModelStateData) { + stream.write(129); writeValue(stream, ((DetailModelStateData) value).toMap()); } else if (value instanceof Owner) { - stream.write(129); + stream.write(130); writeValue(stream, ((Owner) value).toMap()); } else if (value instanceof Snippet) { - stream.write(130); + stream.write(131); writeValue(stream, ((Snippet) value).toMap()); } else if (value instanceof SnippetCode) { - stream.write(131); + stream.write(132); writeValue(stream, ((SnippetCode) value).toMap()); } else if (value instanceof SnippetLanguage) { - stream.write(132); + stream.write(133); writeValue(stream, ((SnippetLanguage) value).toMap()); } else if (value instanceof SyntaxToken) { - stream.write(133); + stream.write(134); writeValue(stream, ((SyntaxToken) value).toMap()); } else { @@ -1136,6 +1231,8 @@ protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ public interface DetailModelBridge { @NonNull DetailModelStateData getState(); + @NonNull DetailModelEventData getEvent(); + void resetEvent(); void load(@NonNull String uuid); void like(); void dislike(); @@ -1167,6 +1264,44 @@ static void setup(BinaryMessenger binaryMessenger, DetailModelBridge api) { channel.setMessageHandler(null); } } + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.getEvent", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + DetailModelEventData output = api.getEvent(); + wrapped.put("result", output); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.resetEvent", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + api.resetEvent(); + wrapped.put("result", null); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } { BasicMessageChannel channel = new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.load", getCodec()); diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt index 8a314fc..182e182 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt @@ -5,16 +5,21 @@ import org.koin.core.component.inject import pl.tkadziolka.snipmeandroid.bridge.Bridge import pl.tkadziolka.snipmeandroid.bridge.ModelPlugin import pl.tkadziolka.snipmeandroid.bridge.toModelData -import pl.tkadziolka.snipmeandroid.ui.detail.DetailViewState -import pl.tkadziolka.snipmeandroid.ui.detail.Loaded -import pl.tkadziolka.snipmeandroid.ui.detail.Loading +import pl.tkadziolka.snipmeandroid.ui.detail.* -class DetailModelPlugin: ModelPlugin(), Bridge.DetailModelBridge { +class DetailModelPlugin : ModelPlugin(), Bridge.DetailModelBridge { private val model: DetailModel by inject() + private var oldEvent: DetailEvent? = null private var oldState: DetailViewState? = null override fun getState(): Bridge.DetailModelStateData = getData(model.state.value) + override fun getEvent(): Bridge.DetailModelEventData = getEvent(model.event.value) + + override fun resetEvent() { + model.event.value = Idle + } + override fun onSetup(messenger: BinaryMessenger, bridge: Bridge.DetailModelBridge?) { Bridge.DetailModelBridge.setup(messenger, bridge) } @@ -54,10 +59,26 @@ class DetailModelPlugin: ModelPlugin(), Bridge.DetailM } } + private fun getEvent(detailEvent: DetailEvent): Bridge.DetailModelEventData { + oldEvent = detailEvent + return Bridge.DetailModelEventData().apply { + event = detailEvent.toModelEvent() + value = (detailEvent as? Saved)?.snippetId + oldHash = oldEvent?.hashCode()?.toLong() + newHash = detailEvent.hashCode().toLong() + } + } + private fun DetailViewState.toModelState() = when (this) { Loading -> Bridge.ModelState.LOADING is Loaded -> Bridge.ModelState.LOADED else -> Bridge.ModelState.ERROR } + + private fun DetailEvent.toModelEvent() = + when (this) { + is Saved -> Bridge.DetailModelEvent.SAVED + else -> Bridge.DetailModelEvent.NONE + } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt index 76b6437..d98ef9a 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt @@ -8,5 +8,5 @@ import pl.tkadziolka.snipmeandroid.bridge.session.SessionModel internal val modelModule = module { single { SessionModel(get()) } single { MainModel(get(), get(), get(), get(), get(), get(), get()) } - single { DetailModel(get(), get(), get(), get(), get(), get()) } + single { DetailModel(get(), get(), get(), get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt index 4a971cb..5f06f63 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt @@ -48,6 +48,8 @@ internal val useCaseModule = module { // Clipboard single { AddToClipboardUseCase(get()) } factory { GetFromClipboardUseCase(get()) } + // Save + factory { SaveSnippetUseCase(get()) } } internal val interactorModule = module { diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepository.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepository.kt index 459db3f..ca088a2 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepository.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepository.kt @@ -6,6 +6,7 @@ import io.reactivex.subjects.BehaviorSubject import pl.tkadziolka.snipmeandroid.domain.reaction.UserReaction import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet import pl.tkadziolka.snipmeandroid.domain.snippets.SnippetScope +import pl.tkadziolka.snipmeandroid.domain.snippets.SnippetVisibility interface SnippetRepository { @@ -15,9 +16,20 @@ interface SnippetRepository { fun snippet(id: String): Single - fun create(title: String, code: String, language: String): Single - - fun update(uuid: String, title: String, code: String, language: String): Single + fun create( + title: String, + code: String, + language: String, + visibility: SnippetVisibility + ): Single + + fun update( + uuid: String, + title: String, + code: String, + language: String, + visibility: SnippetVisibility + ): Single fun count(scope: SnippetScope): Single diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepositoryReal.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepositoryReal.kt index 1b89c9f..f685356 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepositoryReal.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepositoryReal.kt @@ -4,10 +4,7 @@ import io.reactivex.Single import io.reactivex.subjects.BehaviorSubject import pl.tkadziolka.snipmeandroid.domain.error.ErrorHandler import pl.tkadziolka.snipmeandroid.domain.reaction.UserReaction -import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet -import pl.tkadziolka.snipmeandroid.domain.snippets.SnippetResponseMapper -import pl.tkadziolka.snipmeandroid.domain.snippets.SnippetScope -import pl.tkadziolka.snipmeandroid.domain.snippets.value +import pl.tkadziolka.snipmeandroid.domain.snippets.* import pl.tkadziolka.snipmeandroid.infrastructure.model.request.CreateSnippetRequest import pl.tkadziolka.snipmeandroid.infrastructure.model.request.RateSnippetRequest import pl.tkadziolka.snipmeandroid.infrastructure.remote.SnippetService @@ -40,19 +37,23 @@ class SnippetRepositoryReal( override fun create( title: String, code: String, - language: String - ): Single = service.create(CreateSnippetRequest(title, code, language)) - .mapError { errorHandler.handle(it) } - .map { mapper(it) } + language: String, + visibility: SnippetVisibility + ): Single = + service.create(CreateSnippetRequest(title, code, language, visibility.name)) + .mapError { errorHandler.handle(it) } + .map { mapper(it) } override fun update( uuid: String, title: String, code: String, - language: String - ): Single = service.update(uuid, CreateSnippetRequest(title, code, language)) - .mapError { errorHandler.handle(it) } - .map { mapper(it) } + language: String, + visibility: SnippetVisibility + ): Single = + service.update(uuid, CreateSnippetRequest(title, code, language, visibility.name)) + .mapError { errorHandler.handle(it) } + .map { mapper(it) } override fun count(scope: SnippetScope) = if (count != null) { diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepositoryTest.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepositoryTest.kt index b4cf489..39d050e 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepositoryTest.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepositoryTest.kt @@ -49,7 +49,8 @@ class SnippetRepositoryTest(private val errorHandler: ErrorHandler) : SnippetRep override fun create( title: String, code: String, - language: String + language: String, + visibility: SnippetVisibility ): Single = Single.just( Snippet( uuid = uuid, @@ -70,7 +71,8 @@ class SnippetRepositoryTest(private val errorHandler: ErrorHandler) : SnippetRep uuid: String, title: String, code: String, - language: String + language: String, + visibility: SnippetVisibility ): Single = Single.just( Snippet( uuid = uuid, diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/CreateSnippetUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/CreateSnippetUseCase.kt index ade33ed..be9164c 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/CreateSnippetUseCase.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/CreateSnippetUseCase.kt @@ -5,18 +5,24 @@ import pl.tkadziolka.snipmeandroid.domain.auth.AuthorizationUseCase import pl.tkadziolka.snipmeandroid.domain.network.CheckNetworkAvailableUseCase import pl.tkadziolka.snipmeandroid.domain.repository.snippet.SnippetRepository import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet +import pl.tkadziolka.snipmeandroid.domain.snippets.SnippetVisibility class CreateSnippetUseCase( private val auth: AuthorizationUseCase, private val networkAvailable: CheckNetworkAvailableUseCase, private val snippetRepository: SnippetRepository ) { - operator fun invoke(title: String, code: String, language: String): Single = - auth() - .andThen(networkAvailable()) - .andThen(snippetRepository.create(title, code, language)) - .flatMap { - snippetRepository.updateListener.onNext(it) - Single.just(it) - } + // TODO Test saving other snippet in app and navigation + operator fun invoke( + title: String, + code: String, + language: String, + visibility: SnippetVisibility = SnippetVisibility.PUBLIC + ): Single = auth() + .andThen(networkAvailable()) + .andThen(snippetRepository.create(title, code, language, visibility)) + .flatMap { + snippetRepository.updateListener.onNext(it) + Single.just(it) + } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/EditInteractor.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/EditInteractor.kt index f23d20d..e5ae29d 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/EditInteractor.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/EditInteractor.kt @@ -4,6 +4,7 @@ import io.reactivex.Single import pl.tkadziolka.snipmeandroid.domain.clipboard.GetFromClipboardUseCase import pl.tkadziolka.snipmeandroid.domain.language.GetLanguagesUseCase import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet +import pl.tkadziolka.snipmeandroid.domain.snippets.SnippetVisibility class EditInteractor( private val getLanguages: GetLanguagesUseCase, @@ -19,8 +20,13 @@ class EditInteractor( fun create(title: String, code: String, language: String): Single = createSnippet(title, code, language) - fun update(uuid: String, title: String, code: String, language: String): Single = - updateSnippet(uuid, title, code, language) + fun update( + uuid: String, + title: String, + code: String, + language: String, + visibility: SnippetVisibility, + ): Single = updateSnippet(uuid, title, code, language, visibility) fun getFromClipboard(): String? = fromClipboard() } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/SaveSnippetUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/SaveSnippetUseCase.kt index 8e69279..b4767ce 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/SaveSnippetUseCase.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/SaveSnippetUseCase.kt @@ -2,12 +2,19 @@ package pl.tkadziolka.snipmeandroid.domain.snippet import io.reactivex.Single import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet +import pl.tkadziolka.snipmeandroid.domain.snippets.SnippetVisibility +import java.util.concurrent.TimeUnit class SaveSnippetUseCase( private val createSnippet: CreateSnippetUseCase ) { operator fun invoke(snippet: Snippet): Single { if (snippet.isOwner) return Single.just(snippet) - return createSnippet(snippet.title, snippet.code.raw, snippet.language.raw) + return createSnippet( + snippet.title, + snippet.code.raw, + snippet.language.raw, + visibility = SnippetVisibility.PRIVATE, + ) } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/UpdateSnippetUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/UpdateSnippetUseCase.kt index e2e9da3..ad95269 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/UpdateSnippetUseCase.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/UpdateSnippetUseCase.kt @@ -4,18 +4,24 @@ import io.reactivex.Single import pl.tkadziolka.snipmeandroid.domain.auth.AuthorizationUseCase import pl.tkadziolka.snipmeandroid.domain.network.CheckNetworkAvailableUseCase import pl.tkadziolka.snipmeandroid.domain.repository.snippet.SnippetRepository +import pl.tkadziolka.snipmeandroid.domain.snippets.SnippetVisibility class UpdateSnippetUseCase( private val auth: AuthorizationUseCase, private val networkAvailable: CheckNetworkAvailableUseCase, private val repository: SnippetRepository ) { - operator fun invoke(uuid: String, title: String, code: String, language: String) = - auth() - .andThen(networkAvailable()) - .andThen(repository.update(uuid, title, code, language)) - .flatMap { - repository.updateListener.onNext(it) - Single.just(it) - } + operator fun invoke( + uuid: String, + title: String, + code: String, + language: String, + visibility: SnippetVisibility, + ) = auth() + .andThen(networkAvailable()) + .andThen(repository.update(uuid, title, code, language, visibility)) + .flatMap { + repository.updateListener.onNext(it) + Single.just(it) + } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/infrastructure/model/request/CreateSnippetRequest.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/infrastructure/model/request/CreateSnippetRequest.kt index 6d57f20..ad4b9b3 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/infrastructure/model/request/CreateSnippetRequest.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/infrastructure/model/request/CreateSnippetRequest.kt @@ -6,5 +6,6 @@ import com.squareup.moshi.JsonClass data class CreateSnippetRequest( val title: String, val code: String, - val language: String + val language: String, + val visibility: String ) \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailViewModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailViewModel.kt index 0a3ff75..a328cfd 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailViewModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailViewModel.kt @@ -127,5 +127,5 @@ data class Error(val error: String?) : DetailViewState() sealed class DetailEvent object Idle : DetailEvent() data class Alert(val message: String) : DetailEvent() -data class Saved(val uuid: String) : DetailEvent() +data class Saved(val snippetId: String) : DetailEvent() object Logout : DetailEvent() \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/edit/EditViewModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/edit/EditViewModel.kt index 6a363ee..c7ae4ab 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/edit/EditViewModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/edit/EditViewModel.kt @@ -112,7 +112,13 @@ class EditViewModel( getSnippetOnValid()?.let { snip -> setState(Loading) - createDisposable = interactor.update(uuid, snip.title, snip.code.raw, snip.language.raw) + createDisposable = interactor.update( + uuid, + snip.title, + snip.code.raw, + snip.language.raw, + snip.visibility + ) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribeBy( @@ -225,6 +231,7 @@ data class Loaded( val snippet: Snippet?, val error: String? ) : EditViewState() + data class Completed(val snippet: Snippet) : EditViewState() data class Error(val error: String?) : EditViewState() diff --git a/flutter_module/bridge/main_model.dart b/flutter_module/bridge/main_model.dart index 60cf63c..0dd6ae7 100644 --- a/flutter_module/bridge/main_model.dart +++ b/flutter_module/bridge/main_model.dart @@ -105,6 +105,8 @@ enum ModelState { loading, loaded, error } enum MainModelEvent { none, alert, logout } +enum DetailModelEvent { none, saved } + class MainModelStateData { ModelState? state; bool? is_loading; @@ -128,6 +130,13 @@ class DetailModelStateData { int? newHash; } +class DetailModelEventData { + DetailModelEvent? event; + String? value; + int? oldHash; + int? newHash; +} + // Api @HostApi() @@ -156,6 +165,10 @@ abstract class MainModelBridge { abstract class DetailModelBridge { DetailModelStateData getState(); + DetailModelEventData getEvent(); + + void resetEvent(); + void load(String uuid); void like(); diff --git a/flutter_module/lib/model/main_model.dart b/flutter_module/lib/model/main_model.dart index 6ab4331..a17ec60 100644 --- a/flutter_module/lib/model/main_model.dart +++ b/flutter_module/lib/model/main_model.dart @@ -79,6 +79,11 @@ enum MainModelEvent { logout, } +enum DetailModelEvent { + none, + saved, +} + class Snippet { Snippet({ this.uuid, @@ -400,6 +405,41 @@ class DetailModelStateData { } } +class DetailModelEventData { + DetailModelEventData({ + this.event, + this.value, + this.oldHash, + this.newHash, + }); + + DetailModelEvent? event; + String? value; + int? oldHash; + int? newHash; + + Object encode() { + final Map pigeonMap = {}; + pigeonMap['event'] = event?.index; + pigeonMap['value'] = value; + pigeonMap['oldHash'] = oldHash; + pigeonMap['newHash'] = newHash; + return pigeonMap; + } + + static DetailModelEventData decode(Object message) { + final Map pigeonMap = message as Map; + return DetailModelEventData( + event: pigeonMap['event'] != null + ? DetailModelEvent.values[pigeonMap['event']! as int] + : null, + value: pigeonMap['value'] as String?, + oldHash: pigeonMap['oldHash'] as int?, + newHash: pigeonMap['newHash'] as int?, + ); + } +} + class _MainModelBridgeCodec extends StandardMessageCodec{ const _MainModelBridgeCodec(); @override @@ -652,30 +692,34 @@ class _DetailModelBridgeCodec extends StandardMessageCodec{ const _DetailModelBridgeCodec(); @override void writeValue(WriteBuffer buffer, Object? value) { - if (value is DetailModelStateData) { + if (value is DetailModelEventData) { buffer.putUint8(128); writeValue(buffer, value.encode()); } else - if (value is Owner) { + if (value is DetailModelStateData) { buffer.putUint8(129); writeValue(buffer, value.encode()); } else - if (value is Snippet) { + if (value is Owner) { buffer.putUint8(130); writeValue(buffer, value.encode()); } else - if (value is SnippetCode) { + if (value is Snippet) { buffer.putUint8(131); writeValue(buffer, value.encode()); } else - if (value is SnippetLanguage) { + if (value is SnippetCode) { buffer.putUint8(132); writeValue(buffer, value.encode()); } else - if (value is SyntaxToken) { + if (value is SnippetLanguage) { buffer.putUint8(133); writeValue(buffer, value.encode()); } else + if (value is SyntaxToken) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); + } else { super.writeValue(buffer, value); } @@ -684,21 +728,24 @@ class _DetailModelBridgeCodec extends StandardMessageCodec{ Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { case 128: - return DetailModelStateData.decode(readValue(buffer)!); + return DetailModelEventData.decode(readValue(buffer)!); case 129: - return Owner.decode(readValue(buffer)!); + return DetailModelStateData.decode(readValue(buffer)!); case 130: - return Snippet.decode(readValue(buffer)!); + return Owner.decode(readValue(buffer)!); case 131: - return SnippetCode.decode(readValue(buffer)!); + return Snippet.decode(readValue(buffer)!); case 132: - return SnippetLanguage.decode(readValue(buffer)!); + return SnippetCode.decode(readValue(buffer)!); case 133: + return SnippetLanguage.decode(readValue(buffer)!); + + case 134: return SyntaxToken.decode(readValue(buffer)!); default: @@ -744,6 +791,55 @@ class DetailModelBridge { } } + Future getEvent() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.DetailModelBridge.getEvent', codec, binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send(null) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else if (replyMap['result'] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (replyMap['result'] as DetailModelEventData?)!; + } + } + + Future resetEvent() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.DetailModelBridge.resetEvent', codec, binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send(null) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + Future load(String arg_uuid) async { final BasicMessageChannel channel = BasicMessageChannel( 'dev.flutter.pigeon.DetailModelBridge.load', codec, binaryMessenger: _binaryMessenger); diff --git a/flutter_module/lib/presentation/screens/details_screen.dart b/flutter_module/lib/presentation/screens/details_screen.dart index 9a13186..1a53caa 100644 --- a/flutter_module/lib/presentation/screens/details_screen.dart +++ b/flutter_module/lib/presentation/screens/details_screen.dart @@ -52,19 +52,35 @@ class _DetailsPage extends HookWidget { Widget build(BuildContext context) { useNavigator([navigator]); - final stateChange = useObservableState( + final state = useObservableState( DetailModelStateData(), () => model.getState(), (current, newState) => (current as DetailModelStateData).equals(newState), - ); - final state = stateChange.value; + ).value; + + final event = useObservableState( + DetailModelEventData(), + () => model.getEvent(), + (current, newState) => (current as DetailModelEventData).equals(newState), + ).value; useEffect(() { model.load(snippetId); return null; }, []); - // TODO Add view state wrapper and show error for null snippet + if (event.event == DetailModelEvent.saved) { + final snippetId = event.value; + if (snippetId == null) { + model.resetEvent(); + navigator.back(); + return const SizedBox(); + } + + model.resetEvent(); + navigator.goToDetails(context, snippetId); + } + return Scaffold( backgroundColor: ColorStyles.surfacePrimary(), appBar: AppBar( @@ -77,11 +93,12 @@ class _DetailsPage extends HookWidget { color: Colors.black, ), actions: state.data?.isPrivate == true - ? [const PaddingStyles.regular(Icon(Icons.visibility_off_outlined))] + ? [const PaddingStyles.regular(Icon(Icons.lock_outlined))] : null, ), body: ViewStateWrapper( - isLoading: state.state == ModelState.loading || state.is_loading == true, + isLoading: + state.state == ModelState.loading || state.is_loading == true, error: state.error, data: state.data, builder: (_, snippet) => _DetailPageData( diff --git a/flutter_module/lib/utils/extensions/state_extensions.dart b/flutter_module/lib/utils/extensions/state_extensions.dart index 9479451..06159f1 100644 --- a/flutter_module/lib/utils/extensions/state_extensions.dart +++ b/flutter_module/lib/utils/extensions/state_extensions.dart @@ -15,3 +15,10 @@ extension DetailModelStateDataExtension on DetailModelStateData { return other.oldHash != other.newHash; } } + +extension DetailModelEventDataExtension on DetailModelEventData { + bool equals(Object other) { + if (other is! DetailModelEventData) return false; + return other.oldHash != other.newHash; + } +} From 1b228bdc95e6dff98c77b79da618453f76dc5e1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Sun, 27 Nov 2022 13:30:47 +0100 Subject: [PATCH 07/66] Corrected passing new uuid --- .../pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt index b65b37d..0bf13ff 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt @@ -82,9 +82,9 @@ class DetailModel( saveSnippet(it) .subscribeOn(Schedulers.io()) .subscribeBy( - onSuccess = { + onSuccess = { saved -> setState(Loaded(it)) - mutableEvent.value = Saved(it.uuid) + mutableEvent.value = Saved(saved.uuid) }, onError = { Timber.e("Couldn't load snippets, error = $it") From de33d9d49a1b97a505cb9d8c89aad2eebd5172fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Sun, 27 Nov 2022 22:30:02 +0100 Subject: [PATCH 08/66] Fetched all snippets at once --- .../tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt | 6 +++--- .../pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt | 1 + .../snipmeandroid/domain/snippet/CreateSnippetUseCase.kt | 1 + flutter_module/lib/presentation/screens/details_screen.dart | 1 + flutter_module/lib/presentation/screens/main_screen.dart | 2 +- .../lib/presentation/widgets/snippet_action_bar.dart | 2 +- 6 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt index 0bf13ff..2cd3a66 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt @@ -86,9 +86,9 @@ class DetailModel( setState(Loaded(it)) mutableEvent.value = Saved(saved.uuid) }, - onError = { - Timber.e("Couldn't load snippets, error = $it") - parseError(it) + onError = { error -> + Timber.e("Couldn't save snippet, error = $error") + parseError(error) } ).also { disposables += it } } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt index 6ee6ddb..e9a15ff 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt @@ -119,6 +119,7 @@ class MainModel( .subscribeBy( onSuccess = { mutableState.value = (Loaded(user, it, pages, scope)) + loadNextPage() if (shouldRefresh) { mutableEvent.value = ListRefreshed shouldRefresh = false diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/CreateSnippetUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/CreateSnippetUseCase.kt index be9164c..8880d89 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/CreateSnippetUseCase.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/CreateSnippetUseCase.kt @@ -13,6 +13,7 @@ class CreateSnippetUseCase( private val snippetRepository: SnippetRepository ) { // TODO Test saving other snippet in app and navigation + // TODO ERROR: We can still click save saved snip operator fun invoke( title: String, code: String, diff --git a/flutter_module/lib/presentation/screens/details_screen.dart b/flutter_module/lib/presentation/screens/details_screen.dart index 1a53caa..b8a75c2 100644 --- a/flutter_module/lib/presentation/screens/details_screen.dart +++ b/flutter_module/lib/presentation/screens/details_screen.dart @@ -77,6 +77,7 @@ class _DetailsPage extends HookWidget { return const SizedBox(); } + navigator.back(); model.resetEvent(); navigator.goToDetails(context, snippetId); } diff --git a/flutter_module/lib/presentation/screens/main_screen.dart b/flutter_module/lib/presentation/screens/main_screen.dart index dc944e1..6e431b3 100644 --- a/flutter_module/lib/presentation/screens/main_screen.dart +++ b/flutter_module/lib/presentation/screens/main_screen.dart @@ -93,7 +93,7 @@ class _MainPage extends HookWidget { }, ), floatingActionButton: FloatingActionButton( - onPressed: () => detailsNavigator.goToDetails(context, Mocks.snippet.uuid!), + onPressed: () => model.loadNextPage(), tooltip: 'Increment', child: const Icon(Icons.add), ), diff --git a/flutter_module/lib/presentation/widgets/snippet_action_bar.dart b/flutter_module/lib/presentation/widgets/snippet_action_bar.dart index 76a1d28..36c86c9 100644 --- a/flutter_module/lib/presentation/widgets/snippet_action_bar.dart +++ b/flutter_module/lib/presentation/widgets/snippet_action_bar.dart @@ -44,7 +44,7 @@ class SnippetActionBar extends StatelessWidget { StateIcon( icon: Icons.save_alt_outlined, active: snippet.isSaved, - onTap: snippet.isSaved == false ? null : onSaveTap, + onTap: snippet.isSaved == false ? onSaveTap : null, ), const SizedBox(width: Dimens.l), StateIcon( From e2ced346445bd4743f693ac42890d9fb5a961b56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Tue, 29 Nov 2022 08:15:49 +0100 Subject: [PATCH 09/66] Finished redirect to saved snippet --- .../snipmeandroid/bridge/Bridge.java | 30 +++++++++++++++++++ .../snipmeandroid/bridge/ModelPlugin.kt | 1 + .../bridge/detail/DetailModel.kt | 1 + .../bridge/detail/DetailModelPlugin.kt | 1 + .../snipmeandroid/bridge/main/MainModel.kt | 2 +- .../bridge/main/MainModelPlugin.kt | 29 ++++++++++++------ .../domain/snippet/CreateSnippetUseCase.kt | 2 -- .../domain/snippets/SnippetResponseMapper.kt | 9 +++--- flutter_module/bridge/main_model.dart | 2 ++ flutter_module/lib/model/main_model.dart | 8 +++++ .../navigation/details/details_navigator.dart | 1 + .../presentation/screens/details_screen.dart | 20 +++++-------- .../widgets/snippet_action_bar.dart | 2 +- .../utils/extensions/state_extensions.dart | 11 +++++-- 14 files changed, 86 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java index 471bfd3..2b056d0 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java @@ -705,6 +705,18 @@ public void setMessage(@Nullable String setterArg) { this.message = setterArg; } + private @Nullable Long oldHash; + public @Nullable Long getOldHash() { return oldHash; } + public void setOldHash(@Nullable Long setterArg) { + this.oldHash = setterArg; + } + + private @Nullable Long newHash; + public @Nullable Long getNewHash() { return newHash; } + public void setNewHash(@Nullable Long setterArg) { + this.newHash = setterArg; + } + public static final class Builder { private @Nullable MainModelEvent event; public @NonNull Builder setEvent(@Nullable MainModelEvent setterArg) { @@ -716,10 +728,22 @@ public static final class Builder { this.message = setterArg; return this; } + private @Nullable Long oldHash; + public @NonNull Builder setOldHash(@Nullable Long setterArg) { + this.oldHash = setterArg; + return this; + } + private @Nullable Long newHash; + public @NonNull Builder setNewHash(@Nullable Long setterArg) { + this.newHash = setterArg; + return this; + } public @NonNull MainModelEventData build() { MainModelEventData pigeonReturn = new MainModelEventData(); pigeonReturn.setEvent(event); pigeonReturn.setMessage(message); + pigeonReturn.setOldHash(oldHash); + pigeonReturn.setNewHash(newHash); return pigeonReturn; } } @@ -727,6 +751,8 @@ public static final class Builder { Map toMapResult = new HashMap<>(); toMapResult.put("event", event == null ? null : event.index); toMapResult.put("message", message); + toMapResult.put("oldHash", oldHash); + toMapResult.put("newHash", newHash); return toMapResult; } static @NonNull MainModelEventData fromMap(@NonNull Map map) { @@ -735,6 +761,10 @@ public static final class Builder { pigeonResult.setEvent(event == null ? null : MainModelEvent.values()[(int)event]); Object message = map.get("message"); pigeonResult.setMessage((String)message); + Object oldHash = map.get("oldHash"); + pigeonResult.setOldHash((oldHash == null) ? null : ((oldHash instanceof Integer) ? (Integer)oldHash : (Long)oldHash)); + Object newHash = map.get("newHash"); + pigeonResult.setNewHash((newHash == null) ? null : ((newHash instanceof Integer) ? (Integer)newHash : (Long)newHash)); return pigeonResult; } } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/ModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/ModelPlugin.kt index 089045f..1de2f75 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/ModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/ModelPlugin.kt @@ -45,6 +45,7 @@ fun Snippet.toModelData(): Bridge.Snippet { userReaction = it.userReaction.toModelUserReaction() isLiked = it.userReaction.toModelReactionState(UserReaction.LIKE) isDisliked = it.userReaction.toModelReactionState(UserReaction.DISLIKE) + isPrivate = it.visibility == SnippetVisibility.PRIVATE isSaved = calculateSavedState(it.isOwner, it.visibility) timeAgo = DateUtils.getRelativeTimeSpanString( it.modifiedAt.time, diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt index 2cd3a66..694bced 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt @@ -85,6 +85,7 @@ class DetailModel( onSuccess = { saved -> setState(Loaded(it)) mutableEvent.value = Saved(saved.uuid) + }, onError = { error -> Timber.e("Couldn't save snippet, error = $error") diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt index 182e182..da677e4 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt @@ -6,6 +6,7 @@ import pl.tkadziolka.snipmeandroid.bridge.Bridge import pl.tkadziolka.snipmeandroid.bridge.ModelPlugin import pl.tkadziolka.snipmeandroid.bridge.toModelData import pl.tkadziolka.snipmeandroid.ui.detail.* +import timber.log.Timber class DetailModelPlugin : ModelPlugin(), Bridge.DetailModelBridge { private val model: DetailModel by inject() diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt index e9a15ff..0c2b3b5 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt @@ -54,6 +54,7 @@ class MainModel( } fun initState() { + mutableState.value = (Loading) getUser() .subscribeOn(Schedulers.io()) .subscribeBy( @@ -113,7 +114,6 @@ class MainModel( pages: Int = 1, scope: SnippetScope = SnippetScope.ALL ) { - mutableState.value = (Loading) getSnippets(scope, pages) .subscribeOn(Schedulers.io()) .subscribeBy( diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt index 5962b71..8f1526b 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt @@ -10,8 +10,9 @@ import pl.tkadziolka.snipmeandroid.ui.main.* import pl.tkadziolka.snipmeandroid.util.view.SnippetFilter class MainModelPlugin : ModelPlugin(), Bridge.MainModelBridge { - private val model: MainModel by inject() + private var oldEvent: MainEvent? = null + private var oldState: MainViewState? = null override fun onSetup( messenger: BinaryMessenger, @@ -20,7 +21,7 @@ class MainModelPlugin : ModelPlugin(), Bridge.MainModelB Bridge.MainModelBridge.setup(messenger, bridge) } - override fun getState(): Bridge.MainModelStateData = getData(model.state.value) + override fun getState(): Bridge.MainModelStateData = getState(model.state.value) override fun getEvent(): Bridge.MainModelEventData = getEvent(model.event.value) @@ -46,15 +47,25 @@ class MainModelPlugin : ModelPlugin(), Bridge.MainModelB model.refreshSnippetUpdates() } - private fun getData(viewState: MainViewState) = Bridge.MainModelStateData().apply { - state = viewState.toModelState() - is_loading = viewState is Loading - data = (viewState as? Loaded)?.snippets?.toModelData() + private fun getState(viewState: MainViewState): Bridge.MainModelStateData { + oldState = viewState + return Bridge.MainModelStateData().apply { + state = viewState.toModelState() + is_loading = viewState is Loading + data = (viewState as? Loaded)?.snippets?.toModelData() + oldHash = oldState?.hashCode()?.toLong() + newHash = viewState.hashCode().toLong() + } } - private fun getEvent(viewEvent: MainEvent) = Bridge.MainModelEventData().apply { - event = viewEvent.toModelEvent() - message = (viewEvent as? Alert)?.message + private fun getEvent(viewEvent: MainEvent): Bridge.MainModelEventData { + oldEvent = viewEvent + return Bridge.MainModelEventData().apply { + event = viewEvent.toModelEvent() + message = (viewEvent as? Alert)?.message + oldHash = oldEvent?.hashCode()?.toLong() + newHash = viewEvent.hashCode().toLong() + } } private fun MainEvent.toModelEvent() = diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/CreateSnippetUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/CreateSnippetUseCase.kt index 8880d89..77b2d4d 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/CreateSnippetUseCase.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/CreateSnippetUseCase.kt @@ -12,8 +12,6 @@ class CreateSnippetUseCase( private val networkAvailable: CheckNetworkAvailableUseCase, private val snippetRepository: SnippetRepository ) { - // TODO Test saving other snippet in app and navigation - // TODO ERROR: We can still click save saved snip operator fun invoke( title: String, code: String, diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippets/SnippetResponseMapper.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippets/SnippetResponseMapper.kt index 1077ef4..478ce91 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippets/SnippetResponseMapper.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippets/SnippetResponseMapper.kt @@ -52,9 +52,8 @@ class SnippetResponseMapper { return getHighlighted(preview) } - private fun getVisibility(visibility: String?) = - if (visibility != null) - SnippetVisibility.valueOf(visibility) - else - SnippetVisibility.PRIVATE + private fun getVisibility(visibility: String?): SnippetVisibility { + if (visibility == null) return SnippetVisibility.PRIVATE + return SnippetVisibility.valueOf(visibility) + } } \ No newline at end of file diff --git a/flutter_module/bridge/main_model.dart b/flutter_module/bridge/main_model.dart index 0dd6ae7..d6f8bbc 100644 --- a/flutter_module/bridge/main_model.dart +++ b/flutter_module/bridge/main_model.dart @@ -119,6 +119,8 @@ class MainModelStateData { class MainModelEventData { MainModelEvent? event; String? message; + int? oldHash; + int? newHash; } class DetailModelStateData { diff --git a/flutter_module/lib/model/main_model.dart b/flutter_module/lib/model/main_model.dart index a17ec60..7eb0d05 100644 --- a/flutter_module/lib/model/main_model.dart +++ b/flutter_module/lib/model/main_model.dart @@ -337,15 +337,21 @@ class MainModelEventData { MainModelEventData({ this.event, this.message, + this.oldHash, + this.newHash, }); MainModelEvent? event; String? message; + int? oldHash; + int? newHash; Object encode() { final Map pigeonMap = {}; pigeonMap['event'] = event?.index; pigeonMap['message'] = message; + pigeonMap['oldHash'] = oldHash; + pigeonMap['newHash'] = newHash; return pigeonMap; } @@ -356,6 +362,8 @@ class MainModelEventData { ? MainModelEvent.values[pigeonMap['event']! as int] : null, message: pigeonMap['message'] as String?, + oldHash: pigeonMap['oldHash'] as int?, + newHash: pigeonMap['newHash'] as int?, ); } } diff --git a/flutter_module/lib/presentation/navigation/details/details_navigator.dart b/flutter_module/lib/presentation/navigation/details/details_navigator.dart index f937a15..ab7ee0c 100644 --- a/flutter_module/lib/presentation/navigation/details/details_navigator.dart +++ b/flutter_module/lib/presentation/navigation/details/details_navigator.dart @@ -13,5 +13,6 @@ class DetailsNavigator extends ScreenNavigator { void goToDetails(BuildContext context, String snippetId) { _snippetId = snippetId; router.push(DetailsScreen.name.route); + print("Navigated to $_snippetId"); } } diff --git a/flutter_module/lib/presentation/screens/details_screen.dart b/flutter_module/lib/presentation/screens/details_screen.dart index b8a75c2..28dbef6 100644 --- a/flutter_module/lib/presentation/screens/details_screen.dart +++ b/flutter_module/lib/presentation/screens/details_screen.dart @@ -31,7 +31,6 @@ class DetailsScreen extends NamedScreen { return _DetailsPage( navigator: navigator, model: model, - snippetId: navigator.snippetId!, ); } } @@ -41,12 +40,10 @@ class _DetailsPage extends HookWidget { Key? key, required this.navigator, required this.model, - required this.snippetId, }) : super(key: key); final DetailsNavigator navigator; final DetailModelBridge model; - final String snippetId; @override Widget build(BuildContext context) { @@ -64,24 +61,24 @@ class _DetailsPage extends HookWidget { (current, newState) => (current as DetailModelEventData).equals(newState), ).value; - useEffect(() { - model.load(snippetId); - return null; - }, []); - if (event.event == DetailModelEvent.saved) { final snippetId = event.value; if (snippetId == null) { - model.resetEvent(); navigator.back(); + model.resetEvent(); return const SizedBox(); } navigator.back(); - model.resetEvent(); navigator.goToDetails(context, snippetId); + model.resetEvent(); } + useEffect(() { + model.load(navigator.snippetId ?? ''); + return null; + }, []); + return Scaffold( backgroundColor: ColorStyles.surfacePrimary(), appBar: AppBar( @@ -98,8 +95,7 @@ class _DetailsPage extends HookWidget { : null, ), body: ViewStateWrapper( - isLoading: - state.state == ModelState.loading || state.is_loading == true, + isLoading: state.state == ModelState.loading || state.is_loading == true, error: state.error, data: state.data, builder: (_, snippet) => _DetailPageData( diff --git a/flutter_module/lib/presentation/widgets/snippet_action_bar.dart b/flutter_module/lib/presentation/widgets/snippet_action_bar.dart index 36c86c9..76a1d28 100644 --- a/flutter_module/lib/presentation/widgets/snippet_action_bar.dart +++ b/flutter_module/lib/presentation/widgets/snippet_action_bar.dart @@ -44,7 +44,7 @@ class SnippetActionBar extends StatelessWidget { StateIcon( icon: Icons.save_alt_outlined, active: snippet.isSaved, - onTap: snippet.isSaved == false ? onSaveTap : null, + onTap: snippet.isSaved == false ? null : onSaveTap, ), const SizedBox(width: Dimens.l), StateIcon( diff --git a/flutter_module/lib/utils/extensions/state_extensions.dart b/flutter_module/lib/utils/extensions/state_extensions.dart index 06159f1..50b205b 100644 --- a/flutter_module/lib/utils/extensions/state_extensions.dart +++ b/flutter_module/lib/utils/extensions/state_extensions.dart @@ -3,9 +3,14 @@ import 'package:flutter_module/model/main_model.dart'; extension MainModelStateDataExtension on MainModelStateData { bool equals(Object other) { if (other is! MainModelStateData) return false; - return state == other.state && - is_loading == other.is_loading && - error == other.error; + return other.oldHash != other.newHash; + } +} + +extension MainModelEventDataExtension on MainModelEventData { + bool equals(Object other) { + if (other is! MainModelEventData) return false; + return other.oldHash != other.newHash; } } From 1d43c87e3672f0a84df65beb75d90f5840f237c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Tue, 29 Nov 2022 08:29:24 +0100 Subject: [PATCH 10/66] Corrected save button states --- .../tkadziolka/snipmeandroid/bridge/ModelPlugin.kt | 7 +++---- .../presentation/widgets/snippet_action_bar.dart | 13 ++++++++++++- .../lib/presentation/widgets/state_icon.dart | 1 + 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/ModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/ModelPlugin.kt index 1de2f75..7cbf958 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/ModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/ModelPlugin.kt @@ -91,10 +91,9 @@ private fun UserReaction.toModelReactionState(reaction: UserReaction) = private fun calculateSavedState( isOwner: Boolean, visibility: SnippetVisibility -): Boolean? = when { - isOwner && visibility == SnippetVisibility.PUBLIC -> false - isOwner && visibility == SnippetVisibility.PRIVATE -> true - else -> null +): Boolean? { + if (isOwner.not()) return null + return visibility == SnippetVisibility.PRIVATE } private fun ForegroundColorSpan.toSyntaxToken(spannable: Spanned) = diff --git a/flutter_module/lib/presentation/widgets/snippet_action_bar.dart b/flutter_module/lib/presentation/widgets/snippet_action_bar.dart index 76a1d28..53ba0a9 100644 --- a/flutter_module/lib/presentation/widgets/snippet_action_bar.dart +++ b/flutter_module/lib/presentation/widgets/snippet_action_bar.dart @@ -44,7 +44,9 @@ class SnippetActionBar extends StatelessWidget { StateIcon( icon: Icons.save_alt_outlined, active: snippet.isSaved, - onTap: snippet.isSaved == false ? null : onSaveTap, + onTap: snippet.isSaved == false + ? null + : getSaveCallback(snippet.isSaved, onSaveTap), ), const SizedBox(width: Dimens.l), StateIcon( @@ -60,4 +62,13 @@ class SnippetActionBar extends StatelessWidget { ), ); } + + GestureTapCallback? getSaveCallback( + bool? isSaved, + GestureTapCallback? onSaveTap, + ) { + if (isSaved == null) return null; + if (isSaved == true) return null; + return onSaveTap; + } } diff --git a/flutter_module/lib/presentation/widgets/state_icon.dart b/flutter_module/lib/presentation/widgets/state_icon.dart index 23767c8..9920f42 100644 --- a/flutter_module/lib/presentation/widgets/state_icon.dart +++ b/flutter_module/lib/presentation/widgets/state_icon.dart @@ -25,6 +25,7 @@ class StateIcon extends StatelessWidget { splashRadius: Dimens.xl, icon: Icon(icon), color: color, + disabledColor: color, onPressed: onTap, ), ); From e95d26011a47636e0539221e65e00b7171aacc68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Tue, 29 Nov 2022 23:11:07 +0100 Subject: [PATCH 11/66] Created logic for sharing code --- .../bridge/detail/DetailModel.kt | 8 +++++++ .../bridge/detail/DetailModelPlugin.kt | 2 +- .../snipmeandroid/di/ModelModule.kt | 2 +- .../snipmeandroid/di/UseCaseModule.kt | 2 ++ .../domain/share/ShareSnippetCodeUseCase.kt | 22 +++++++++++++++++++ 5 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/domain/share/ShareSnippetCodeUseCase.kt diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt index 694bced..339d3ce 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt @@ -12,6 +12,7 @@ import pl.tkadziolka.snipmeandroid.domain.message.ErrorMessages import pl.tkadziolka.snipmeandroid.domain.reaction.GetTargetUserReactionUseCase import pl.tkadziolka.snipmeandroid.domain.reaction.SetUserReactionUseCase import pl.tkadziolka.snipmeandroid.domain.reaction.UserReaction +import pl.tkadziolka.snipmeandroid.domain.share.ShareSnippetCodeUseCase import pl.tkadziolka.snipmeandroid.domain.snippet.GetSingleSnippetUseCase import pl.tkadziolka.snipmeandroid.domain.snippet.SaveSnippetUseCase import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet @@ -26,6 +27,7 @@ class DetailModel( private val getTargetReaction: GetTargetUserReactionUseCase, private val setUserReaction: SetUserReactionUseCase, private val saveSnippet: SaveSnippetUseCase, + private val shareSnippet: ShareSnippetCodeUseCase, private val session: SessionModel ) : ErrorParsable { private val disposables = CompositeDisposable() @@ -95,6 +97,12 @@ class DetailModel( } } + fun share() { + getSnippet()?.let { + shareSnippet(it) + } + } + private fun changeReaction(newReaction: UserReaction) { // Immediately show change to user val previousState = getLoaded() ?: return diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt index da677e4..6189184 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt @@ -46,7 +46,7 @@ class DetailModelPlugin : ModelPlugin(), Bridge.Detail } override fun share() { - TODO("Not yet implemented") + model.share() } private fun getData(viewState: DetailViewState): Bridge.DetailModelStateData { diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt index d98ef9a..605058c 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt @@ -8,5 +8,5 @@ import pl.tkadziolka.snipmeandroid.bridge.session.SessionModel internal val modelModule = module { single { SessionModel(get()) } single { MainModel(get(), get(), get(), get(), get(), get(), get()) } - single { DetailModel(get(), get(), get(), get(), get(), get(), get()) } + single { DetailModel(get(), get(), get(), get(), get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt index 5f06f63..ff8d837 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt @@ -10,6 +10,7 @@ import pl.tkadziolka.snipmeandroid.domain.reaction.GetTargetUserReactionUseCase import pl.tkadziolka.snipmeandroid.domain.reaction.SetUserReactionUseCase import pl.tkadziolka.snipmeandroid.domain.share.ClearCachedShareUsersUseCase import pl.tkadziolka.snipmeandroid.domain.share.ShareInteractor +import pl.tkadziolka.snipmeandroid.domain.share.ShareSnippetCodeUseCase import pl.tkadziolka.snipmeandroid.domain.share.ShareSnippetUseCase import pl.tkadziolka.snipmeandroid.domain.snippet.* import pl.tkadziolka.snipmeandroid.domain.snippets.GetSnippetsUseCase @@ -45,6 +46,7 @@ internal val useCaseModule = module { factory { GetShareUsersUseCase(get(), get(), get(), get()) } factory { ClearCachedShareUsersUseCase(get()) } factory { ShareSnippetUseCase(get(), get(), get(), get()) } + factory { ShareSnippetCodeUseCase(get()) } // Clipboard single { AddToClipboardUseCase(get()) } factory { GetFromClipboardUseCase(get()) } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/share/ShareSnippetCodeUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/share/ShareSnippetCodeUseCase.kt new file mode 100644 index 0000000..4794dea --- /dev/null +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/share/ShareSnippetCodeUseCase.kt @@ -0,0 +1,22 @@ +package pl.tkadziolka.snipmeandroid.domain.share + +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat.startActivity +import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet + +class ShareSnippetCodeUseCase( + private val context: Context +) { + + operator fun invoke(snippet: Snippet) { + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, snippet.code.raw) + type = "text/plain" + } + + val shareIntent = Intent.createChooser(sendIntent, snippet.title) + startActivity(context, shareIntent, null) + } +} \ No newline at end of file From 7fa66b971b0b700e50db50f25b3534072759b84d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Tue, 29 Nov 2022 23:31:56 +0100 Subject: [PATCH 12/66] Corrected not tokenized code --- .../lib/presentation/widgets/snippet_action_bar.dart | 4 +--- .../lib/utils/extensions/collection_extensions.dart | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/flutter_module/lib/presentation/widgets/snippet_action_bar.dart b/flutter_module/lib/presentation/widgets/snippet_action_bar.dart index 53ba0a9..7864af9 100644 --- a/flutter_module/lib/presentation/widgets/snippet_action_bar.dart +++ b/flutter_module/lib/presentation/widgets/snippet_action_bar.dart @@ -44,9 +44,7 @@ class SnippetActionBar extends StatelessWidget { StateIcon( icon: Icons.save_alt_outlined, active: snippet.isSaved, - onTap: snippet.isSaved == false - ? null - : getSaveCallback(snippet.isSaved, onSaveTap), + onTap: getSaveCallback(snippet.isSaved, onSaveTap), ), const SizedBox(width: Dimens.l), StateIcon( diff --git a/flutter_module/lib/utils/extensions/collection_extensions.dart b/flutter_module/lib/utils/extensions/collection_extensions.dart index 68e6eb9..3ea6cff 100644 --- a/flutter_module/lib/utils/extensions/collection_extensions.dart +++ b/flutter_module/lib/utils/extensions/collection_extensions.dart @@ -54,7 +54,8 @@ extension SyntaxSpanExtension on List? { final tokenIndices = uniqueTokens.expand((token) => [token.start, token.end]).toList(); - final phrases = text.splitByIndices(tokenIndices); + final phrases = + tokenIndices.isNotEmpty ? text.splitByIndices(tokenIndices) : [text]; return phrases.map((phrase) { TextStyle style = baseStyle; From 3dc60d64a95393be54eb98bd3ccce55749968700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Sun, 4 Dec 2022 09:43:06 +0100 Subject: [PATCH 13/66] Created input card with fields --- .../presentation/screens/login_screen.dart | 11 ++++ .../lib/presentation/styles/dimens.dart | 2 + .../widgets/login_input_card.dart | 42 +++++++++++++ .../widgets/text_input_field.dart | 63 +++++++++++++++++++ 4 files changed, 118 insertions(+) create mode 100644 flutter_module/lib/presentation/widgets/login_input_card.dart create mode 100644 flutter_module/lib/presentation/widgets/text_input_field.dart diff --git a/flutter_module/lib/presentation/screens/login_screen.dart b/flutter_module/lib/presentation/screens/login_screen.dart index f180966..8b60964 100644 --- a/flutter_module/lib/presentation/screens/login_screen.dart +++ b/flutter_module/lib/presentation/screens/login_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_module/presentation/navigation/login/login_navigator.dart'; import 'package:flutter_module/presentation/screens/named_screen.dart'; +import 'package:flutter_module/presentation/styles/padding_styles.dart'; +import 'package:flutter_module/presentation/widgets/login_input_card.dart'; import 'package:flutter_module/utils/hooks/use_navigator.dart'; import 'package:go_router/go_router.dart'; import 'package:go_router_plus/go_router_plus.dart'; @@ -27,6 +29,9 @@ class _MainPage extends HookWidget { Widget build(BuildContext context) { useNavigator([navigator]); + final login = useState(""); + final password = useState(""); + return Scaffold( body: Center( child: Column( @@ -34,6 +39,12 @@ class _MainPage extends HookWidget { children: [ // TODO Extract strings Text(LoginScreen.name), + PaddingStyles.regular( + LoginInputCard( + onLoginChanged: (value) => login.value = value, + onPasswordChanged: (value) => password.value = value, + ), + ), MaterialButton( child: const Text("Navigate to login"), onPressed: navigator.login, diff --git a/flutter_module/lib/presentation/styles/dimens.dart b/flutter_module/lib/presentation/styles/dimens.dart index 8b255b6..a59b6b7 100644 --- a/flutter_module/lib/presentation/styles/dimens.dart +++ b/flutter_module/lib/presentation/styles/dimens.dart @@ -4,4 +4,6 @@ class Dimens { static const l = 16.0; static const xl = 24.0; static const xxl = 32.0; + + static const inputBorderWidth = 0.5; } \ No newline at end of file diff --git a/flutter_module/lib/presentation/widgets/login_input_card.dart b/flutter_module/lib/presentation/widgets/login_input_card.dart new file mode 100644 index 0000000..e6ac7d2 --- /dev/null +++ b/flutter_module/lib/presentation/widgets/login_input_card.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_module/presentation/styles/dimens.dart'; +import 'package:flutter_module/presentation/styles/padding_styles.dart'; +import 'package:flutter_module/presentation/styles/surface_styles.dart'; +import 'package:flutter_module/presentation/widgets/text_input_field.dart'; + +class LoginInputCard extends StatelessWidget { + const LoginInputCard({ + Key? key, + required this.onLoginChanged, + required this.onPasswordChanged, + }) : super(key: key); + + final TextInputCallback onLoginChanged; + final TextInputCallback onPasswordChanged; + + @override + Widget build(BuildContext context) { + return SurfaceStyles.snippetCard( + child: PaddingStyles.regular( + Column( + children: [ + const SizedBox(height: Dimens.l), + TextInputField( + label: 'Login', + onChanged: onLoginChanged, + ), + const SizedBox(height: Dimens.xl), + TextInputField( + label: 'Password', + onChanged: onPasswordChanged, + isPassword: true, + ), + const SizedBox(height: Dimens.l), + ], + ), + ), + ); + } +} diff --git a/flutter_module/lib/presentation/widgets/text_input_field.dart b/flutter_module/lib/presentation/widgets/text_input_field.dart new file mode 100644 index 0000000..0642ce7 --- /dev/null +++ b/flutter_module/lib/presentation/widgets/text_input_field.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_module/presentation/styles/color_styles.dart'; +import 'package:flutter_module/presentation/styles/dimens.dart'; + +typedef TextInputCallback = Function(String value); + +class TextInputField extends HookWidget { + const TextInputField({ + Key? key, + required this.label, + this.isPassword = false, + this.onChanged, + }) : super(key: key); + + final String label; + final bool isPassword; + final TextInputCallback? onChanged; + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(); + final value = useValueListenable(controller); + final shouldShow = useState(false); + final passwordVisible = shouldShow.value; + + useEffect(() { + onChanged?.call(value.text); + return null; + }, [value.text]); + + return TextField( + obscureText: isPassword && !shouldShow.value, + controller: controller, + cursorColor: ColorStyles.accent(), + decoration: InputDecoration( + labelText: label, + floatingLabelStyle: TextStyle(color: ColorStyles.accent()), + border: OutlineInputBorder( + borderSide: BorderSide( + color: ColorStyles.pageBackground(), + width: Dimens.inputBorderWidth, + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: ColorStyles.accent(), + width: Dimens.inputBorderWidth, + ), + ), + suffixIcon: isPassword + ? InkWell( + radius: Dimens.xl, + onTap: () => shouldShow.value = !passwordVisible, + child: passwordVisible + ? const Icon(Icons.visibility_off, color: Colors.black) + : const Icon(Icons.visibility, color: Colors.black), + ) + : null, + ), + ); + } +} From 26bdda70e6650f090077fb9e4f22bff966f091f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Sun, 4 Dec 2022 11:23:33 +0100 Subject: [PATCH 14/66] Added logo image and text --- .../assets/images/illustrations/app_logo.png | Bin 0 -> 2046 bytes flutter_module/fonts/Kanit-Regular.ttf | Bin 0 -> 169744 bytes flutter_module/lib/generated/assets.dart | 1 + .../presentation/screens/login_screen.dart | 42 +++++++++++------- .../lib/presentation/styles/text_styles.dart | 12 +++++ flutter_module/pubspec.yaml | 6 +++ 6 files changed, 45 insertions(+), 16 deletions(-) create mode 100644 flutter_module/assets/images/illustrations/app_logo.png create mode 100644 flutter_module/fonts/Kanit-Regular.ttf diff --git a/flutter_module/assets/images/illustrations/app_logo.png b/flutter_module/assets/images/illustrations/app_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..8a0bce37b64b4397dffb2e45e57eb1341540c84d GIT binary patch literal 2046 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 literal 0 HcmV?d00001 diff --git a/flutter_module/fonts/Kanit-Regular.ttf b/flutter_module/fonts/Kanit-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ef204c1b65c2bed16ab38e4bc68872ad15f7809b GIT binary patch literal 169744 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≥A=9^h*Bz1kvEwMBz! ztM!^{#~PC(YrOJuqyE0NU7e0KVb#=Ff)>(C)mz)*SZ9)8-4H5QRazaWvpQ5)Z5t3hG!Ry8ohA2`R<)C?X6mf!>slP^!)c`T z48%Zd7F@MP!BuM%TrF}p2lUbe`}z=Vnk|G{EQFc^w6Yp&Zi!nTL7VlKT&=hC(R!_q z)|>ihgW2s3p>9WQ2mPw9db+ysQ4jCwuG@+_0 zCg=((swbJ0s5TW!MRkKof#wR`w~FQpEls$Z1a7V{^EX$RimJInQwr(2Wn4{)HdkmG z;ohWdbA_f6?i*udCcrYFqPeEl3fDRbSSe;(cRNm9MZk-j`9A`#0m#+gG^k_m= zAhT3e(Gnh8Sz%UCSrJx2rPZ5Ct2dQaZz`?cR9dZ9TD_^XdSgzB70spy;%fEA6f@jg z)T-2+$GtgKHdmU|ZLZR42EDb~ud>>&vf8hzF}qMz6W)a?3!ADsE43~n)xxdHqI8u- z=_(7iDvQ!pjaKQ#h|(6ORTiez7KYUpX4MvEHCB9$g?WvYUK2>SC};Ams?y|hRmDVI z4p&_cS6vQQT@F`W4p-erTs6gU)xE-1_X=0tE6AydM};|UR8%yWcvP4ZCep3?CRp`N zuUjJ6`!=j!8?CSX!iy*!T+z2-jT!3cTsfetuWhW0TG`#x)45`K z-$v&^f46tl`n6^Xc5eE9gfCZ7FF2aNV#fDRaRF_h|(&&qpx>)l-Al3vhtX9R$FSg+T8u2tVzV`iux!$i-eV^ ziRX~vCBI!;N9Sr1hene7TKyTvRSbfTdFO# zSg{r#sw4T(VD-VGPm^^!BD`S}XDel7c#4Jkut{bQtfbN5rNTR6rNbT%!!6!?P>%Pg znD0AF)$cn?mEOXpXG5yIbQFmctTyDBi*lfN8K%9|WJiH;r2SzG94oPv8AU8Ge%u_+0vT81WKTuSwYaHTseB z55{4LX))m2h@8L?IiJ$MX83mqpJZg|)Jpt5X?(6ztMQv+r0MV<^e@7LUmD3e{G9%U zpW)zpc%GO2Z{QcSfYt325Cv8-JYQXe5IoXVzjf{TH^WjUJa3@<%CPc*xS>}m8xE4b zH<+0I18L@aO9Pw|4$i=rai)PcuEDM;Z{>>D0J;r6D!uk=nha_x`IjOWoSXpzdtDW5 zspi{IMV9m%kGKaMR(OY3EbN@&T%HC_3x6kq^Ho%ebAckwc~*5&RwbIhuz@pd+wwa` z$BdCMtn3nlClm17t;06Fj@sx^u{9gf{u)?4DU(|715hrJMB4hv#XK|FiHkyq93e2l;?S=z}&R?!!`cO4xs1AVx5k`pkBM zvAgl!gWyM}Yy6Q9nET(=U*zyV;lIQGJ6!vuKN{}vf91aquoXG)kXrq30T2#Whu-)< z@Shjz6LM>+A)nO`$?gAHa`?YD%jsIaLc1TxJ?gXK_1%5I1FZx;;eEI07o@KAOZO+N ze~5W^C{2SHeTMh@2B{BB1GKlJlocPMT_6?xz%xamPoQ6B40^WH#4%8xrjwQz)QUEO z^;ld){y@9Bim(XDgmNPR(UL5a`6ZILk0D?lmT%bl98TQ&Ve zN@ZNWYmO^i!}yKTcUa@`Gsx*3{$n7&GGcwG`Et~cQHRi04Z9I@Iqu&@jh~_9S@49% zlcdsO;Dhp#9^^PMc+ndD);QAmekC-~^xX~l{{Ywuh9-S?hU16W7fp>~z0EAp5UyrE z6MKo)bqKFy`~|fDLXrH~8PrhHrX^Lnyrv8KV@b9ic``;Ui8JK{cVYCC)&dxyr7%u| z3VNLTmjvX?f1>0DJcFEOTOaCn!}I}Cck5?#r9aY_4+7XCZ0bMJQl>r?&TIXnYYDW6 z7`Zn;;2gLWywkn4`XPLk(GVO@=ADj#4hWP&x~0*r8ZAAHe9#7_uF-N9=#%Ix&>V51 z?>|HPW?uvP8&Vs<2RYw`JXrW5|M!49q&@t;;y+b$^m|~i6?A~sg{C|Y^`h4Z?=(ji zV#4KbYk=CwAvuHGLRgQSA-xI89rEkZ9L__;2_+)>AHdzjLhuO0gkK}%DvTeJC(vIr zzsXA-KLkI`f8mg&4Xyk^ji}3f<$o(sAI9Tb{sI4y{)9_bF4zCS) zt@+5mNB1Z=JDD`nTuBJXM2K#h_c8+B4aMpDweHX%NfkNM`cHD|Sj^|Ttmd1RX*2#a zYt%%JkDoCLEdD}PWH!R65LpSztzNaD&fSnniABA-A1_*Rtw&Ly$BehH&0l z@)scuW}L=5Fz4+(|Ay%(!NaN@YbjlBx3m$MhlE-Hi4mR%=+R=9(fNb*1JkzvTeVKY zDAnUm^ojKF1L(D{gf_UtC_+6?fZIaxAiZ>LqF=ZfCq8JD~+o53mc)TV^N_YtlgK)B0LCWVS24u-edhq~^EKD{K59v{TdP2`Qn! z!e8hMX-oIhq;G`&)0BwN*HX89WTfeTuW^gs2bL|Z1 zYmp?-UDhYBXvz!yzekM7oGHgL=6(j2SR-mF6}+*|4%Lr%;hmjm6V77j`aZ-t@TDA=Kt&(&@E_DdTL3RmLh2vAFMY->SU%m&&m_fNBSzM zAslpGJ>LmMKrPu>K%()r#s}(yPD8yIN4h;tW!ziq6vTy(3Yl+%JAzO<|7|F|E>kB3 zHM=$Ihh_(nK>1kQ6C04+S!)XOPErH;0x|z5*YL6OpVSkOs{NIbJV7dB{S=UU^gw0; z85w$S0A9(646N6l2;`Hx17l;jcC7LCVnEtWy}fr%&3$PtFr(=4g0@8xpz}lm@V!GM zN8=&mZKr=1!g`+?l%y9mo{{@isaMMnf)2&$X%Rb~8T~@Enga)|7Y60zGJ*K5v z=8|i)-nl$NMo!f@Sh9f)ETdCgi^0QcaGg>o7{V5Hy|$g~upBj7K%6uz2Q!p3+tm*YK~ z74TIpfnVomd>wy@cjaFXf6j0C2KZHu<{MRmYQkG&r>SZ1Va1=ppq7!)H(B^HEPJ`I zdFJ8E@Yl;nTmileU*$rCi|{rehPQ1!@-)DEl<_XyLs9xLYDd_M?-@24R6-{1lG1sISP)b(HxEUPQ}2(Itg#tO~*TeGPnRX zu|gh$_XNR98ZpIOjQ4Ywz#}>a9?=sJo`kn|x!42mWrcU~E=G6>FG0?wyc98qa|dEp z;0#E@pOYJ$_>%JASNkpH^LOyB&4s6H4uL0lYghre(t!6dHmN53f;aFuRrr?|{KEuj zB)+E(%^~@(gqc(#h}uwSjwQpw2V#t@M)^Zup*N!uvG9 zziQ3D(VBl%nt!pHe>vdaZuIG6aIOgNl=>19@D;vzi!i=6ybaiVAMp2}qwumq^D;&A z(gj{-fU=pKNp`$@*o|;5=Yo!TjDNHMj|&kl;v%Gu0l)KkEI6G9PM1mj$>6NAEkLePIJ8+WAH2Z++!5ty{p3SN*RTa0O52lxa%9qb2fA^t32qPB)S@U=o-MU0M`Pp16&W-3V0aS z#V7rz(o=w^0nY%Q1w03M9`FL-MS#NCBi^B2g7i75*fR#$88{AEg;BxkFq{VPG8;m8 z9Dtk-yA9clB)cP{q&g`#sW3Mww~((Myn<^7Usf;esjF5iP-7{S)&=S`)E7;qo^jD^ z!|PGdZ^bs%h5ET{>9M8h5vs}pLOiD^}xJHyzr&OopkI8jTs5VN!Klas<92j11*#;64)-k1;b;fXu(JUPGk6mOkcBC$$QH}WVN>1Ul*<|I>LQ0@?98|* zybWTUb5?39_)Vj-Q?pXDMvlnLNKZ>lh>MAG*;4HI0~p9byhTE1UQsyi5y+4CENIOs zX)SK395=4Ap}1x6hOjOeg{PmsepEwkX<1omZNsP`{qWC6KLV7_rc3WLNj=?{K4+0< ze1PIH!%#d{Q+y^xMMW=*-YF0ly4a3?WtO{2;=1cX9kX`&e$IVJ*^8oho^2Q*-I)5L zqhh1G$mJS4Ga*JrMU9J^RaBIbjyKem7nK#2m6jBb8#^X1cl4;Nk?DmQg=jIYuplWB zDurw7>S}9>^79-{gsQ93(^4JIwEgwBsv>vf$i%GJgp!Q$H3h})eRjS00F|DS8t1fE z+nvSvx%sZ#WA`b_ni4xOmllA#<7vQiRFX>>D%punj|UwLO$P_r9U(dxarlE91G;3t zbSNxLPh%=CD=aNE$xxK*&dDA*BCR03ATd5R8a1SHYN9ErVRV3^nULmi?2i(6j;tP^ zF=AX&Tv=vzMfr%D5z%A2_DPMll;p(Z*0{u!l!Alf5^o!x5&``^2WmPVGgzyq$$;8U zVyB^IFxXG0yBKsi9g~9H=CpT_VT_#_4ZFtA%^T8V zt>yy^NTUlp(IFO?x*iG?#+UX&{)s*9Y?u`}bNmCH5G1y)ZSTGAQPpV-~)yJ(xIAUiuP zji|67J3l)=FV{UfXH;5NT9&MHfOl$Peo{hk{unZS3?uYADn^Y@%#KYc98pKLACLhtZ`&J+K=l5L(bLNr(jh9O#LvPsz2WHSuie35fZ z^~mV4gWs6M`!t+*btx%{DVSc8Q}SEmlFr;00nbBg6jE0@!2ye zVrmi1X6=LGsp~#-BV?*H*$0O9Lzw=sADRiZ?O5hwhoeli_NZVMJL2q)4!w0Uvocgy zdQ1!!^6@diIk=08irhtsq6*(hK|=-hQd%qB#t_aX+OZ>tST8-<{BTqO9>DVZ_oOlY)DgTbHl~#1uh<( z9FEMht*v_w2=WkwDfJiN`QFp?M(-j|d=5LYd{!JU&#qc(Vw9|1SQb;**DIq_i;?U+ z^%fkjW=n-GaR)QW{@hBPiCwe-*)YlsEsy)F2Y*AIB%4LCUL~a!7A7S{xw6e2y~B~F z_qDR#%*QUbrfx#mE=>{KN6)ZhwPVGMvE@)*F1FdqX9h-0GVse8m6@KDoh<)jng}e-;UXC6 zoqioPBBk}|WSF`+^sNTidu#tgiyD?Dr-@?9LwjlNbhjVsQ;$IuXMrdfpu0Md1l^G*T zlj6pY7*$m~0*R5KfDM?| zB-mrF0zVi}sEh2w3}9atKp8K7!|E5M5@4c|PmF66ggQ4ZH?2A?AHTV^`B*F^<(J~` zAqRK;^b?OBTz|i3=~ZLYX$zlT@XUf+Z~d9&f6gHdMxKl%w4XsBA>I^k$p9oa+GbxR zCKyMwYZYYKP|LLa3f36svVb*4WC13aR9lKoTaTYP4XpC30#HuzTBPA&|+LmL_(WBXAjb>v%_?3f+nZx0zgpV!zLsWKGxis4Y zGo|dd?Y5j`<*<(yt!Zt$ftn55ZoA#)#Q*@l4u|1&K@`Uuvs~C9WsgixO^9>mx^m53 zcT{NCy+52=%w2fluxOjR@z&uG7hWf7UXQ02J$S{S%_tlJ`WD3lZ?Q)k*h= zqD0w=O#_rZUZd+m8eVA=PJ4$J9U6~SmAT<4krEE4!e-%L7x9!v<|;jl(tvCj#T6Oz9g_3&V>!5+=ZCTBB`JdHsmBh_Ni2^Um0LEpc+A+W zGdB;F&VqRradi_~zqA_%xRSE7;|`0z{j4Fv8WXPyD~c=e-?x^?1|`AVV@F7W-Znci zj%=`Z{F{;>(|mA?oU(7}{f%k=}dLn<#*qF<$}VM%a(N&gN?ap=XA6ai-i`nAQX)gQ!djg(J$yf8G^yhH*@j=&Iv=~#RcGo z?y#{0>L39Mxxq74bPL`crLA~SCt}5e5?KaHU{ZxR0i~cBZ)9e2q7$aPXv>HfrcT(J zIKmNQmu1cSbO>(hVMxx_JuO42lmRLo_kWQ}hyUAD!X7Xn78%rGZvJwINQa(9&4mZd z;r22+R@*sbL)c~)gH>S06-m;0_4Y3nek-XNsaO*Pg-WK8;TS7B*r9^efxTcDPxlny z>;YW$%X^VbG0qSH+dmrr0S*SXHbZg18_gn-C~Iwn?iSG^AQ403p?tAagQ0U3=Jo&` zWQFDcp>U>#C0h)|=%ty{l_i~*MVbCiQ=LObc3{~l>^~9Y(O`dxEe@|!? z=8#ob(%)g8Mb&t=?%5fcp`mfP!O z(SBZ7=b_ZdFeN0NuDj3VhpF3YHCtl#%a&M?lu*z0>1@@o^1 zg?c|cr_uG1#`D@9Jq9cBt7y5w&>x_`r*Sx%a3W)A44yA5<}*bk(lBGcDkwPzqZN?31MmrGw0(ZSX)*81qFo( z;WdQY_5ZCZ+C1Y-1OM;@3>j?pd7eC+YFWE)8~+tygxrUTKV0LIA=_mqme| zL|caa;U`Wx62!!5TKHWlu7FmIwDHSXX3)kT#{7%K$REaP^@6tXPYfu+z-mJFCjYC| z1QZh%z`-@l=Sv5}Cq~(S;D{kOEs7r2>!bu)XR69MgeS0%(^{S#bmMGskRiAoJyaSi?*%gdk+4Nyw#U;isg z$x%ZXrGAm4hEGG{q>Uo*+zQf455g<8F?Ok^EliJ*7?1RX4d0V!>x5x=d9oH_a@3IK zPLm_U$is$MY_3wY$H@@Y5{~KTk%tX?!f?KOud{~Wk|RJuaTEPtk+2qr4PhkQgwuww zsbBpbhYjNc6vV0=M-3uvqvkFj<1N#ECzfidd~jxl9*0>$G8C zQfg+?V(P^VF;q(TSF?Hk0OUr?*@JwdT{#i^fqX}pwa+?-2$R@xk>?OrzlR(_Kv2vb zM1>Y4xYmVqxeAGqA?tbVy0gFayqu&)icv_xTI=~?EaG7d<0FSLi9Z}BPftWz#OuSD ziRIRJU}hAJ3*%joX?Am~j@?xhMF}w-u8|>PCQar02Or>q!FP0pgCAq`h;8;z*k-e- z#nULZ*%;W3)AV6Kl*|m7mkhfNZlx;ROzQ}kX=PKBnj9YsQ*Ab9JBONT1D4tewPLw& zIC%ASTU*XwGwJYg^Hv!RDfdJ~>?G9@87L^+NFlY`vn_vPOvE+?nsnH|@Q$RkFOS_TM9q zxu44&wmWxeYt6y??TTzGbXNiv%E+z+cYR`{{4cr^vFnR4SPo^*Q|1O?&agc(9erB& z*^?30U~tP>&whqOVCIb+7O*eN>BXo~OrzYRz>mn`5&f@*2acqi!w-?mM=dTNc9^{T z*ki$wA`2RgxSVSwu+uhz?T#EAZD1UW!Nl5)I_yr<2!Ltbu#Pw&V}TmwD1(iMOwd5pM(?Gu>Av2Q^q` z8ZN`>+7#JQ^T1oY_#9BOmix{=5uFqp8V>39%#MMg*6r zv3iLbB31^DCqf?f0n%L=RKejye064hesOVrJzOC4j6d5lcVKcG#`YFQCrja& zGz>f-vA-pL2*V6WL4ayKSl)*a6FZ3(Wx5j_$8$^`_+~?b;nidwd+5<`XzO$}gjP=G zwTOI=*YnF+b26=&?#qOTU_S@Jgn3WR$O_y*RA;oA$*Gv%Ahxo)Tn=-sq19xTC)12} z$g0Imw3`~V&6Adpz?6`j0DELiG>+opIo@1d;3Q5T#eoH>jzawMiSs6B*1H>Pd%Fi0 z^+snbjgFgeAU{4>v2=NK0Acj%D?@fM*0VBl6e)gXf|PLIde|h)fy@!K5mzU*z&3nD zZ(wI4B#>tip<`PyVw0%WQx$kb0qY`d50=MA2Zha|d5Jjlo+v^Ymoji)l)zH2w_yCTJ!mZs0OGuj#SwswZBxYE=dbGARc zH{7)v-Fh8uWQN)Z8!~oqR}8lMY$GX2+AvHH)D*N)ZVrr8X~VL@q92C;6SP4_u4YDz z5D#MGG#r*eKqzNzV6E+cj4N{6FAo5TgPbTh=}S7c`%m_&m-QyUHjLwU8hQUQ$E`YB zm#*klFJaeS7pTua-x?!Mnr?2{aaM()qE865!__hoB<*pPioa(w>h*&i`;MC)DrS|cY5vlzDMAdix7xId|wNPXl_S78_@`~j>{$|{eZFHub`R!{P z?Xb4dVe+dt&6#N-_gdG!Hp{x{h)r@kct70MSj+SKkb`JL_Qn6vpp^3{Y01sSFZTxa zx~P!J;V+=ap_VeJ&l4T+rVDIz!IO{|9b96hqZ-Nr3#yHKKMvrIst*NLM*_uYM1{+b$n$QN+QZ0LLK!s+{ z?!E8a+N&On+-)ALMIMj5`M>A&TFb+~FQnd04<}LN@Z~Ulbpi_72DO%v7onhU4C>|x z#SCUh^}DpHNh5SxR1F+$?cypbo;WxoazN$SwM&Ec?lP7;LwlX%r1)58nk!9qIZ6K? zdv5|J*HxX1o^xuh>gulQn!CEXx~jUX=Se-OH6FF*(V8vUvRW-$l4V&~27_#4TXL`r z_=fp1gb*_X!Vm`E1VaMxO-S+{nL|Rj3A{{XfH0ZI5CZ|V-oN&6raDz!l1&o6?~#z$ zUHhD~hqc#Ud#!){D;~6j3+&(jMXrSQ8Ql=dXsh*@z{kit07VNMq^eiKAwPU^JSa|E z+=C)I1Dc3gwztQ9EEbQGXVR0Xcmi~|wmR?ytj_Xs?lqkYA!~8S`t~a4E2V@t<%TV1rSg!(e<$QKya1 z4qoj99kzTJKE)T0dogMBZiIKwt2?mhpF2n$#?(LOp`$XSOz5LPW?GI)Xn*esal$ZV z7V?^jnS>tWQB`{9R-Bm&^j?V^Hs8*A7E(s_A$pavKyoCyh=Yu}==TGP74qZV7z4UW zCkyW?&KH`19ly79G3xJ^DPmd4*ub+{-(Fq+*$n%j`8;ZwCM1 z826Ag^2=sB>{teJx3{Mpoz8HWgd`jd1D)@+iVEE)r5-P(sDDv)@b5(z>DD!Abd=`l zMaDc4uHXii7Doon|I%ZGTfhvwaVy{o*Zz$}9V;7U+0GC)Y;*<@gZhyIY3`Y*5>2J} zo(W9P#wSy=sXz+vz4_+zk3OpIP$SiYRkiou=MK(&l@j_%p?m@_Zy`C41W}NPLf;;& zyGOgscRdaqxglGWmiBnNJWCP@x+Qulp7{C;fAYr{?*8cf$FR*8)OX{e`1z%GsCO)l zaQP8mh$-hjDL=v2k*q7rmjdidjywel$*?wx$W!3G;%zmoUn+`YE*gs>iwwhH9Skvd z;-uL@cDIn7d=FhNY~3_27%1FDPRky>6ojsGQ?2B%G*qh!y;M=->rbeA?>@2gzwp|s z`X!A1i%Wepa?I_mOh-it>Vn)~oab0HU~I^$=a-6rk7pk;Jr(sv3DMw3JQMB=iQ2?h z#D~;8!9V^~eop8tmyeQ{D@I6UGQZoxK#j@-NDiHXYJd?QL?n=Ge|D9O1!9Exm3fag z$dvK@F#)Ieec1&AOJVQCwJtG}QMv<&xpY5F!eP1hm^0WoOvI4cQ6j#!$PW@@jt$L+ zX(p%A^O$DrZOb$d9$WfNIYJQ>bBynRF}|ap5X0jlowFX54$xKFeFl!|$>k=2G_&Z? zOGtW+DG-u-EzSCqQda8IxQN22mzEHW6j&$N>8JUc!!E+fr7G=lf*aw+no?7K=J1mS z(X@(P%~l*d0iRhl5HCi@dY7I#Pv77D_1H~5U%Br#^$(VSL1+D_ZrsPV`Mt)JJl`cJ zJt#&SBUNY)_jS;Sa$`}9q>CU#rXgC$f^GmK#`Xe0<}5(?+#02L0}_1$JY$swM!Hg6 z6kN-sO`DYbo}fvnwMi|K@vd6abm=iEDz)(tv*+j&#R*=67)As(`3PXeSffQ8`lGno zL)S=5c@)WY0B>ia87xi&jy1M8Miz#@&yqi)9{Yv2z3ms?{?@m?{nWz3sXG=H!tegz zQ%^qm)Cb>v?4C=HpF8*XrF*a~*ms#8Y6Gfst-i_xko$b}cX(In^fVCU@fcyqGpSfU zo{xmQZN4_9k0hl9_fqm?cUy0Up`H31J>ux``rYDKwO(bLzFB%FuxKny5(xNscqEMT z+`AY;7ZD%HcTj|+RP5-@_ofo9xwf1cAE_m}sN2_D4KK!gO^;2VM*V(scoM@5la93@ zQz-~S{bj+A5WFiMiF8jU2`x@a4%>Nw)5zRCC9vOe`mPntvWl)74X)kI%@Hg zNF4U{bkvr6&SM+3LhzgXCbOfqpX6L|>8Dr7=ldXI1a>Pc@36t$Cd6*O945FMZvMze zM?MV;5|Bq1b3MAd1)u{ToVz~X7@=ENIG^!fUhW|P+_D+ie-Qkx&P+L5ZjVLMEomFT z&5@N}Zwk~;S-@@^9TQ=uG}oDn1KMjmhaea2P;O=A4L0-}?l}UKNZ~1C?P%PdMHXVC zP+}0d>cQX$B`^=!9thy?f#B_~4|)jiRxEN6EtZSrQnyo3ZTaf;22Yk&9T}KDu6>-j z*yd}b^%|}kKVyPmLBG|T3rkk}w(AuG92Fj(;ogTz$e*qcjhn;7F=$*1LF0n9>~?jUEOVZlJ%wa@@y8`X>h_E2Yl9 zLf>`fxYwIG^%+-S+;-$XMv65^QkIs^J!Ws%cZ~9ozf4w;N*VDbK$o-=hY1?*I-g5Y_3ShIQ--x z$RUV#5VQ21)BlH~+X6v1 zTBBZ=5d_65jIX?M7dO_+rVU2PbIKwEjBu-+y!$LwqNsTB&pcH^7;Kit3iM_^nmIVKeH=r8Gcq{ngTNd6?%T!?-7sjJo;tb^AYQbL|g1M^fwP zcadvutA9s(3L)#Fa^KT!@c0t^z6T-19^5Qou=_4Ub_U5;VaNm|nhTHu)!IV^p265A z+PXJGGG8jGifur5ZXAJ7xzC&TlB%F95sM zvGvCegtuO(y$A5{Vg?YH*FT*AfJcsM1bQvt@djWg0fE7`uFr?z1p?xNK+C1U250yS z;`~AA2s|s7YoBv}&^HwkZli^nWvk}RjoVf)wp8J(3;ka z+`MSi9sU8-CU0Rd`ARfPauCttwm^!9ry5_j1ahoes;rw@& z7K;w6D=nL$%QoDov^=MMhbgvxaF>3}YJUZk9yJ)E)CAH8)1YDu`TQY^R8}%_N$JR|Z(n z5NH9>3k*W-B>bn~Sjg)o+{D!@qM>UWxOIzN@Fw&X`#Q438Ch-br9f$BmOJyvv(4En z%GJI=UxNIOsbq$^_xe2P^!xH}e%mh$_QrQ)_ull@x93KZiI!hUW$#${y;BQ=Lm!BC zPH)}#ji;XUkE}1g?a~L|?e$Lniu#P_+~dD^;GRoI&Ssa^X}A@_w}LU#5e}GKNf}b! zs<*ebXnt*=Kko_nvj{!} z_I`nYgCP#SM+K=BK)~T&fTE{73xR;E&;^zu;odzuh-qr0(B5Vle{8 z6?0$g2*b%C$wXNfrjlcpb>hYbLfg8x0K04qWg438q^8H>zs7R)991*HlQ&a=1V zXCZxX&5%~CR8kgxnc|KUqZdzo^z9qw*ROx4_SnkBbtmroqB^!|)w)e^qEPUUK1M`0 z6b^>;cvU2TgO?4U4IY+<_8>?fx!M`EO>4CT3;}~uq@e5Qll0ALN$Iiv{IB7gGbcA0 zAAQm6;yV4r^tsP$UbSu>Umd!3klk|0z<-0@l5E48c(B@<#20nqn_dkOWSA=m3u%nV zgx_GWtvOj&(QM=OirWp|pqwPs;|LkD4c`L8HI)Nv4mb1@KxKo#J|l82`WPz-SLD!s zoF&$FuldbgTeOECrjzCskEx~po=R-$qFCAwAHJ#eoo}ZA$1lArGFl1m^97cSIEOjq z#7jM?kAv!!NV_-@9_74?RR)Shz((`XLIg^#^tvyq^8C)NM>1|QH zjF(HY0dN*^@{Wid1Nv11-B~PjWW5daZ|N<%cc2x#jLcKF|?BJS~}!q#Kr;jz;}f z6%kYZo_pVUfCGtY?!8B(nYJ0Nc~RM@>yA~I2@uBWBUsB;zY`#q&SEFaMKPoxyo?kO zZpd8{MpQUh^1BD-JYQ`QXXsGhyC zaZ!BPb^32cw7GUvjLpN#N5!x?BWy81eCo^qt?Rv(b^U3JALlr6 zQ$%?Rle_eCo)APVz^1?qs1gh%jUzvUO@RMZRCwrcLU02Qr??S>MlF|YvK`t7#V|Or znM4PGMaaE_BK z;qDW$WY0{gzYxnG*na3lEPSF+T~X-ih$UyYj~+rNct<^VV8aX&?-`}9=L4F@hc^=k z8zE;H&#K8DMZG6)tl&NC>ddC$RvPpnx-Jkj4C`m0EgN>Pc=P0HcWaQ|$j|Gc`MF~s9bCQ^q1S?vpn9m_jJQ5titH4Fh zxUk^&V3)OK7UU;fwQgxRC=o$kZdfEnF=Xhz7R6!yK+p7GK)rDcC`JRqJ_kI~N|^@n zVw7nf&7@2ND+v$i(?AJ#fN`3XX|Pl{W!kcjz?=f>rqS9$ndbHd*ds7GXJ`7W$Egik#h8pTqohJsC(^6mf$VUuCMVPY} zP*LG9@-(HW{`J`UhKs>68Jk%O;&-59VNiLg-sV^eeu$SG=L1^_2dvG?CFZhf*nZD* z;V4qpaN*v2e7R-&WdzYwWfc%uo7sq8&u_!AVp}V6dZhmH;7)A&6EJh~W?aNhBsxdp z@RDi_dvei>A{%tI&FhrQ>}(-3#&Af93oveDl-iw1C*ml~}$JlT_$&%T(o5RBCOA}L29@4`Ux@$+)w7k~$ zQy9+geK8V6@&6xzAo~2i)0XK2^#z+hDqkTZeap3Fl-u}Tp{J*ZK@|gzmTWS~y68K{zE@ew|o+o^QfC zLTa6T_7S>*F(LRm%ZSKcyXA#=J(Lf7Jrdv8%YOx$g!+E}3-a<3fBB!lerIog6*JgL zPu+>Njw?NS39v}WKRI9|L=-%I(QpvK)VPX$z+GlQqVwur^bdY@|CKkLKe~V4(YxOm zzWjjt=B0b~?cKXieZ$hZ2PjU(-tQr=aEkER1|F0omnAXTjA}|^0$wBKOBqHrQG=8B z((%=9SkS%3mw80C?&k^l$@tYBxKHx4qZ5FOoD`5x0(?RNr4#L~#kQge_Y{C#Ykq2N zpEeml&uL4~Oo(UQ{*nEjb>HEX>Vhr;b!Ro#Q^|N+OC*pDX7Qvj*Da(Lj=37*o82;9 z!Vq7+L$x^0W;*0B{hW*UQEuk#G$%=rP}KCtBjkspesYv%fIVwqe$4fga32o#NvGOl zQBW9#au%o$1^y*DR;@t&4nRJSnH}N;S)(-_@`?_<0rDAu-?pIa)pr}t1mt(O_#z%$ z7d-H4Q-K%81Q3O424ypP#GptW@CC+WErn=-YZ^-*C9feRz_M)KS|RISdtl?oZ#jST zbw+1B?Uyc{Jc$2X-BP!|uY#O};UVq@%#$A@ob6}`b_Jq0C}&1Ax7d_Xe~ZT(^my+i z5=``;Kzywj3Ip*CQfuW(GmTMU1$6-BxeL~B z!8!Gh*aT0Q6MSgv*0-=6^4S`>BE^0{92_a-7`NaHmh;2Sdhd`)Db98jt;w{vBUOY| zuQHDu2~V;sG@5kFvGeicH3tvb;4j44Bak2V&#RNbddwNqw9MH;kN)Sz&gC@f3h!$|;K+ zIstehf_Yy5SsX`$yDf_-ZX9{N2&|UxifmTf0`VGE0nIZAPQ)VT)SSyArvwzuW?#$> z9z1fJ-N@LYjqB(3YfDSg*6t5%AfV>=)gqo914 zZ?{1?&(7sQ=@J5wNh)K4=kwX1YA$>M15odT&vz^&dy+1geYS;EM03l6ik9$r6*@ZL z!!2}xCCRz65;$g&OUpFaw=tiFPuTaeWe$uj{oiGYYjuNVZ0Z;+|zHXf#dGs%24M?n|u>+f>n*L-v=^M;0mIS{>6wof%c$rMFli z6kN*9N%8;)A0i}0Gn3>~LOyenoR7Lq5(8p5C&>h3G(>gPnWuNx!ZGf0OqLVKW!2o) znUc0M$8o&?wlgkb61-*UJyi7D1#Ig9+0!>lz?QXdwhJNQwQlf2C_!5GK72wQe69I; z2F$AYI2~y{%xLLR9@E1mc(cp!M@i2{4xSABt8$7nioyA&F2W%+rb@Izr{f2@mT}h= zgDTX29$GR%Cc-PVR#y;exjc|jfNKwj0*T?K|sQ;@kFLj@(`JTM=d{h?`J-9@AtvZ@Exs3ON8{}YPMtt6n`SSpcs#%RmUHJHJo@T; z?t1gNM_-Lx=Ysm?J@`NW3>tyoTUv|BWM5zgR9so3&&48OfM7MVBnJi~elfH8DJt%b zds%)rh+H^=MCR_Qwr{JC5oqMAwjM}Qfja#luSk+=TaXhXfsNw@o(7>=VFYA)yW@X}{#ER2Q6g~-+-K_75x#Fha$BMb>`N`p3ziFk{*1-L<~ zfan2gPUMf6-+*M_%)i5|_`NP)BUx@!bbT$!ax17wh$e&RobXQ7b6F53pbHD6!X!~f zTL5w#4&hllrBgzF`+Z!3lj7?_b}ior=9Femu+7J)2W>4OR$~(127{%C!5jo@0S_Sv ziH!vzt=M!XJsZP@Ne6=L!ZVQ5&xr2}T6xR&@@`h&{pY+JjsU64e5MN_-8TM~o zTM$yxLa;n3Cz2osQ%{)cQX_q>)6s^mhbAUDLQRFYp|R}&#Sr;eN7csKJayZ{13 zmk0SVwGSKY{4xSW*MfqwQ{QG@3p`3^E{&zM)|JH-7CE|S_SGEju3g1?HM88{f*>OH^u=JPfl{EAU530vjsoy!eaVbHxVI(AJre&fE;}-PPAU_5p_k5r2dcK!h>NYc|U$B4~QrTB(XO=^qXW60TaaBWhGYD*w{b8G)m$=?*8( zc_Ai)j!R=Qt{z*mex0cW>$l;k`8J$LMFd}S6gCH9!0|^f{wUD& z-8dxuKGn~p3gI@-1S1W#f(D3`+0g`oHz3*>sDWIeli>!cg=#h!Y#B^U! zH89;{GVFi!x6e?uA;6)z^H!o()VSpTczRXO06Z`l=quJ8JzyW@#*^bs=zHfb5K@p>UrHwmF(I`yVN_P)ymFqQO}LfC-$tN;u>T!wh0cNhq|k z2v_37?kETyyo65~{H6Y2HpTvBNkJUbfJ9O{RAjN0L zM+c{drs#Ic$`%Z?4VZD-R(@32q$%jkJuY{s>zb`ya#jqga@gV4M?JR4;HeA+H7(wu0bq3r=~KHx&$q7UBMz z*d0?N5&vSi4JkCdLhKka%+@sjJh&zX#7o+E$THvPajcQ}Fm)unQ2RL6YiO8s7? zXeeIQXpgn=J=RKlY&R<^7DG|75fl~UADpdugAzfDNXzZc-D-W4v0GO+{W#ly{2eHr zz&**pAMsu57=-MCWB1ygJ_=D7nV#kwczWgZ%GsGl+c8Ze&`1a-j%(rO zHuHC2_Sf#`-k-(&ydHZMb^y{T>+1z`W4{nv8dseg+|oZpdA~8smcFOaYHHWLrD1Y; z*1?g}t87qqsUP60*@gO+8d3<=B&aE7)No`P$LD4>+PYp1MgurP8#HJ^1EIAkLk?Cp zhyj6G_eT6)D#gXiS#uJH8up99hI(pV-~5Mx(*SmbexSju|n#EjRKe z1(gWJ>*Bz1@VC)vu*rCvf-c~Cfs7f~4w}7kd6&KwUJ58}BIS_dtoy!3Zz8oYx^cd* znn9U`ZCmDdZQQkP?W&biD@KQ_8~QesOGVsT)4iEqEK{ntC!WB)m6s)>bP#EOg0m!b zgCh3z@lr=eEZ@>Llo^}q8SHLa+8$f2r4z|$z&Gg&3|2~&VCli@I!{RWI&e+yQFLXe z3btc9Pdd%}@-#$m3Zl0OqPGsB=ZEOk{ka7{3&GQk1;OQkrx7o=z%^oL7nG*oQ&&(3 zB5Dds-S;&X6n$cTL$#MupSNz_uycOr+BI{tlM^FDy?Rv#F-0d(5FR4 zz%yn@7ABW{3>Sm}@hsxf>`{H3yBYATA8rQmJ@ZSok3bicdMd5q>!m(uVHWi_T($>gVeREtj&4z62Vv#z5Gz-^ zRfICD{Xy&@cAIKhZZouUWfmo6#>^~~XpTfiB69I&UEUBzlW>H}Bv^N&^-V01kksPp zrXOd!lD`99Sgsy%ug9*WS6FwY{Jf!%Yab<>X<&4eYs%4y(TVXfcYPU;$4Fm_`a{hv zJKdk|=UY{4wCID!wGhk0Yq;k8-c%*$=KsH8uWQ5FDPNoMf1EdKp}%0;^*Jr&DZxn1 zE3?YHydjk>5hzwUeHdoy5NwduYD;ul4YUMdhb+@5f<{olRBwGl8l|;+r}x|tbH?wR{I*-@56H|IRWmvH7n=m=BFm%Jn8Q#cg_}O9W=P@^&)#h z{R5)Yj5Mh)cV)$Gs1vk1+zZG*+t9EQeiiKphMXPb>)CByhA2DVDP?pQZ`dT#!fxYK z(|J9ugEy@BUTofu;7F$MBZ9xCu$jx?Dq%W*jn`+9Fwb?pM`sd&fRf_v;YpvjJK|I+t3t*@uiVWGV~k%%R5`)N;9wnpQxymV>*{$|WP zoErDT%nK+LKs}eZRJ5b!aexf&K4_hi3~Si`XbEeK%4kN5Cj$4SvH-Ii7y#2%Ni@}n zYDA5w#sK7H#d1$E_b7ifD&3>wcs2V9gN}R^j8h{Cfz<^vWdXtpz;;52F-^i$xs`D zq2q`w&N1YQHwbo;btLciKhyC}j)ZS!%6<%_%jJe* z*?he7dpI!0!4UFyTqwINW{J`ZC4u{&pFC)zP!h~>TfadN4TOtC)_~v$d+?@$&Wp!G z&8_T+go2mVaDu}7;r50`VR*wwBiZ2!Nsqr+yX9%U!YIr*x z&dw&CA78gYc?{FD7E1z8&5K4*ZJ|&=X;%Tc zVEM@;hclos!1fftgxP6UX@`gcJo?F*wRh@6dJ&+6?p>Mf6Y0sG9XmFJhweCZ;G&+) zl$NSvh0gsezLCl-96h+!*r2EJPd~GO1mKjK6qzT~!7n$IlOiZj|GJS@W0KLM{s$yu zRGCvnkSs5cj1}s@L-4km^-;LTbp2>%*xK45&bJ_;8^3 zPgxHIV1LDVfZ4;dU9-T ze6HF9x)|IuQAt-vC%e1jIXKH9@!gy}QQJmg|f=>yYKJa+*xq;=El z#>a*S0o3WrPUa@9ISit(g=?{dCO3*&aU?d(G~70!+FXZEHrz!%4unDx5f6aF>;kTQ zRjj};F55%cN2>uKnU(+&`yVX;lGQBG%K(Rn*m*s^v#2rPK$2iC1(YMsF(fg8A<;oF z3HV8~Pvdq@mtcaS#5jWFs6I|t6<+77_Oen_`Kw)^K(GwYXL-H>U{$|kg+>~!kSF$< z*DU>e?J9k`;VMbOQ6nai8$hT_Md|xBaSzzPzD;KC71RG0@I67d83xw zi_;RBZR#mETcnq45pb8)*dpdNQedkrkyj9y&8Ga#B6QRXwIJQ!XtX9;&dyo>cgwGZ zXA91q5oKD@m0!|RsTedrVJPPYD;~eE2R8-c(xT7qDNumL?L#`23}d&evo%LgY5H?O?={L=e^2M_n`>g+x+ zw&83syXN2*emtY^n)~t8rd%!da($tE@6jWhr=u-5wYClnW~ym5QJR>YU;2Kr^AJO0 zvO~bLg`qJglsRRS@(B>`CSl(Z7IW=rpC=gTM;@7+)JX;2Eu8DXHhaPW&&{ZG3pEuE zoQdF)>|6BX>IRfr3-Ar7@6jd^m{XRglxV;M&tF(s?lHtaR<9ylnZ9n-rq!EPV&aAe zdn+Zd!wN{|gl_`6(}P;rJYj8t6ZUvt05v{SOt8(mGJ#ZD z-8i+Y+kgE0x|>hE^u9-TtexMwV{|OsSs2sa^VNZuTz>Sy{Ra;n**5SjSr7yY{$1?s z4AF{WtE$N`_)4I!h^rI5qC8D@Q-Bc&2M;*XsIn!aUj&1gBrakpiw2V@*IjD=!^7m$W!7MoIai`B7xo< zcs0=l1JW~hiFX@xp?P1P38jSe8^@fZTmKAMYp>}Um!rDfqsUs9@~EC5 zbU=(4P2-y1*9n{tZBSb9Uhse-6t8(Nt1|HKU4+Go{z8pQh9S7`@w1sCAUII%jj-sv z#3Vt)16{!`9-_B$jmab2F}7!UOx|U9A`o9QKoWo$6M!M*4|UMGdp+eWX1~&k@kvHK zpP|`7GS3-7{T4=0>&h^&1Bb{`2m;Ixe$CGrL6?0*2P3GZwS^JX?F+Ow;B2b)_7KBr zD#1i@NF5U8y1>I{GJ*<&$39zv0#pr~Bxm_kr)?6uYKM*Y^GM6X=dALZ_4JxNpAce0 zN+`NoM@q1zASS98L2VufwMdC9F$yVRARNG**EY5V_?P>S~>+R`p z94poirgY@U)i1aYMGHXpwV8-6iuAWwyeo$87kZKFe#67!(EXa=%+E-1tTL!i5VVLm)%ycJpJ^Zql-c>zP z2}Le1^yovIi`{!i=MEQ+t?7OFgIl+D?daXRVO#jZ!l|>lj)C4a>6I%7@~x4bk!Y#V zKi9GGj@LbU|E?`Nwxv3^ty{YVQcENJ4R+uspveEqIPwE(CJhAz+_j%M)xat#{{l); zdF1y{1z-)yYV1%C1jNcwIW=X`6SSTD{qAkq0`muuQP2Hx12nOk?`9wo#2e`gLKtjq zq3nvOWDKSNNOMv3I)=JFv&0oBpnP*vh>aoy`Q=Q9y%!%ix*#h^6(nwom0c$ zSzLO{_6aT+_dR;~fdMWRD!A^KZeQ`O!JCd8f?2{QZY3rzqm-4uHKr}3<~y`t2xdZ1 zOq=nS0RAF;k$pf!gY_GEe-v?MVP-K-ib*JDEfT(OhUzwFP!0YJ0K|OqtgS;KxtF|N zS+azELA0jnBLY+~iYBZT5SGUn8(mIB{d*suo-MC^?oeE6IDxJw>x=L~FGewoc zKlPMY@pv6I_U|I*bIhD><^NpAbpN-RzUMo80m_}Na|CQu2kT7VH^EuTkNIbD8%Qdh zM4B1L)&+MQaYMkgWhHOSn z)gvm%B5r2vra`g+es5^lYszgZ zBjNH*dbo%xP2F(96EDsPcHoFY2@yU@4Gb#&K+rE!14X?%Y%OyCp04Sgd9%+ zT_JKXt?jW7DXDLuucuPT=d!6pYq_m#CiIcg!_D225Y})K)P#}7*H>>Y%$%QwGeUN9 z9?*l=>#cO30>r?V!%0C@T%?yquw?uy1%QIjk9svQE6G@<(u>BGikC_UWKbW(&$v?I zDayJQkGMBRnP0kE5&KRWWG1*?<%4;Cy`oAtCIVV`NG<*%DGcA^cdS^QIS?1EX2(xA zh1&6(mjpSQhT~_Kg^;)`$R}JUtM2&YsNQNtG}a&gd4791TX*~nY3w?F=JbMhVF_C= zjF?}a@)5n2FvOieA|Rm_mIH?lPqz?s82RFW4kK%b?d}j9SD}D_4u``6RgY!%p%gj? z4(l`WIX|vf%|Ccl@8NjgYEP-VBTF2&kzz~HhV_;Fu_oB7{uHSfEC}BdbtdWoiS2#1 z?pz&!&XaqkPx+eO>Xa5T5mrW;l9m=i$1FdGGy>=VM5t(mJ3<+y#=e<}D+Yb)VMfpw^(;)XR;;+V#tr>E=Y*j)tu-()XDd z*jwGFTYFpz^q1Y{8w#E7V67gGdL2iqZ(8rL07n?$bp&!Zss_S-C<5{yt#2g=D>!{j zcws~dg(D#jVI{~n%k{$?wzPy?!t#8^y+NbnXryZu}7d_w}mL)+{tA zoB_DsEAS$-n;Q(mLIFjox(nTu6llG~5s#y+<7$hXN=ulI;CdIieaBs|a_rQVYf8w> zpT>2LgC^_C;=cka*;;3P7)Y}2`aA~>NZ`l;_(|{3aRVXWsG=fdt5mTo!37iWnLJ!5 zgedb7*xk;t$;7RW7l9ud>?spQIW>?T5Ew;NSr8f7rn3}o(m-R;`mR?EctwsO0?UVf(KSE{%`t?Q-`ORIkzn zcY_SY!^ehQNHwHTW!dXHg)6tq#u7-mextEVE5?Pj2rtD5vL4BI*Tj2aNFeMrIy}%{ z?#}0g%cW246Jf8KNwE>$RyfX0ZeZu;?d&P`2nO6Vm~lJbUF(x+3FKCG2@c$$(K7nN zfDI523M4joQ1Mve7c`kTz2;UPX^u~T(8M}j6iLATBJh+7eqxRr# zsGHT>Yq2sn)sYq_N~W&hVCYcXRKIqkt^E7B>U*?BcyY%;+=zw< zZ>Z`J@W8CvRbJ1-REB%T10KPeUytf-2=k?tbUe{h0S+YCDe7)nN|M-yS^X{bS^4eSO;L4i8vm94qcciMOade?ZOiHFHCH*P+x;tuVMR+nvVo(^W7tt zA1(I|^qoJSS<(7Y&)!7)vD;$#Sl4*A^UBuU51idFIk{o&%IT+~(Sy;}SZ6AJYhgt+ zxn<(k{Q=L`*m&ozn-LN0NVi4TuG+Fzef9A4^!VuPZuhY!yvq`7LcaYI^Q;L`e|Jm zo1ULos1d`Ld((AFh=tf`%Z&;P`LtFmTh(9Sc$%>eEfj&i_rYFbr!4LGRZ%#B8KDN! z#@Y4yEcQ6Oz{I2B2mXgRHE`AUEkITvFoZXtjMfpBM9u`J-Wza3H(t=hyO_Y#zDohp z7s!B9yjSd<5MA^s7-S(x2#o9<<1`?ZyVm73$er|tQIdVR*MYW3Uxkms(~Y-)_`{eN z_)=}JaOq2sL=J_c)~+G%+r|xRwyxbeJ2f%{^h~8xOefl+fz`p)m~XI=veb6i(7Za_ z^Aofma%cJG>lHHYMc?`Y2pG9c>j)V6O&L*$Ig{5ZV0GSbLjvaVE+G;eDMUisgn+rc z#wi{wmQedTCSc5|8^udNonTIj^9^nfH!EOH?u6$jV0ZCo#G30DFbY%Luz-CQ2j5C% zlRBrj4QI4q7%V3!dyAXn_ z_1_U-$69sO9C=Rkb#t3mZJL@G?5~uGLn;w#342%iR$|^fah7%aLa3L&9^GD8alK0) ze&(60H+*#6D;ECIItxEztT)zQdZU_NdX)1O(qeh~Iv2kKi@(}flGOSA_cDgc`VB5mFT-j4^CeLOGtj0@$U&GQAt#INgGNgVD1L0SkBxk@%876hi7g%eD~O@o*h~3JlC}l0& z4AwTr&uHymkZpJg>%5Yuj?#Z*32OnQw?AfsRay*Li0{R9|2-(C6qIKa=M{Xp`hTG5{@6L7Xl46gxU>4 zL3K-4k7x*2gS`pF7f?l|HPK3SsM<`=4f*7$aN8s=>OPtD^WMG3*n`KZk0H75uKqyw z6o_(l8^ivzC})ha(j<-F~`#TC!VQ`i6&YfMxwAlUF3R)4x}E%hd)5?)PIb&G!z*cuze%ioBN)<+e`YVGaRNtg`;5%3IzM+ z9+!jS(n=MZ9%1|Z)N#vB=lTYwqVoM4->?*_WwZ7M#AHv>tl2g!P9e3zBCZ|Ec72OA z-s-W$Sy7sa!ElU)6ML7HXX;3GkHUIG)}IDYVdB z@#knLj+P*|%H=pPBiEHf&>-KDLQVs~8QDTziO}FsKA(94^TD za-JXVN}4EB*vI|q;0Nh*(-f1HukXLsqkMzSX+imGJ)A(`H3^X?i;|Mdu>%-EiGlU7 zo@uZr_@TywMtuz$vE4=fG~4EC;?=adB;cHP#T>T$KDk;u*x(D^LN*<5gPxV=r+sf# zTv$l>nc8Y68?kIW2epWzUHb`^WR>=F5b+LsX)r_}sTddwxQak4OU<*NUj&j$iEUy= zSHp>LpstxSd$w+hSd>{qR~^sK2Cgmma2SYB-?S^@a9H7j=}JQ$`LU z7FOJS zCZ>$(!(;(qbE*{4#eIgtfl}7BJt}ApS>i)XjZr>yrtJ9KQOgGVx@}Wo?eHCSH#LJuCrLvFGD!>hq%xa1)vm&SL zQkugbkx++_1b%u9{k_G zz4WhNUh_U?*LR7pw<&kle7{41YZgEaVXRX>Q0-J)j!TvwwFUln-dEWMMsN#y)Y8_1 zWJu93R)WmZFN&2Ied|DKouz;O^vp9@89K>+3~}PsJJ|%mY=Z5im?L)%JLMSmJ9PC+ zpZnaOEPeO$^KYP60&t;W&RcOb;@r{H76gan%{z|yW~xy+2|5FwF^9jgkfmn)4K4sx z2?yb@ZB<*nOm>Yj!-Rl*dexfq$z(R2sD$U{wTY`=>`eM^LW&PvrSNgjYp~DSl~X#n zmAxJ?O`!%K7g@`tf?W)~UhO2dJFg1{-~o1#$9vhld02kOe3JvYwFM_!yRY56bkJ-`UOV=QELD>EpW`@8HAUR-s|JX{9 zV>rDnKfpw7L-~ic_BPo1a_T7GiSFE%zEP<%bNX_S3ELD>nx&9jzJ~1_qG4afoUE^D zuBOqy9A8Z&SfX4pu{auiZ?=m8TNm$ecJXgS51+$LgruxpxxID*J6I2q8V0n36gZ=c z9Voy`Rz6K5wYY&k(pA%(#_nVGNz4Po&#v`iBeW}z+qVK;>pLAiBPW@6ffk;F3+PsL zgA?8KOxs3r_PA{nzf6gX*S-uo(1-{9z&xjwU$t*38b+^a)EiqWNL$KzQK0d-+yLwk zk&mL++=tHV0_{ika^|V%OeAPaCejHYk!%~2tQimIz_SWJT^Wk%;9+BLo;|MpC9J$2 z>+Ym+YuA2!?L}&@kS_*O`cK^f=(;Igf1wWA7Uoq*iBzxsJv3bo9ZW0x^!5Zh>cNZ= z0#VA1soVA#F5PK0E!FKb(&6bSNdsxVc~kYid+^|(x@PJ7;|Iq-D^xH$ zQalCVrh1iQItmYKUZp2XIhs(x2vDH)OaZaFEZQ0Vk`s7lg`nQ^p|X-L7FLz zhOic)g$Q_tM?x}s+C{umpOg;=Wj^?ek?BLxa>w33l(Z^gxI{dq`OU&4U;TB*w{*3Y>GUUNRDlWVvcCgc- zOps)A23|C65dcC^jt2eWVy1VST1;?}dN_-cY=Wt0H>ZpjM2=+63%_GXve!v*v5sMh+(Gw3J{2@0|y05p@nbWBYpF<3S}s73{mr3Ch3472o9n}0! zmQHsOu&7c?yFU@aWBBQf)_MhR0`>^axmVGx6*i8mC%+vb8#BAsW%%FW?ncKVaum=H zT<73gm)I8>HoEjRilKKf2bKPSLwzI zz23e;q|kBUH*}YKpoQf4d9LI2EhJaj7WKE2l(by|3axxC&BoTHoYUJ=*byV#&7JenkLit*%^IQXS_e+uI@wjK^JFKFdPda zIt%(YDUV6NdR|=And&|-mT9;2I6OEzOd4E{=;FqGY?1CY7U{os8xd=TT>%!ivjWD)7|^8$swn z*3jxFLqh@a7E2==gK?NS%=%OPy%kE?$)ruE8F8C{Q=!&r=C+yC`)Yk?OHbLimm>q= zt2Hx%MWnpi4j6(FD3uY=Z<;4R4qt<|FN%Awm8sW{uA1pxCV!xxk_^h1FW!IY^8FVd z-MN1K&K>L5hc92ec$xltZ2SKG+qUiBza1)!eV17RM5DVFMR*NvFM`>lA5wLU@Ma0~8At=7 zS@MEUS}<%>v5T3LL^L-M3$}*9?&muQzFjJI^yYg(t&wZXnSnnc2G+n|ow2EAMcw(D zA)0g=^#@930P(~>V2ct~oos~Wflm1oCLN(T)zO?zb@x`t5lkjHBVnQ|*#**!_Fy}? z5ADZ#oe}4+oRo#)`E(TKO1Iy2>L8AKQ9mb8uNf>L^kI;Dxx!=_Oc0ZaHHvJk9gW)qzzt)vK=r@{$_3!1O~~mnYI!h# zpQyNrFn$Fr#qt6=g@>5W+#pU7kGOq~-3cqjBHstaanX6Rmcnaj;~ z$E4*AzKWVTyN0{q&y4W6QcBo`=jC9N*o^Wd-Jg5mfe!s;ElNh;UeNMi5PY061(}^^C-6PCjqpA87Ig`mD>dAf%CD zYHVruPvZgwmjfq?7#r!I9GC=6L|>uLK@-u|G);tk-qkCv3=vx$lBk_>FE{qB8o@NA zlJi2)ihM$knFbk^7o#v1g>GkPP8@s?AYy0M8O>{*DFGavKj^g)K{W9s7$EpuA_K%_ zE={XV6fLN=+LqIS7hK36e!$Ci)y%0Y>qGqs<&+NzrKsG`Q7pY7@Su>_0m_lw4u%C2 zVHONP2i`FXlveONYszA!?yZjMYI# z<$pLuN02$x4vg$YsC{9l=9RlR0;X)5t1v=fuHw$5z^6^IU+{19)aat(#XnOB&#-MR zjg>t($Fo^7aI^Vro;coGGSLj;R1q~|&x^JXfbk4EP2K$QT4R^=?+a}2+DHoQ-J()X z6V7>M#02iM3hSR?WV)4MAcOZ?^r2!!$si=mP%?%{*eu#|iHHcms6SjD1`R=Xx?3P- zBFobd*t`ED8-`}sIS?1>f!bm5OU;|KEIQGxY|=Nx;9^vPW>7s>kX%cwsh8oNQ@MxJ zmQ+o(7We?7EsP!&e1tNonkUZA%)l`BgLKy3Z+_|7aI84je{l2Wxc%-^>Q<-p`G8$SF>p@+nL)75Bxb}QCZX8}S3gB?v^^a;3b1r{ z<$5}L;<47Kg`qGa9iP$)#f{&!|5tHMT0yg2I|TE#RDT=?G&$Wp%Iluy^Ub&DSe3U-~cXI9EyZMH89l5!& zfBK84TDN??fv7%g{?#|WiqBycIYY@b0Qq;Bx*(wRqP7hf7y{5_0ncSXokBi;=ra5V z@aw85$|l)L3?2rut_||Zp+V9Q6JvwZL(}CFLD4N#CDCPdM7{AoXHymfbp~EXXSgn@ zP}FbIUIpDTfO;ht^z+gn38*v)P>KLWsR6~D1cR1BxQTGS)}IEoW*SUWB3jP@WE`UP zWZ=pe3d{0{Fz4*^n7vobN$;3`WE-083#7I*%Fl1Op zcTW`akE0pK$htbK0O4!ouM$CTH@^TXy}i*n2F5#6d(P1a{s_l+4B_Q&NGm|DEg?j5 zt5kylj%8K?1G0JrT$q~Yaacv{>O!Uc5S%)RSQHMAa!FB21LeNX4!k8@ZLbnMDIpU&-pZO0Q(N}jicc*NIbm0$JwFCB87AkVk}gG6yDjB7$!{FYA;5s^o~@Ay1Tp` zUZjS!wYG%u3JTo2h3mFp@tIJd+b$!Y8Hjw(_S`Bi}DTpPz>iB}YOg`ti4~BXX{`D#4 z*_}^OPH7vE=PK0)DP$2aWUTM}FG2Yiu)dT3riYFgtoE5u1UX(3W6%;r2v}M4g*A?^ z;x8SQZAt6Mrck}|ADvGjtr;&5;^p-oz?mh#LYD~n&oDDUJ~}u6EeSrMfvLeMIPd#Q z{W!)F@ffQeQdEniy$zo@6t#oBx>5$widy!kP%n6@s#30kdJHaCQwLBJr$`swJw|P___<}UA z4>ji4wVFa?1*kYhfB?u4W;`-CucRCWr^jK#IA7c~bm~4fiY=SOFg*Wr=~^mD;SL(do+Afu5(62!)~els)gaH) zdpO7@(}oPe?xF_Xae$Mva5d+k3wtay}tJF z>6g9qj;Wp9f!k7D;Y=uAUixm|s`SLR?dvyvSKsvW{j{toAa(V3idnCA6JD??llaz( zn-3n@0*y}K)|)XgElRImjs$Q=2euXW_#Iflq1{xAc0k>Oe{NA);vT4O4k?;VwQ2zX zPWD)l!HciZCV={zlmd z52C7cT6kmLm;j9Uu=nERMAt*@}nzJ{66uwQ5uFk!R`QRMtaV^IL**=o=vSyzJ~q2`Lbf&m#U z-xDAPKo$9N#}uHot)Jv407oFPFY=@iS|K9l(UXcxpN{J7%XN!|=B1#VL0I0>DGJGZ zln6R?t48_tF&xS-)myCcB3w;YyO5)J7>LZ~YAUcGCX+4cb26Hes_a1OA_Og-M505x zMch~rlZ--(xDgC8q7Hu7Sj5xEHOl?|Vom?GYhSx|uQ91Fz&f&Mx}31zqe!}C#wd!; zE5E%LySASyzhxmQIe?wn-X%lL!ww2igg};o9AbwyctZ2(B-uC4(#aY+*p{Jo?W|gzHmC+8a@_n zO%_twWNT|_YH-8Wme8${miBySCK<0z4{q7aW8CU{nKBrrDsi1KAPYgwcMU<2) zBE_jYqIuyd;S^XDfapc{;Yfn33v0kZT*8KgVPlEmnkD6YEQagSNMwKXlnbC542%WG zc=*;{Qm&ba_?@LqI4$2L;H>&n^3ij`DMs)|T>fZeM^o2_DY5l|utP z<#ZCMArbhS*Q#sj>yxtVY+-za?9L-|X9Ct4()UYY8>nM(a2E z`z+P!-m85~3LphvYXq>$1dt-JaAo=~;|yUaLhDq+q3of#)`2z`xko-0YIkYmDuB*v zbs zcUL%lEE1WUS-JAS+KGuZJN7KiseiQf{JNFLW@h)aw;fn_+nt!O3^F+mXm=_p!m1&$ zm@+t!SPYsOZ3h{v8uGvdoYqp=sy!xiK9rQ2l9^u9R3vVe7;a_O#`kya+_L5FM*&@bv<|eiUCXC^zZ5@@fc0ZE83!Yci^B$d7S-vZA{kSxWL|DXu)7*sB!O0y&8&^NjV!>=TOD$$tIEuALh<@}52V zsO;PP&U37H=XS2){nOa&d|3M?)-k8-)_0&^m@zfr$*LN8?kIpo8o|~<@ZZV^7R9gV z1`5bNy64ULD3yuV*CI3#`K)T^Or6FJ;|bfuX=9*g`)tkZWl*K|PR!n*@_9XrI(nXR zs}>AM3&Fb!RUExLIL=^L!HTLORGm;vd!);#HZOxGJ|1I9TY2wtza=2T;@vH2Y+SS2c44>ro@#7ZRWtO zExYC{0vOe*;_S~L-hPG`%N1T0Y zycTQj*dG4~a6BOKY=3?c{}jM3A)jhY^Q+h@D75TF)CK~{3RfT#`V4tN{0qba3*QL1 z3l~Mn3Jz-EdxdJI1`DBr1=irKS5YgHS%4Z=*(LH7hw-1QFLB5u?U-BsbEgughkYs^ zJnoptci?28j@_@`#uVLu(i-EoY46yNkVB_+yH|B&O1 z56o>)p;mabb0GhWd+A==VE8q({)h+|T~aD5jIn%C0%Rqqf{&-FT7R>Kf6_{H_+Etxan5`V)v%w^mjJLrd*AeXC z7kq2lmCHU5^vt)|r$=1go1GfEKH8?n{DW<3?5N&GQ{&pwWop>9lL+X5@V?HesR4L9 zrRPC@&&)WdhC#b)r^X``#>?30l~;aSY%t=k$;l1I|HZPwN?}h3x=dpFtosYjAhExC z=qB#k(AYIbflvbfv8Dw6bM7tC3ewWek4exXq0}J`$$ThpIV7FfKj+lE0Y~IB_Ic@M zN96yhofof|mv69ni6Er-K`}E~R`U`1krY4w7o^z8odhB;DxrZ)614MrNs3}} z;Ytjoa~{64_Q1xE-*W!w@7U+!?n{?W9>jmHezX2`d>Pv_3{Hiz@-2&kfvFShRs%3~ zgo5FJh~N)6V+cb#OxnPsT%Zyo+7%omt^TOT8}xYZ3?uM~sR4}nWTZ`#IA#hE4si|A z+#~wfGOZ&KOelRtRM>+w4S@45LU9c$V37khrL7z*M_MB=zXIWa!7*pym2WhwKe}*e zefz|oy?@W1`%kc`{Z{>{{h5?BK*Ji%$<>Vq*Zd|XkFh`G4 z!iihOiAzCvmmh-oK^W{kFsz_X3Hu0hi?Gqw16UfkdVjrU#_=nxTLcFQBZ*snnFs7>@7f@dx7nw~1qoR@N!&O10idVz%4@~>(guGQgdN5R4iGwD z%s=??2Qekx2&{Xr0J#i|wRYJe&){?xm#-eTChRUlH!&&HOpN`ha;u`vAM46_t;OQ6}IS!o>{5qzNE{QJQ}U z;tyh!!#I6Gs0Mg8z}cRF7tsxoJZHJN;f5n_ZsW@}XM#XCImDdlvIlNWy2+7&kx6qt zV{g$%epd^0&2Em{CgKOU)!kvNyD=HHSG$@SIiT@XFDhSZdfiFU4Y@5Ut*1Xk>rU{{ z-~yQ#Jll@6Ucc=T1#H)paAy@6_9&^q;YLjj(ZP+7n75!03!U(db{0EfPdH^r`a3O~ z#x`|!-DphRlwCnu=griUKcm7_t5a<=vIO0F_YDcta}XxNl8-BY&4tNQt~sdmz%K_~ zi*n6gzaLwz&W~aID7M`c3e|eRvwK+q^4u5lTd?OMHO``c!|;pqgr=3vKE`Z)@-`Ud zYr9Avv$|S+LMM->$AwOAh*_^EUO%+y=G$GSbr}V1l|12P`T#?!kINlvR8)Zv+`#D3?Tkq#aL&HG$DcsNDR+nIU%OtXM z^sE5VKV?i99iWQ>O_| zga)2b!d%(UQZ@;%RtNvkK7IzluN^%ZOFdc?PU))hc|P`bx)t=mPy(frLS9iXHlO&T z*YZc`HM*2{03rcWZdN)FTZ{RsrMbkJhzpiu(QDo#D5u}NMl^a2=yGh|sJ%q6C@Yfs zQ^pOVsv;4<#sBR>Ic#%@$L-9+wGMIz0Wn!)a(jJ~k&p~2?+`-NgUK6^?l1l?7WY># ztS}&YJZE7y+5N$!#f>cn6772JoIC8OUdpjV(Eh$^Z@IhBnH_@VZAk|eHDg`*9Nff6 z%}we0)Qqmrbe2k;9i>vrE>xlUvGMu&@v-@taxPb<|6cuMt&GWV0;jAIJIumcBkwFE zXElEg`n~c5<$N~w%mT}qX*lJH1coNmmV)4P2O77Q0_7lBZ!M^^C1N54|iyU5Khd2%T_5At9@kr?*~t03W-Oe7{fU-|UCSFT+6 zl=`Km*Q*y;5ej4C75vB^qx}@bWkGu4(qZ zipTSThtKc7?ZBD+cRzII)ET_^{c3R){?E~+syeguM>x9K7ufY9uB_4LViAO+eHv0f zCI1~GH)h;-tS$qCC&#^Uib{eZ8P`n_+hO5x+qczCxBz_5+5rhwuGb9;RwR{s^eFSa zBLwdSeKGRxDb&zK9&&t5h2cdgE;f3`0|(Qa9H@E2j}Eq0$FGvmEg47>_8*y=)Tt`Skh0kAL%|!jLEGCO!=t9wT&cwW|oFK>1owIG*gWi+KSae z{PXv|?d=ynlbhT5RO>#!H#t;R|7z)7>WQTdYi^yQokv{~q1jV0QGk1f7kkI{Bb#*tZI5WZ9YO97f=6L;z7aaRfYRC~nZ1KgRH%Aiqq+X*fNwgZ4}7Ck1eX3BM6wFzr57Ts)hv6<)r{Nl&au(^S-M4DQ=92k@%b;srGHyCb?q+VIze-P= z8NKENNMgxeB%#8C?69wErvNu2*-i}IObIq7Ay4+VISHoqO{h9oHc7Y5{fb)S`};1F z;jqAGT{b>5!PQS=g_06f~49^&vFr1jxM^g};!Lz(5<*r||Nm1dYX z1U3{!rI79b?FC2tw*$&$vLGA%{sW#;^EIREBGa#? z^K{NcL1%#iQ*z#1F#WPHjfrh%br_4wOfgOqc0+NQWjamF-X8mr(NV_zYwad-@VMAS zsT!y$I1+-7F((Pn>;;%WuCoJz@6wi~Du=_eW*Nkj#EunXG5SXV07LYCcwF|5 z^Y$V!QT@F3YsjX)gTRI!xP9<{#PSm(v9<^S97iT6eVvXe+~15+mYrC2oH(AdZa;JC z{0;^Afr;K8vZ9DOpAgv`%AOHPaj6o}?Unek&6~##%~jSVqbt&@X4S~~C)F#5CMI?c zg@XJ1{{Epc+%vB%y&PZUeg1VN4qW#Cd^)MBLAn{WLkkxHgwYo13>-%eEcKY&ZMfM< zwskJn6^skHJJgm9&b6st!l!^<3!b(9j3>Pd@^v^q6N*A;JoE)sSldyIH5}|TMmal^ z&S1p&pZMf7WdNoy$XjnJtj*nW{>dk!rPv7!adOM8UWAdZ-bSNSzw~mSm+1xdA$3T5 zEsCgBlt0?}R38S{PPNKWwOT_Kiyx?C?t`=c5l0DmMyPo=&Lh81^`9jLvWR}qvvg{t zE|R-#18yZdpIU)-eJ~90F1X0+w5I?jH3yA>qa`56_|d1FGCskIW8^vzQY{v8l@37e zTFUv27e#bKN z%0PKaUdOQm!D;sfkT5-+m`tR4OP-;x-0^Qqv+D7WtTh8p=&hgAJ_$)4 z*GI}pdgm_aI5zDaNQP&e20l2T^2`BMQ7V1$bTu9b`J$yAA%K`ft^ z)*+Kx@%qjC_rC4ixwr1!yXAFD-#U6=<>@V(&&(Y>vVZtBi;HhQbm+~Ci?1Epe{lTg zk8C@?cJ29XM}B_%AiA~w8Zmu;3HIGeeHzhjCD96#T_aGv(n-}K@?>m#7tz%M^qqSPVcEF#xq%N-gpU=&B}V81T+{N=m)aYNX;EEhJ({ z$tb0`h{5rR-#IlPy89zZPfbEUKm4o0UF@NSg?~xZ=%*?9h886%#sn`F$flSweEK-Q zu`;w#Fwbe~O~%+5uT$U^ykTpt%HYC^G`HsB^47&`c}Z%G-H8$kcKc|k{#5M)yY9HW z);HC-uaYUVq4!4vivtA=#< zO?sl9{*KQ4+THpSD=mT6`Z!ut&5eax8(RY1xu)iJcChflm6P&dAX=g8Lk+9KAqc_B zyW1fFmQfZA)H^`i6$*?GwXgRYEK{M*w)Q}B(|}P7LX2YpT~y@2 z0vtYSERf!)!1j_`OG(QJ|6p+$U4jb*<*Z?i0b2_hWh8!!YiPH(HZ?hEyuBALUA}E? zI6p8{*WITa?M|?zgQrg)EIh|n+FM%MZ;k8>y&DuO4j?ArQgkmwQGSg&D&Vo>PP@Io zW+Zp2%Iok~H0zu;zqhWtUpd;9Vs9Fst@e7Wr=|-R*dyMc!)kQ|z3-C8oB_+Zu*yl{ zofFTyAZ)9!@suX0JV^J&NgyDyfWX$~57FhD4pq7wIsqK#C~^TFRW57Dw+ep1COwEG zDbBFeL~6ThumsYDls5#%fj-QHcq|+w^#N|!3LL@6ebPzX6W2g!6OUiZKMtN1MS=(A zfx7ob)rYpQkb6gcTidCF`dCfpw*v#+%^OVA+ua=)_C{N}+5W4Y`!Ds$L(^brLE&vOpE$6o><|kS z2(5wU!1-kJhv0@HpVVxnB_;t{9#pA|ToT^q3Y7{xLYK*N&HC7iM(fc*jsn8^U9rv)S-R)1N-kj+tD2Hf3B&$ zT4U5Ln5=7O3NLp0!HS^n}nx6U617;t(uq~cui!qs*ur2GMbte$}*ZXLoA7` zNQO(7snxiEuEk`wT$&FHYEcb5UD*?_xe9tU?EeJ1NrVEj@NZ&Gn+5B&!oPSRw6;Lr zN?Ee_fz#mj^3$NNqqEPS@TzRdz?!!%?Y`$a2Sa}*I-N*0Wi#CtYfrk3pAkg@wF_U$ z8>KTs!C1JiKBsM|t^Pns^ZN28k)K*$ zQDi~x79pSS4uian$S9-yY0OoeB->t8Jp^`w48oZ%?RijA3NyC|EfH%Z8BB%AnpeH@ zWXcb7viYEIu+J2g!;RTIuq!j^_b(<-t*%uyW&;uT``ABkD2tmXWHeV=4jj2!VE>wE z^=L9<1$1#08ZfJ!#aV^QXC=zBV#Qe@TZC+4ZhlBrxNbZvwVa8+u|&>t^|v^y@Aiy0 zEmhwhtL|@Dd}yGjXW(c5u+V63@C}Sg=eptzplDWjZ@vOxT5?2cpdQOa zM1v|Ae3UphEqe@*WK|T{Br)Qk6C{Rg7D^W>mO=6{`TF9K2rY2)mEbt}LXznaOZ>6Y zNtdoq)`o*lyR8c0;4I5ZC*7-{q$Dcdc_X^rt^eNT>l5mJA{5(E`0dlD`DW@AM>v&k z&)b3Bry2%ytieXbWE^R( z7v7jRH)PU~_HL9mhU|b_M_NZL zCeIK)B_GPK^9Z7doljU5mlG*uNg)DMytWWTrOn&k6tv*6fvwV}^_D$->( z)J0a+Bfg+vMC)_=UE44F^jFg_YG$JCCpe9V(Z;vJN9{4=we9gCJokDw8P>|t4F@b#PcEfQ#Dy< z@~8YV`amOnKq{9N*L5NqW6)Qg!(aICKD+`-8l~NmDMv0ezUhTku@f^_q)|6Rj%KY z+Q*%-v!0Mga%u)WwN__eWNGqLu+iFJbo!is0vBhqP4;X0EzbS>3s103&GRBDtQfOD5vcP|)v1 zxgx8v0#!e8npH>}uUJx83Tau&EI3$q@aD;?jO@$g`Y*3KtnIy}WA99&p~~JD?(1|< zq;gGb`AYcOw>&>5`EZBx&O)6ZO zmb|P%Bc{!U^=9RlqHhYsVmI{U(&me8rzcBSV%G|=WQvM1wq6#;GL@GENX82CZy44$ zuPYY|-iRxU2evmeT61+{?zKbe$WHx{15e3^C_)yStOQD7=n23HBt^9Yn}tP&PznXeywOH2p{rNZM#2#nD3G;Q(k zlp-5))QsE6ACcTTn}TQXkCgjyHb>Bi9l>@Kh4Eo{R$+E?l$p+ijVSY|6lF%-g@VX} z0GVGJKHx??BC|VR9z!;`5s)dykV`|h^d3@24|FdrpK5EX-{BU$oq_0)eT8?k<<91) zw{V-Z5!gRqL@DZ0g-S09H^c?J#*9o3wA(?H3R?7KFuj6c37pAG{0+!9GIn$tL>W*M zu^455hQd7t+tgGhK9{R1GgIGfv0Jh|o%z^!YkIcn&RcWGeZiIbNPn_^N2YnY?hbvm z`I~R73TLBHdxgQu4X zqU5^gHM#g1!)tQkQ>7A|O<$F7;ZjZ#)#L(?Y*&-(S36hNTHEVp+^ESF-49`eVC8R? zgicM2Rv#m+$4D(gBoDM`aTFZrfI%=p&Na6%i%L4EO1b88u(b9318XA#gCnP2r$2p~ zjTb(-$rB(w|99|wB#mIP#>w{~&Gf(gj(;jRxVM2gco1{23C;Pe)o4I`jiS(yH=+`t z6ySfsIABCL8(s+wK)D|J_noqX~s_F`T8 zg4132&%gN__QtC}W`@GQ$y;X-IkLwZXC-a@{jh|rX8Ak~;WY5;|mi$`3tS`;#n zJrN-shOT6m zff$9d%he{?zer6pidS%zj4s2T+I$rcx52d=@WHIKRT(SFa13|2JiFn#`_RpWkUV$2#gp=8gJ2m6fiT%jYy( z5}lc8aup$lBI^Xp`3f*Sg-<|nawC%u134ywLO$E%Fev$lmC8v<*+8cZoPZB)a}D`H z=?IPpJj6m9+jVF(OuyZD} zGZ{bHeBsR6d)nHYI%<8^MtyLsb9z^(GsEErhmIGX{ikTUE~K4TX)rECwPfL1#%FL= zY{O^r#@@nbY!jaquVJsuXWM#T*|ssR^Y?D>**0EvIXw3^c_L7vi+n~C0iRuOj5p@9 zS2e&5KHF}9#Ajhhpl^cDTqJ=kU@np{ zBXlD@jN6|4Iyyo&Uj}~{x?o{6H9(Eo0B`0_APF}nyA+Ftf!$t)9&fS3C@(n+qP^RLJ%zx@r^8;J~B^lELs@Dm>+9G2TsNj=if)-#O z$*)M#2XHYaO5dpIn|$#@42s{BH3 z7tuW&3<`ytyi)wW5*Vbuct;s~BH3se3%bqqSW1Lr;!*S|^+Ad`WHDC9>L`l4y%;Mk zaY>l9O44j0d*av!yJph6lJR5B?u_lz>RzM%)O&M~X;sn6sx3kdtM14JWWfgC6qjto z>0T^)HNj6SAq1c>!d0SR%A?qHlD1z|fvbh;aJ4mPkx+|RTRC>j3*k%X=CGxA?cJUA zr^4y9|5RP)Q=<1dlmZ`UoV1vS(qI3d9jU?(@p#gRzYUGpC-mie;9gL8DGeTE#=Bv@ zGZkQcFgz3pqAQg!b!Z$Kv}jksd5VAd1fSCyj94vN12FzYWxO>ehp~_~tj3Tu3fLb| z&SzJrPW9+r;o8c&SX853)oWvcX4|1dva((|Rd|-U8)ER4MzYDI#=E;P#M%nKFAwtz z;zDtlV%ad2f)eQ`r!Uu0dSjN6=1@SRm{%q_|Rp*?Y-rZx7`H|FtWO6O{*nRiGqGyiz2ei7y z6XT17;e#xF)5DL+Lu|tkBf?MQU4891i@O@%s5j35yITGb+qXEa;;s%9=XXsQsp!@$ zMj>Q6NY}km6yjQ!ohU&6|Ka#B2xle2#s$*M&j6?em(DrLF{Q;n2E#<9+=-o}S^)@4shXkKF(~&S zT_`G)m#=>%^et##g4&>lHHnODyRo2PY_xs@V`_0A6Nb({$~@f%gz>l{<_8) zJRIsXyNFdc3yFLTvXW5_1NJvQUgdTVML8t&u~{%jBT76p2_CS z-C+xW85t`84n1(B@E$?2j{IYGQIWy?ov_y~<@G`Ee*hq1I;g4JZWQl?H9%Fui%zyV zj>TE!9Dp97>M_Dakmd<3Lvg(9dJ0RHUxnC1H80;%U0V%2q~BX!x`jkHm1vQG+*^9L zZ=KW~ng0Gvrmrt!^?0mSpO3x0Y?9(qPdd}nlS%jVc<)udT{^_0*P`GocSN*Fb3N z1=Jzo3I)y{VTIf?-0{iXc6z2)VqE0(Bv=aYZO`QzPt~Pc8`-A{N9&isLlFM#U-)zE zluk*2J9s0oFv1FA>!~H+((me2VfM$i*b-I};pVjNCSP7FG)}onT`&QtS9Unqgcby8520Y4o21|i6T8?Tfy97(To?uBI?*_o zi938Y|4fy&D;LPrqNQu$kEH?qD+W|koIll(rt?-p=w3U2zUMa0zcokcB}nm*=Kq;o zV>0w1mde#jZ~lDe4#(1*V{x=_n!iq{Twjs5;YeP;u?;2j*s^&yPLfaB0`dqV8O72* zC7a8Df0Z^V8N%1!r0?TkKzWy5Es&f-c?AucsQC~#-Jjo6jr|!4qAMv?{Df*r*8;&n|HO-s)5 z__6;bMymD(ui|W=``91hz8dluVuZ|ug=cicmV|Vg+~wuT)G$C?(G)Mw0ui0-QEaua z)TDVK!P>pQrMAUi(KQ^Kj)YfIr%ngjqUl)|^R{*$Ooy|zDPMD|*|<_+I~Vrs|aP<~Z6dud=iF(2@+WG>v6Y3yiuSg$c^m(8YhM{|1i zmMp^L`zxw$+0n32W!llQx*yDdXpX2s&Ym7;AA}91Em5Ea$z*g4Bl;*D!&p%!2>vJZ zVikl61FqF0Th*e`x>LcHAS9WE+9O4qEU4!61vb6h-(UEhzkhU!Jy%%W_2`cnMH+}m ze}>n%DOp1XpOQqA_6uox9Nt*Fg)pc)!n0f8GI2AvOqdN%9iX+=peCr1Qb&H6IuZ)+ zsht{Yp8SG^oz@k8WmvoI#65bWY1w3M>27Q7XQqXZ96Y#unr{N~3_XS|5Emx$qXx{I z@|V$fK@r6oLJ3tM7^zT|u)b-I)G006Mw?)45+fE)uE&clLTKz(45^f%EJgLp4p@!d z#%k`oyZ^3*>{w0BNZp>L@ZRXzQy1BB2` z%nf(;40#+wq@4z_4PFL0;YrGe;8CXY2p^ICT{{I?xKY*%-Bpnz-(2O=c!$>9&C`pzHp&(veAFB7dk*tOc zLP;i+yLjcy37djaX?PgfX@$Lo6A-u|_)%H>*PXe>^lf+Cb*8?pr|k#1rU&~5ch^l! zwB+WRx~BA>$t_h`CsOl=56`Ek?Nt-)pJ8W=4Gp(6G*4R!f0k{|qzo2AQ)_GX+9gEW z6&02dfn(0OOgRa?8RA$8ag2r`3guPStJHWlLCOu@=H47d0fzO_Imb#2ie_ zjs~RF+Y>vpcHv%H(VoPl|1m71zCwSXqGD;J*5y8Zc=_U;muW#+pVvP8=x}HEFovI4 zzs!1sXSgC*CWbr8#Bisuqp|{-rDgT@)H*5Jx1G}Vso|l%@$vk?NX+Ao$GpDFlYJvI zGb4SIoypovT^&wzuufq8Vc`Y5cB|xVFG*f{LkmgXW228>{9{w6uRu0iYW-oztY?t% z3CRnP8iS|+hm=d+jt$A%&ZAGr`>c*8R5};OUNFVVt1J9kLS|65>myn%gh0CxZs(G^hNy*hKj|a1=2UXt@Q0E zlfGC#j~jxh#vS!UWhfO!rj>VwWY5U4Ci`}>_cwEW!`+TVrME5=HAky*^|>CO-Rd*> zDlWIQ_q65Js+`_eT~T2`CQfs+6N0=#XU&4|g4jFjpx`!Qw~dS4_F^?fh+SQE$_c%< z$*!5*_sCzaT>P`n#vgE*dzl3=b%zj=WNu=M%q0O(l)0E{1W}4`eWc#+h0LYY$)W%# zh1H5OR~?r(804(e*;o>~&$bR_9G0GzmTq&k$39r+G`buOl~tBji_>SdT~5?C*bD(% zESZeif|dG!vo`Tzt+}eo5;j&TFGtKKs7DqX(o!J;q*bfN#>1+ii5#kj!PyFd3$NRD z0(bMSqrwgWzboQ~6O4E3Ls>aT70op(o$`VZ6z%`S1xKnHwE;wR}i)NEwtJqYPZnpl|?N(v7M~NSR5e8P0QN; zGFgiy+*a1g`{TbNYuSlc5VqJs>)Y$Nc`j^G^8{Oo7f)5{;oK_|w(ML{+G6qnO#Zsk zmIOgj+G4WTNLzWL&Hqi(mYsMdacf56uPkomg}ZriyLs{COjzr6M0+sR)M;`3KRtmBN_S)tb48S zd*W|gJwZn$Y|}+7o(ffYV#1M6sBam}?~D6PQZ&Gs&+#87g2Lbt3cdiT95V#D0dapqjxMy3}Y^ZL1l6Xr2)EKj%A)PE{Y5}WQBlT5L4-@dfHry19`rhc z{sj0?tI=uE-w1w2Fdgh;fR0tG)HIIQ)k8#xmZ$alcPf=w9=aV1baftxV=Mq7s ze9)l5hJbtHEZnpTg=#0YJXWkAQ(vV(a4EI@<>EuzT#fA^2@;20Z_~?N#48M&x5s8- z65OOTY;156T?0}cuS6I{tKW$qdhLn< zuhD{ZgYbSURca;Lq?A8M^AIGrRQcm39@J&CAqW3U?+y&O#96yd}o;j{{gmewD4OjCEL!VXSQfGsMggG^;bdw zl??jAZ;(_kEs}~m(hkbZ82}O_C%iRZC9!6c$D{!ExQsEQg!O|*4fIk1JEl|t!w8|R zM_>=#-;oO=OYOw=*CE^oX-!uvkdt2&*XepmVAto;x!Rb+rVkhb8~jLZ2R4c8mYY{- zHh491Lql>i2kUQE&)Fbe1TOSmVM17}JYVcpEzfR#P0^9pbBjI6OR1ZNfYA6*~BGo4sZQ@PP74q>!OCIaj{ zm!cM_*IF%GHK?f9GGBqrjjl#pZ&95zkKlSS1N5S8DEZ~*d{IJ|dy->$ii4rjC3))ren`pD}adH9Lr`>M~b zEbbi|>&aE<=PQgSS5IGj)8z{v!)PeS=MvBut-{XyLOoMx3Cj{hEk8tLl~QnER z_NVl@Q?snG@bdKh;MfCxU%=rAq_U}iZcwcmncO?$bR+22dFAY#obAZci6t^&8p2sP$?@{Q5s6Sca3F_&(_2eg{I|^}r@6 zC$aO#VGcZO7v;D8o|b`s|NqE3G&g25$=XQB>n0Qp zrk~qb2e|0uVp5V+yQ$^aGgTGQStfT!b{wHqIdso;SLO8i>5J70qiW9RweZ#1vv0b4 z`_-Ykn0p`-y#lJiTHy(XRf>FBo(%xRgMtu3Q~?w^D8yjVU5+DzAU6O!pi(BbwwHN} z*w#{R5>&&mLQbot6NK$(U5(f6X;|DjwBz33)c%!b&yj(JW0^PWU7npj=hWQn^d)9` zPeW_|%;;?oNQ|SfVQ9_5FJuV@O9JWq3QZH3VWVWr#;35Ap=={zyLcIn{Srbw3h$Ew zrEM-lu%T)gS}+j!nQ9`?7Ehxl#feYE(df(1!9X*sxxMNc(hr?ajr+(bsm5%n>V*2xUK5(DO15q?~a96L#J+;H*((11a77C7P zm#0~t&bLJ^>H(rfAwLm`QJT=pwwVcF6q$464>nT$BND?Xh+%-V(Gk*y?*>{=O_6W% zQd8t@uH|C7eC=uJNr&KV3O!DTRZ9AAVJ)=u#S{gga%2U8)*iuGU^<}3^Tl^s!wL=( z#Ntb9|97p6**%MOS)-*Z+BBh#dKZJ^Jw48V->W-#;E2W(NQPS*P2NhMK6`xb%6(q9 z+uPb`31lZ*ho*zcSRm*xe3I>UyXW^V^(vK%l#@lkx7Q2g!gL|H4`po`lOrV>zZV_h zNNcsH_AM@Q@;)qlSQx@re##4ksMuM&Nnq1u_fXfdlz%yW@$;XjNG;UT*RyxyHVr{! z1pxvGXbq&X!T+@rikAYm6r5qxq=7^4DC*p5=-dR7R`3z*+sB5NmI{BoM5x=;`dfq# z|5qM~0Z#6{2Sel!9mhB&55HMh;NvvVNKs3GXrfgC$qW1(=py%^0D|ydSUD6rK>jlMYI!O)jCXnR)MQyy+cy8|MAK<}wtTa^iRrrnw?u&Bn=qb|e=9q1D{!Gc9$Vg!AnA4>)Q&(sp-K zw58r|+Gnw?F0~CsoRLJ0fcV)e(MRK>n&aZ@kTeIMO^VpN+-0D4eoWDP0FAuh)F8|E|fWu!>@9HGh_7F ztUwHvwx{}U&QGtMZB2P=ookp^O+DtdSQA>|1peKFi*18ZXC&#jIO3gYyQ?|coW|tL zcD@V$8T(@dZ+eAoh2$4(#ebIeqVNn0v3CJCTZ8$)ZjlkbgnbZZA?-L$Rsp9@ZVOKK z*-%?B*cJ-Y-_PKeko;eehQbEe6>zFi@aA1;Wl8991g`)`1S%fx4q=Cb5iR1mc+MGb z(&U^PrzZYTe)sv%5!Hg~Nbvmb{45*DtTqfb-g;}}V8d$Wq{LO?<9MtGX5v_W*r>w; zAyom3KT7-qFA5b>M^zo_oRFF_SBeHh!T>2mnIX7~N|lfs#EQlw@*izYs)=lI{=d{M z8E7Nmx5AomF09Gni?P;)lMQQES`V~-q;6Y53!Bj5F!hsLf5JnBWD~!{p5#wT%V9@|Re=#AJV_Os5NDLbju$ou z9aTugVzpP#{JK^A)>Y|1J1smfd=i%Z<8p^K63|?Dv>|{w@w^@k4-7g!D;LH1!iX;_ zwp?tw-o<3oY`%D#hf)DIX}D;s>QLyMDJKVD=bHE~a31pF&lG|_!MFK#*VXOz1sCh; z7Xw7u_5Y`5(aIsAsN9Gxtx1J^zR(Vj*Xwy(V6nb#F(}Q2&*C$zUVIiiu0?9Svq7;W z1(UB`28miG!BkBVWoH^S@F5Uoa(yz}<#u;v@17nSnjWpY>ye?MH{D$~^u^n5`{EE@ z=3M^_bG^2gIrz)uN%=)Cr<^l`gERMJJKgTi?5OmTy1U;rH1x<_6qs_ZKgEp5B@793 zFS<}n4Er69H)=tNqC1EUD1<{esJPT`Ao=OSJ-}m=-2gpdHyNmu|Dh13c`wjljTKD* zgb)i!y+8?r3}Xl*B3%$WlvLYdQFAg!zuJ*?$CJ+NK)=szb@}}+tJ`<1z4LvkdgY)h zn|ZpkX~E(9AKq3&MYHdJ_#A|D4`RsGe8|%;dfYk$;Wvg1{Q^S!5Iz!DG42Lh92cYz z2F(yH#b|e9Cvs4}pums`6c7@E!MY&9@|~qJ9^h`$#UNxj01U`GFggYv4sHyye<15j z#@!8_$7qPY<_bfrmxkEX`E(|$8dTP&-q+cVH>cL02J-7G(157;3^wc*1qefi0)#I# zf(8Cl3B&*HDYOXFfI!t>%Y5HMhBO86pTNNQl;jhLE?06k8E>xclhvQ-BBS6{Tv zF7?93VJlm=sqokABdiVF_==|~wXwVJujS+PHQpuKZvI_=UwW4!ebaX-6s5bw%uU}V zDob}M_TBVdifrjF_RX8Vi@i_2YyAcG3*tZ_#>3$^G!ZBu?L-`Qo9@0csO3SYP z(dJ$Kw|y0V=y90$IP_n790J=}> z{sqqXN$LcI8e#H{(1c26#u&L2L2qF z=_Q>GYsWX6gja*1tVv1t;EqP@!Uj0jVnS!W&26XXe-y4AoQ4SfQ$jC#x18V|y5Jqt ziad!I4~q#g)JF|hMeMwZQBZQt;MA6s7DP8-fsii>KjGAu95)tr$vs-T>!hjjq{(!2 zs$tF8;Kko-_|TCsGKbl|XD;01_jiQ$MLzi{b5-P%DC>n!_4>kp!hcS|s|NNTWXQmO z-ziMx2|b5=H3Vo>aOkNt#|?~HNsDUbafAjDrjecD*@j?%5(9HB4V~G}L_ClQW?W8l zjip91e#*+3w&DmU^^QQ?r0tP=jl0#h8b_Ow2-71MhKDbVjNCpveEUe_d?WsAn$KqE zo5G!;P-i&Y5ejvLJ<+Jg8;R(LZXcB{9KC&LBD+8r(|-%uTezi0?kTRqR z!xIori}A2gq)?#rlm6{Ml%?m=6Rk(bj~|{IiF?}m#zt3npS@M3K2Tk&7qtl34Z~k6 z>Z-&poz<)qwT2wD)-L6+%3PTfE6pfwYcOg?R9>?vR#B+HdZ;h{N{k6%Mo)L>(fSKE@uhv zta0?_-QIzG^`Xb-Bxxa9!HBg~Pa5m4<{;xu90>*_oYhc6vO{@yWs&Y2fT1A?A4MeHGl-IQt>u zAbP<v$R$@eY*#e3gq`FmCZlB2KbZC#-`R6-xx)wRKDKY) z$4t3QL%)K4vv;(%4C|Mc#4Afnd*+%)swxNi4(wZEU;px#n^KM0g8$p!&UQCSIgAgB zN%5~Ru^?5IsnkG{cs+I_96-n~6h6bTkbh>;4B@k>(8;Thz$T=`h|gfo|4#H)mA=M8@zwuUFw%R zQ7X_OAkHl)OC!&6yuMR#T5OhTHIj$74i}@vU^m99JbC)|TV{eyuDZU?riPxG=3L{_ z4()xnp1n)IV@fnu%sLc>|EZ{+x2bkK!@4TQM}~G_)1=maC|bn#;$+PrDsfxBy2@0c zQ>uk*z1v|`iRx&GiYX&uT(Y-4)P_%?dx@S_S+M#S>XYtwukjtZ<(2(MHE^^Gy7}s zTUt1o4^THAr9!B#0@g=V1U;y@3slx5#Go*a%DZUQLMAHJwxmo%>US%wVqd2eAdVZq zC_uL3_!csBH-7MQP-GyBF&s+PhSK454LUuMV?CZD02`c`JOJZ_A5(Jwz?&&)H95Y| zNREL~_hsjNzS)L(XIo96-LcRxXEd9Qa}5iQwm?mrb1j|sH~P~(y{%nciPo0dp3Xjt z-Cot-*<)>RyR)|L&b-BDvn2BAnyf#cPUksaJO{7(UqFH+g{&}??{`|T@Yz%};;GiD zv`iU-0OR%Au;Cy+;J~IeE+!3QuE^EL-)JyvvtZBBhI3-W5g^Rj5{hr$dT~4V18ERS z;TzA2qYJ0n&vN_1i31s>^4P+m)w#@!Yj|#AKDe?xm#B|EfR=T2OOqnj&unFXAbRG< zCid&6r$m!>#jW_7EjLm>cEB*h{`@%&3du(czo&`E!wEB=+Fd9?_5p0n5E${-5|+-S zg=qA+MXe6q@U#?(EB^#V?x}Dof$lO;{CI(7@u;AOSk)V7?&xr>({ZgP3z#F-9Me=l z$b$yZYsgX6^oq9uOOcT81^ANRy6s)1_u#c0fwAQ-XcX~q*j0^0$XFN>-0!Upxk57R zntRLv$#62K39fezlf;Qf>9Aa&+tJ$=3>Jq1Ewh#eHZW7H-f{QArLK3s8vv{Qn!u!U zESnh_8t%J?J+D81|K&Rq`*w79@3{K+8J~Bq@u$h@U5itHFh7q2kb3V@YwbqiKz^?S zKng;kW|)CBFu}lc&Z*)PD$qf3)-z^60~3Hyie(=R24PjE1rzGgTrVC2Iv+U}Xm|kU zKXB9vp^-IKN;8RL4#ZZ?D5YBjrb>56^08#1_SprY^m z?)Hh=#8~6b8RK94kg*^Bsd;faGoDC{>u+5iFgPjzJ{cH2-fl4-u$aaM2k_4dhhd`&s$<3;^3ivBozBlD}^8n>M(z2NXjY6SrM_$NkRi+l++wj z02OMrxykPh)Yt3B2CD`uE&FHuF>7V5rM_NQagXzk3G?o|EmMYInjKi|n3~mTcc|2% zMEhdl>B(8XWgZrnaPZPu79o9tAH6R0a27+=fT&Ts8q@*BmB1`$;OnE>E}KqawVF?u zpTfW!)ZkGltdFBjx6Mf$w^Pu)bhJSRqF*^{eBjFc56v~sMR)A|Bj3>-a|=V`?+BA4c`M1rBv&>X^z@vD%@RGGgy!8)p6yJOFz*(DEq5A}gPQ_5Z23D-4@!P

#6?UC9ica$uYtTr69?2z3dx-v*Kob)6 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 literal 0 HcmV?d00001 diff --git a/flutter_module/lib/generated/assets.dart b/flutter_module/lib/generated/assets.dart index f225538..f355e6e 100644 --- a/flutter_module/lib/generated/assets.dart +++ b/flutter_module/lib/generated/assets.dart @@ -2,6 +2,7 @@ class Assets { Assets._(); + static const String appLogo = 'assets/images/illustrations/app_logo.png'; static const String reactionDislike = 'assets/images/icons/reaction_dislike.png'; static const String reactionLike = 'assets/images/icons/reaction_like.png'; static const String reactionUndefined = 'assets/images/icons/reaction_undefined.png'; diff --git a/flutter_module/lib/presentation/screens/login_screen.dart b/flutter_module/lib/presentation/screens/login_screen.dart index 8b60964..eebf252 100644 --- a/flutter_module/lib/presentation/screens/login_screen.dart +++ b/flutter_module/lib/presentation/screens/login_screen.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_module/generated/assets.dart'; import 'package:flutter_module/presentation/navigation/login/login_navigator.dart'; import 'package:flutter_module/presentation/screens/named_screen.dart'; +import 'package:flutter_module/presentation/styles/dimens.dart'; import 'package:flutter_module/presentation/styles/padding_styles.dart'; +import 'package:flutter_module/presentation/styles/text_styles.dart'; import 'package:flutter_module/presentation/widgets/login_input_card.dart'; import 'package:flutter_module/utils/hooks/use_navigator.dart'; import 'package:go_router/go_router.dart'; @@ -33,23 +36,30 @@ class _MainPage extends HookWidget { final password = useState(""); return Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // TODO Extract strings - Text(LoginScreen.name), - PaddingStyles.regular( - LoginInputCard( - onLoginChanged: (value) => login.value = value, - onPasswordChanged: (value) => password.value = value, + body: SingleChildScrollView( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: Dimens.xxl), + TextStyles.appLogo('SnipMe'), + const SizedBox(height: Dimens.xxl), + Image.asset(Assets.appLogo), + const SizedBox(height: Dimens.xxl), + const TextStyles.secondary('Snip your favorite code'), + PaddingStyles.regular( + LoginInputCard( + onLoginChanged: (value) => login.value = value, + onPasswordChanged: (value) => password.value = value, + ), ), - ), - MaterialButton( - child: const Text("Navigate to login"), - onPressed: navigator.login, - ), - ], + MaterialButton( + onPressed: navigator.login, + child: const Text("Navigate to login"), + ), + const SizedBox(height: Dimens.xxl), + ], + ), ), ), ); diff --git a/flutter_module/lib/presentation/styles/text_styles.dart b/flutter_module/lib/presentation/styles/text_styles.dart index 78d0f7e..fa05766 100644 --- a/flutter_module/lib/presentation/styles/text_styles.dart +++ b/flutter_module/lib/presentation/styles/text_styles.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_module/presentation/styles/color_styles.dart'; class TextStyles extends Text { final String text; @@ -57,4 +58,15 @@ class TextStyles extends Text { key: key, style: const TextStyle(fontSize: 10, color: Colors.grey), ); + + TextStyles.appLogo(this.text, {Key? key}) + : super( + text, + key: key, + style: TextStyle( + fontFamily: 'Kanit', + fontSize: 24, + color: ColorStyles.accent(), + ), + ); } diff --git a/flutter_module/pubspec.yaml b/flutter_module/pubspec.yaml index 0a94855..de069a8 100644 --- a/flutter_module/pubspec.yaml +++ b/flutter_module/pubspec.yaml @@ -55,9 +55,15 @@ flutter: # the material Icons class. uses-material-design: true assets: + - assets/images/illustrations/app_logo.png - assets/images/icons/reaction_undefined.png - assets/images/icons/reaction_like.png - assets/images/icons/reaction_dislike.png + fonts: + - family: Kanit + fonts: + - asset: fonts/Kanit-Regular.ttf + # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware. From 1fa305c8610c6078af67215be4e50b5f885d93d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Sun, 4 Dec 2022 12:30:50 +0100 Subject: [PATCH 15/66] Styled login screen components --- .../presentation/screens/details_screen.dart | 21 +++----- .../presentation/screens/login_screen.dart | 53 +++++++++++-------- .../presentation/styles/surface_styles.dart | 19 +++++++ .../lib/presentation/styles/text_styles.dart | 4 +- ...o_overscroll_single_child_scroll_view.dart | 27 ++++++++++ .../widgets/rounded_action_button.dart | 41 ++++++++++++++ .../widgets/text_input_field.dart | 4 +- 7 files changed, 130 insertions(+), 39 deletions(-) create mode 100644 flutter_module/lib/presentation/widgets/no_overscroll_single_child_scroll_view.dart create mode 100644 flutter_module/lib/presentation/widgets/rounded_action_button.dart diff --git a/flutter_module/lib/presentation/screens/details_screen.dart b/flutter_module/lib/presentation/screens/details_screen.dart index 28dbef6..18dbcaa 100644 --- a/flutter_module/lib/presentation/screens/details_screen.dart +++ b/flutter_module/lib/presentation/screens/details_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter_module/presentation/styles/color_styles.dart'; import 'package:flutter_module/presentation/styles/dimens.dart'; import 'package:flutter_module/presentation/styles/padding_styles.dart'; import 'package:flutter_module/presentation/widgets/code_text_view.dart'; +import 'package:flutter_module/presentation/widgets/no_overscroll_single_child_scroll_view.dart'; import 'package:flutter_module/presentation/widgets/snippet_action_bar.dart'; import 'package:flutter_module/presentation/widgets/snippet_details_bar.dart'; import 'package:flutter_module/presentation/widgets/view_state_wrapper.dart'; @@ -95,7 +96,8 @@ class _DetailsPage extends HookWidget { : null, ), body: ViewStateWrapper( - isLoading: state.state == ModelState.loading || state.is_loading == true, + isLoading: + state.state == ModelState.loading || state.is_loading == true, error: state.error, data: state.data, builder: (_, snippet) => _DetailPageData( @@ -128,18 +130,11 @@ class _DetailPageData extends StatelessWidget { Expanded( child: ColoredBox( color: ColorStyles.codeBackground(), - child: NotificationListener( - onNotification: (overScroll) { - overScroll.disallowIndicator(); - return true; - }, - child: SingleChildScrollView( - padding: const EdgeInsets.all(Dimens.l), - physics: const ClampingScrollPhysics(), - child: CodeTextView( - code: snippet!.code!.raw!, - tokens: snippet!.code?.tokens, - ), + child: NoOverscrollSingleChildScrollView( + padding: const EdgeInsets.all(Dimens.l), + child: CodeTextView( + code: snippet!.code!.raw!, + tokens: snippet!.code?.tokens, ), ), ), diff --git a/flutter_module/lib/presentation/screens/login_screen.dart b/flutter_module/lib/presentation/screens/login_screen.dart index eebf252..1d1fdf5 100644 --- a/flutter_module/lib/presentation/screens/login_screen.dart +++ b/flutter_module/lib/presentation/screens/login_screen.dart @@ -7,6 +7,8 @@ import 'package:flutter_module/presentation/styles/dimens.dart'; import 'package:flutter_module/presentation/styles/padding_styles.dart'; import 'package:flutter_module/presentation/styles/text_styles.dart'; import 'package:flutter_module/presentation/widgets/login_input_card.dart'; +import 'package:flutter_module/presentation/widgets/no_overscroll_single_child_scroll_view.dart'; +import 'package:flutter_module/presentation/widgets/rounded_action_button.dart'; import 'package:flutter_module/utils/hooks/use_navigator.dart'; import 'package:go_router/go_router.dart'; import 'package:go_router_plus/go_router_plus.dart'; @@ -36,29 +38,36 @@ class _MainPage extends HookWidget { final password = useState(""); return Scaffold( - body: SingleChildScrollView( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: Dimens.xxl), - TextStyles.appLogo('SnipMe'), - const SizedBox(height: Dimens.xxl), - Image.asset(Assets.appLogo), - const SizedBox(height: Dimens.xxl), - const TextStyles.secondary('Snip your favorite code'), - PaddingStyles.regular( - LoginInputCard( - onLoginChanged: (value) => login.value = value, - onPasswordChanged: (value) => password.value = value, - ), + body: SafeArea( + child: NoOverscrollSingleChildScrollView( + child: Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: Dimens.xxl), + TextStyles.appLogo('SnipMe'), + const SizedBox(height: Dimens.xxl), + Image.asset(Assets.appLogo), + const SizedBox(height: Dimens.xxl), + const TextStyles.secondary('Snip your favorite code'), + PaddingStyles.regular( + LoginInputCard( + onLoginChanged: (value) => login.value = value, + onPasswordChanged: (value) => password.value = value, + ), + ), + Center( + child: RoundedActionButton( + icon: Icons.check_circle, + title: 'Login', + onPressed: navigator.login, + ), + ), + const SizedBox(height: Dimens.xxl), + ], ), - MaterialButton( - onPressed: navigator.login, - child: const Text("Navigate to login"), - ), - const SizedBox(height: Dimens.xxl), - ], + ), ), ), ), diff --git a/flutter_module/lib/presentation/styles/surface_styles.dart b/flutter_module/lib/presentation/styles/surface_styles.dart index 2bde85f..50afe29 100644 --- a/flutter_module/lib/presentation/styles/surface_styles.dart +++ b/flutter_module/lib/presentation/styles/surface_styles.dart @@ -41,4 +41,23 @@ class SurfaceStyles { ), ); } + + static Widget roundedFloatingCard({ + required Widget child, + GestureTapCallback? onTap, + }) { + return Card( + color: ColorStyles.surfacePrimary(), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Dimens.xxl * 2, + ), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(Dimens.xxl * 2), + child: child, + ), + ); + } } diff --git a/flutter_module/lib/presentation/styles/text_styles.dart b/flutter_module/lib/presentation/styles/text_styles.dart index fa05766..452ca53 100644 --- a/flutter_module/lib/presentation/styles/text_styles.dart +++ b/flutter_module/lib/presentation/styles/text_styles.dart @@ -21,11 +21,11 @@ class TextStyles extends Text { ), ); - const TextStyles.regular(this.text, {Key? key}) + TextStyles.regular(this.text, {Key? key, Color? color}) : super( text, key: key, - style: const TextStyle(), + style: TextStyle(color: color), ); const TextStyles.secondary(this.text, {Key? key}) diff --git a/flutter_module/lib/presentation/widgets/no_overscroll_single_child_scroll_view.dart b/flutter_module/lib/presentation/widgets/no_overscroll_single_child_scroll_view.dart new file mode 100644 index 0000000..db2fda6 --- /dev/null +++ b/flutter_module/lib/presentation/widgets/no_overscroll_single_child_scroll_view.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class NoOverscrollSingleChildScrollView extends StatelessWidget { + const NoOverscrollSingleChildScrollView({ + super.key, + required this.child, + this.padding, + }); + + final Widget child; + final EdgeInsets? padding; + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: (overScroll) { + overScroll.disallowIndicator(); + return true; + }, + child: SingleChildScrollView( + padding: padding, + physics: const ClampingScrollPhysics(), + child: child, + ), + ); + } +} diff --git a/flutter_module/lib/presentation/widgets/rounded_action_button.dart b/flutter_module/lib/presentation/widgets/rounded_action_button.dart new file mode 100644 index 0000000..cbc751b --- /dev/null +++ b/flutter_module/lib/presentation/widgets/rounded_action_button.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_module/presentation/styles/color_styles.dart'; +import 'package:flutter_module/presentation/styles/dimens.dart'; +import 'package:flutter_module/presentation/styles/padding_styles.dart'; +import 'package:flutter_module/presentation/styles/surface_styles.dart'; +import 'package:flutter_module/presentation/styles/text_styles.dart'; + +class RoundedActionButton extends StatelessWidget { + const RoundedActionButton({ + Key? key, + required this.icon, + required this.title, + required this.onPressed, + }) : super(key: key); + + final IconData icon; + final String title; + final GestureTapCallback onPressed; + + @override + Widget build(BuildContext context) { + return SurfaceStyles.roundedFloatingCard( + onTap: onPressed, + child: PaddingStyles.small( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: Dimens.m), + Icon(icon, color: ColorStyles.accent()), + const SizedBox(width: Dimens.m), + TextStyles.regular( + title.toUpperCase(), + color: ColorStyles.accent(), + ), + const SizedBox(width: Dimens.m), + ], + ), + ), + ); + } +} diff --git a/flutter_module/lib/presentation/widgets/text_input_field.dart b/flutter_module/lib/presentation/widgets/text_input_field.dart index 0642ce7..1dbb55e 100644 --- a/flutter_module/lib/presentation/widgets/text_input_field.dart +++ b/flutter_module/lib/presentation/widgets/text_input_field.dart @@ -36,9 +36,9 @@ class TextInputField extends HookWidget { decoration: InputDecoration( labelText: label, floatingLabelStyle: TextStyle(color: ColorStyles.accent()), - border: OutlineInputBorder( + enabledBorder: OutlineInputBorder( borderSide: BorderSide( - color: ColorStyles.pageBackground(), + color: Colors.grey, width: Dimens.inputBorderWidth, ), ), From 3b9a54038e14b372345ba267b3ec70890113d226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Sun, 4 Dec 2022 13:21:26 +0100 Subject: [PATCH 16/66] Corrected equals checking --- .../bridge/detail/DetailModelPlugin.kt | 6 +++-- .../bridge/main/MainModelPlugin.kt | 6 +++-- .../lib/presentation/screens/main_screen.dart | 27 ++++++++++++------- .../utils/extensions/state_extensions.dart | 8 +++--- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt index 6189184..f4b2eee 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt @@ -50,23 +50,25 @@ class DetailModelPlugin : ModelPlugin(), Bridge.Detail } private fun getData(viewState: DetailViewState): Bridge.DetailModelStateData { - oldState = viewState return Bridge.DetailModelStateData().apply { state = viewState.toModelState() is_loading = viewState is Loading data = (viewState as? Loaded)?.snippet?.toModelData() oldHash = oldState?.hashCode()?.toLong() newHash = viewState.hashCode().toLong() + }.also { + oldState = viewState } } private fun getEvent(detailEvent: DetailEvent): Bridge.DetailModelEventData { - oldEvent = detailEvent return Bridge.DetailModelEventData().apply { event = detailEvent.toModelEvent() value = (detailEvent as? Saved)?.snippetId oldHash = oldEvent?.hashCode()?.toLong() newHash = detailEvent.hashCode().toLong() + }.also { + oldEvent = detailEvent } } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt index 8f1526b..71fa42a 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt @@ -48,23 +48,25 @@ class MainModelPlugin : ModelPlugin(), Bridge.MainModelB } private fun getState(viewState: MainViewState): Bridge.MainModelStateData { - oldState = viewState return Bridge.MainModelStateData().apply { state = viewState.toModelState() is_loading = viewState is Loading data = (viewState as? Loaded)?.snippets?.toModelData() oldHash = oldState?.hashCode()?.toLong() newHash = viewState.hashCode().toLong() + }.also { + oldState = viewState } } private fun getEvent(viewEvent: MainEvent): Bridge.MainModelEventData { - oldEvent = viewEvent return Bridge.MainModelEventData().apply { event = viewEvent.toModelEvent() message = (viewEvent as? Alert)?.message oldHash = oldEvent?.hashCode()?.toLong() newHash = viewEvent.hashCode().toLong() + }.also { + oldEvent = viewEvent } } diff --git a/flutter_module/lib/presentation/screens/main_screen.dart b/flutter_module/lib/presentation/screens/main_screen.dart index 6e431b3..bd8cad7 100644 --- a/flutter_module/lib/presentation/screens/main_screen.dart +++ b/flutter_module/lib/presentation/screens/main_screen.dart @@ -11,7 +11,6 @@ import 'package:flutter_module/presentation/widgets/view_state_wrapper.dart'; import 'package:flutter_module/utils/extensions/state_extensions.dart'; import 'package:flutter_module/utils/hooks/use_navigator.dart'; import 'package:flutter_module/utils/hooks/use_observable_state_hook.dart'; -import 'package:flutter_module/utils/mock/mocks.dart'; import 'package:go_router/go_router.dart'; import 'package:go_router/src/state.dart'; import 'package:go_router_plus/go_router_plus.dart'; @@ -52,26 +51,34 @@ class _MainPage extends HookWidget { @override Widget build(BuildContext context) { + useNavigator([loginNavigator, detailsNavigator]); final state = useObservableState( MainModelStateData(), () => model.getState(), (current, newState) => (current as MainModelStateData).equals(newState), - ); - - final data = state.value; + ).value; // Event - final event = useState(MainModelEventData()); + final event = useObservableState( + MainModelEventData(), + () => model.getEvent(), + (current, newState) => (current as MainModelEventData).equals(newState), + ).value; - useNavigator([loginNavigator, detailsNavigator]); useEffect(() { model.initState(); }, []); - if (event.value.event == MainModelEvent.logout) { + if (event.event == MainModelEvent.logout) { loginNavigator.logout(); } + print("State old hash = ${state.oldHash}"); + print("State new hash = ${state.newHash}"); + + print("Event old hash = ${event.oldHash}"); + print("Event new hash = ${event.newHash}"); + return Scaffold( appBar: AppBar( leading: IconButton( @@ -82,9 +89,9 @@ class _MainPage extends HookWidget { ), backgroundColor: ColorStyles.pageBackground(), body: ViewStateWrapper>( - isLoading: data.state == ModelState.loading || data.is_loading == true, - error: data.error, - data: data.data?.cast(), + isLoading: state.state == ModelState.loading || state.is_loading == true, + error: state.error, + data: state.data?.cast(), builder: (_, snippets) { return _MainPageData( navigator: detailsNavigator, diff --git a/flutter_module/lib/utils/extensions/state_extensions.dart b/flutter_module/lib/utils/extensions/state_extensions.dart index 50b205b..1fe0c21 100644 --- a/flutter_module/lib/utils/extensions/state_extensions.dart +++ b/flutter_module/lib/utils/extensions/state_extensions.dart @@ -3,27 +3,27 @@ import 'package:flutter_module/model/main_model.dart'; extension MainModelStateDataExtension on MainModelStateData { bool equals(Object other) { if (other is! MainModelStateData) return false; - return other.oldHash != other.newHash; + return other.oldHash == other.newHash; } } extension MainModelEventDataExtension on MainModelEventData { bool equals(Object other) { if (other is! MainModelEventData) return false; - return other.oldHash != other.newHash; + return other.oldHash == other.newHash; } } extension DetailModelStateDataExtension on DetailModelStateData { bool equals(Object other) { if (other is! DetailModelStateData) return false; - return other.oldHash != other.newHash; + return other.oldHash == other.newHash; } } extension DetailModelEventDataExtension on DetailModelEventData { bool equals(Object other) { if (other is! DetailModelEventData) return false; - return other.oldHash != other.newHash; + return other.oldHash == other.newHash; } } From 123ce6f87433fdf1bef13edc1ebe0a659e1082e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Wed, 7 Dec 2022 22:47:49 +0100 Subject: [PATCH 17/66] Completed future builder for code text view --- .../lib/presentation/screens/main_screen.dart | 6 -- .../presentation/widgets/code_text_view.dart | 36 +++++---- .../widgets/snippet_list_item.dart | 3 +- .../extensions/collection_extensions.dart | 76 ++++++++++--------- .../utils/collection_extensions_test.dart | 22 +++--- 5 files changed, 73 insertions(+), 70 deletions(-) diff --git a/flutter_module/lib/presentation/screens/main_screen.dart b/flutter_module/lib/presentation/screens/main_screen.dart index bd8cad7..14fb021 100644 --- a/flutter_module/lib/presentation/screens/main_screen.dart +++ b/flutter_module/lib/presentation/screens/main_screen.dart @@ -73,12 +73,6 @@ class _MainPage extends HookWidget { loginNavigator.logout(); } - print("State old hash = ${state.oldHash}"); - print("State new hash = ${state.newHash}"); - - print("Event old hash = ${event.oldHash}"); - print("Event new hash = ${event.newHash}"); - return Scaffold( appBar: AppBar( leading: IconButton( diff --git a/flutter_module/lib/presentation/widgets/code_text_view.dart b/flutter_module/lib/presentation/widgets/code_text_view.dart index 70e69f0..d831259 100644 --- a/flutter_module/lib/presentation/widgets/code_text_view.dart +++ b/flutter_module/lib/presentation/widgets/code_text_view.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_module/model/main_model.dart'; import 'package:flutter_module/presentation/styles/text_styles.dart'; import 'package:flutter_module/utils/extensions/collection_extensions.dart'; @@ -42,32 +43,35 @@ class CodeTextView extends StatelessWidget { final GestureTapCallback? onTap; const CodeTextView.preview({ - Key? key, + super.key, required this.code, this.tokens, this.options, this.onTap, - }) : maxLines = 5, - super(key: key); + }) : maxLines = 5; @override Widget build(BuildContext context) { final maxLinesOrAll = maxLines ?? splitter.convert(code).length; - return SelectableText.rich( - TextSpan( - children: tokens.toSpans( - code.lines(maxLinesOrAll), - TextStyles.code(code).style!, - ), + return FutureBuilder( + initialData: const [], + future: tokens.toSpans( + code.lines(maxLinesOrAll), + TextStyles.code(code).style!, ), - minLines: 1, - maxLines: maxLinesOrAll, - onTap: onTap, - toolbarOptions: options?.toolbarOptions, - showCursor: options?.showCursor ?? false, - enableInteractiveSelection: false, - scrollPhysics: const NeverScrollableScrollPhysics(), + builder: (_, value) { + return SelectableText.rich( + TextSpan(children: value.requireData), + minLines: 1, + maxLines: maxLinesOrAll, + onTap: () {}, + toolbarOptions: options?.toolbarOptions, + showCursor: options?.showCursor ?? false, + enableInteractiveSelection: false, + scrollPhysics: const NeverScrollableScrollPhysics(), + ); + }, ); } } diff --git a/flutter_module/lib/presentation/widgets/snippet_list_item.dart b/flutter_module/lib/presentation/widgets/snippet_list_item.dart index 88fb028..25472c7 100644 --- a/flutter_module/lib/presentation/widgets/snippet_list_item.dart +++ b/flutter_module/lib/presentation/widgets/snippet_list_item.dart @@ -44,7 +44,8 @@ class SnippetListTile extends HookWidget { horizontal: Dimens.l, ), child: Expanded( - child: CodeTextView.preview( + child: + CodeTextView.preview( code: snippet.code?.raw ?? "", tokens: snippet.code?.tokens, ), diff --git a/flutter_module/lib/utils/extensions/collection_extensions.dart b/flutter_module/lib/utils/extensions/collection_extensions.dart index 3ea6cff..161f682 100644 --- a/flutter_module/lib/utils/extensions/collection_extensions.dart +++ b/flutter_module/lib/utils/extensions/collection_extensions.dart @@ -24,51 +24,55 @@ class TokenSpan with EquatableMixin { } extension SyntaxSpanExtension on List? { - List toSpans(String text, TextStyle baseStyle) { - if (this == null) return [TextSpan(text: text, style: baseStyle)]; - if (this!.isEmpty) return [TextSpan(text: text, style: baseStyle)]; + Future> toSpans(String text, TextStyle baseStyle) { + print("toSpans"); + return Future.microtask(() { + if (this == null) return [TextSpan(text: text, style: baseStyle)]; + if (this!.isEmpty) return [TextSpan(text: text, style: baseStyle)]; - final syntaxTokens = this!.whereType(); + final syntaxTokens = this!.whereType(); - final tokens = syntaxTokens.map( - (token) => TokenSpan( - value: text.substring(token.start!, token.end!), - color: Color(token.color!), - start: token.start!, - end: token.end!, - ), - ); - - final uniqueTokens = tokens.where((tested) { - final isDuplicated = tokens.any( - (span) => - tested != span && - span.value.contains(tested.value) && - tested.start >= span.start && - tested.end <= span.end, + final tokens = syntaxTokens.map( + (token) => TokenSpan( + value: text.substring(token.start!, token.end!), + color: Color(token.color!), + start: token.start!, + end: token.end!, + ), ); - return !isDuplicated; - }); + final uniqueTokens = tokens.where((tested) { + final isDuplicated = tokens.any( + (span) => + tested != span && + span.value.contains(tested.value) && + tested.start >= span.start && + tested.end <= span.end, + ); - final tokenIndices = - uniqueTokens.expand((token) => [token.start, token.end]).toList(); + return !isDuplicated; + }); - final phrases = - tokenIndices.isNotEmpty ? text.splitByIndices(tokenIndices) : [text]; + final tokenIndices = + uniqueTokens.expand((token) => [token.start, token.end]).toList(); - return phrases.map((phrase) { - TextStyle style = baseStyle; + final phrases = + tokenIndices.isNotEmpty ? text.splitByIndices(tokenIndices) : [text]; - final foundToken = uniqueTokens.firstWhereOrNull( - (span) => span.value == phrase, - ); + return phrases.map((phrase) { + TextStyle style = baseStyle; - if (foundToken != null) { - style = TextStyles.code(text).style!.copyWith(color: foundToken.color); - } + final foundToken = uniqueTokens.firstWhereOrNull( + (span) => span.value == phrase, + ); - return TextSpan(text: phrase, style: style); - }).toList(); + if (foundToken != null) { + style = + TextStyles.code(text).style!.copyWith(color: foundToken.color); + } + + return TextSpan(text: phrase, style: style); + }).toList(); + }); } } diff --git a/flutter_module/test/utils/collection_extensions_test.dart b/flutter_module/test/utils/collection_extensions_test.dart index c8ae3f9..f0e0d26 100644 --- a/flutter_module/test/utils/collection_extensions_test.dart +++ b/flutter_module/test/utils/collection_extensions_test.dart @@ -7,48 +7,48 @@ void main() { const style = TextStyle(color: Colors.black); group('toSpans', () { - test('Returns list with single span for null or empty collection', () { + test('Returns list with single span for null or empty collection', () async { // null List? nullList = null; - expect(nullList.toSpans("", style).length, 1); + expect((await nullList.toSpans("", style)).length, 1); // empty List? emptyList = List.empty(); - expect(emptyList.toSpans("", style).length, 1); + expect((await emptyList.toSpans("", style)).length, 1); }); - test('Returns spans for whole phrase', () { + test('Returns spans for whole phrase', () async { const code = "class Abcd { }"; List? tokens = [SyntaxToken(start: 0, end: 4, color: 0)]; - final result = tokens.toSpans(code, style); + final result = await tokens.toSpans(code, style); expect(result.length, 2); }); - test('Returns base span for non syntax phrases', () { + test('Returns base span for non syntax phrases', () async { const code = "class Abcd { }"; List? tokens = [SyntaxToken(start: 0, end: 4, color: 0)]; - final result = tokens.toSpans(code, style); + final result = await tokens.toSpans(code, style); expect(result.length, 2); expect(result.last.style!.color!.value, style.color!.value); }); - test('Returns syntax spans for syntax phrases', () { + test('Returns syntax spans for syntax phrases', () async { const code = "class Abcd { }"; List? tokens = [ null, SyntaxToken(start: 0, end: 4, color: Colors.red.value) ]; - final result = tokens.toSpans(code, style); + final result = await tokens.toSpans(code, style); expect(result.length, 2); expect(result.first.style!.color!.value, Colors.red.value); }); - test('Larger syntax span overlaps smaller', () { + test('Larger syntax span overlaps smaller', () async { const code = "Code = 'class Abcd { }'"; List? tokens = [ @@ -56,7 +56,7 @@ void main() { SyntaxToken(start: 7, end: 23, color: Colors.green.value), ]; - final result = tokens.toSpans(code, style); + final result = await tokens.toSpans(code, style); print(result); From c9f348b331d9b78f6744d27952262be282a31726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Fri, 9 Dec 2022 08:13:41 +0100 Subject: [PATCH 18/66] Added not empty field validation --- .../presentation/screens/login_screen.dart | 27 +++++++++++--- .../widgets/rounded_action_button.dart | 35 +++++++++++-------- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/flutter_module/lib/presentation/screens/login_screen.dart b/flutter_module/lib/presentation/screens/login_screen.dart index 1d1fdf5..0043989 100644 --- a/flutter_module/lib/presentation/screens/login_screen.dart +++ b/flutter_module/lib/presentation/screens/login_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_module/generated/assets.dart'; @@ -26,7 +28,10 @@ class LoginScreen extends NamedScreen implements InitialScreen, GuestScreen { } class _MainPage extends HookWidget { - const _MainPage({Key? key, required this.navigator}) : super(key: key); + const _MainPage({ + Key? key, + required this.navigator, + }) : super(key: key); final LoginNavigator navigator; @@ -34,8 +39,12 @@ class _MainPage extends HookWidget { Widget build(BuildContext context) { useNavigator([navigator]); - final login = useState(""); - final password = useState(""); + // TODO Debug why useState not rebuilds view + final stream = useMemoized(() => StreamController(), []); + useStream(stream.stream); + + final login = useState(''); + final password = useState(''); return Scaffold( body: SafeArea( @@ -53,14 +62,22 @@ class _MainPage extends HookWidget { const TextStyles.secondary('Snip your favorite code'), PaddingStyles.regular( LoginInputCard( - onLoginChanged: (value) => login.value = value, - onPasswordChanged: (value) => password.value = value, + onLoginChanged: (loginValue) { + login.value = loginValue; + stream.add(loginValue); + }, + onPasswordChanged: (passwordValue) { + password.value = passwordValue; + stream.add(passwordValue); + }, ), ), Center( child: RoundedActionButton( icon: Icons.check_circle, title: 'Login', + enabled: + login.value.isNotEmpty && password.value.isNotEmpty, onPressed: navigator.login, ), ), diff --git a/flutter_module/lib/presentation/widgets/rounded_action_button.dart b/flutter_module/lib/presentation/widgets/rounded_action_button.dart index cbc751b..e0bcd19 100644 --- a/flutter_module/lib/presentation/widgets/rounded_action_button.dart +++ b/flutter_module/lib/presentation/widgets/rounded_action_button.dart @@ -10,30 +10,35 @@ class RoundedActionButton extends StatelessWidget { Key? key, required this.icon, required this.title, - required this.onPressed, + this.enabled = true, + this.onPressed, }) : super(key: key); final IconData icon; final String title; - final GestureTapCallback onPressed; + final bool enabled; + final GestureTapCallback? onPressed; @override Widget build(BuildContext context) { return SurfaceStyles.roundedFloatingCard( - onTap: onPressed, + onTap: enabled ? () => onPressed?.call() : null, child: PaddingStyles.small( - Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: Dimens.m), - Icon(icon, color: ColorStyles.accent()), - const SizedBox(width: Dimens.m), - TextStyles.regular( - title.toUpperCase(), - color: ColorStyles.accent(), - ), - const SizedBox(width: Dimens.m), - ], + Opacity( + opacity: enabled ? 1.0 : 0.3, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: Dimens.m), + Icon(icon, color: ColorStyles.accent()), + const SizedBox(width: Dimens.m), + TextStyles.regular( + title.toUpperCase(), + color: ColorStyles.accent(), + ), + const SizedBox(width: Dimens.m), + ], + ), ), ), ); From 4a8fb9c2918f6eeb2932edeeab281185049387d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Sat, 10 Dec 2022 12:56:54 +0100 Subject: [PATCH 19/66] Added field validation for login fields --- flutter_module/bridge/main_model.dart | 16 ++++++++ .../presentation/screens/login_screen.dart | 17 +++++--- .../lib/presentation/styles/dimens.dart | 2 +- .../widgets/login_input_card.dart | 41 ++++++++++++++++--- .../widgets/text_input_field.dart | 41 +++++++++++++++---- flutter_module/pubspec.lock | 7 ++++ flutter_module/pubspec.yaml | 1 + 7 files changed, 105 insertions(+), 20 deletions(-) diff --git a/flutter_module/bridge/main_model.dart b/flutter_module/bridge/main_model.dart index d6f8bbc..cbc1bda 100644 --- a/flutter_module/bridge/main_model.dart +++ b/flutter_module/bridge/main_model.dart @@ -107,6 +107,8 @@ enum MainModelEvent { none, alert, logout } enum DetailModelEvent { none, saved } +enum LoginModelEvent { none, logged } + class MainModelStateData { ModelState? state; bool? is_loading; @@ -139,6 +141,13 @@ class DetailModelEventData { int? newHash; } +class LoginModelEventData { + LoginModelEvent? event; + String? value; + int? oldHash; + int? newHash; +} + // Api @HostApi() @@ -182,4 +191,11 @@ abstract class DetailModelBridge { void copyToClipboard(); void share(); +} + +@HostApi() +abstract class LoginModelBridge { + LoginModelEventData getEvent(); + + void loginOrRegister(String email, String password); } \ No newline at end of file diff --git a/flutter_module/lib/presentation/screens/login_screen.dart b/flutter_module/lib/presentation/screens/login_screen.dart index 0043989..f6bd7c5 100644 --- a/flutter_module/lib/presentation/screens/login_screen.dart +++ b/flutter_module/lib/presentation/screens/login_screen.dart @@ -43,8 +43,9 @@ class _MainPage extends HookWidget { final stream = useMemoized(() => StreamController(), []); useStream(stream.stream); - final login = useState(''); + final email = useState(''); final password = useState(''); + final validationCorrect = useState(false); return Scaffold( body: SafeArea( @@ -62,22 +63,26 @@ class _MainPage extends HookWidget { const TextStyles.secondary('Snip your favorite code'), PaddingStyles.regular( LoginInputCard( - onLoginChanged: (loginValue) { - login.value = loginValue; - stream.add(loginValue); + onEmailChanged: (emailValue) { + email.value = emailValue; + stream.add(emailValue); }, onPasswordChanged: (passwordValue) { password.value = passwordValue; stream.add(passwordValue); }, + onValidChanged: (isValid) { + validationCorrect.value = isValid; + }, ), ), Center( child: RoundedActionButton( icon: Icons.check_circle, title: 'Login', - enabled: - login.value.isNotEmpty && password.value.isNotEmpty, + enabled: validationCorrect.value && + email.value.isNotEmpty && + password.value.isNotEmpty, onPressed: navigator.login, ), ), diff --git a/flutter_module/lib/presentation/styles/dimens.dart b/flutter_module/lib/presentation/styles/dimens.dart index a59b6b7..3514a0c 100644 --- a/flutter_module/lib/presentation/styles/dimens.dart +++ b/flutter_module/lib/presentation/styles/dimens.dart @@ -5,5 +5,5 @@ class Dimens { static const xl = 24.0; static const xxl = 32.0; - static const inputBorderWidth = 0.5; + static const inputBorderWidth = 1.0; } \ No newline at end of file diff --git a/flutter_module/lib/presentation/widgets/login_input_card.dart b/flutter_module/lib/presentation/widgets/login_input_card.dart index e6ac7d2..f492c19 100644 --- a/flutter_module/lib/presentation/widgets/login_input_card.dart +++ b/flutter_module/lib/presentation/widgets/login_input_card.dart @@ -1,37 +1,68 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_module/presentation/styles/dimens.dart'; import 'package:flutter_module/presentation/styles/padding_styles.dart'; import 'package:flutter_module/presentation/styles/surface_styles.dart'; import 'package:flutter_module/presentation/widgets/text_input_field.dart'; +import 'package:flutter_module/utils/hooks/use_same_state.dart'; +import 'package:validators/validators.dart'; -class LoginInputCard extends StatelessWidget { +class LoginInputCard extends HookWidget { const LoginInputCard({ Key? key, - required this.onLoginChanged, + required this.onEmailChanged, required this.onPasswordChanged, + this.onValidChanged, }) : super(key: key); - final TextInputCallback onLoginChanged; + final TextInputCallback onEmailChanged; final TextInputCallback onPasswordChanged; + final Function(bool)? onValidChanged; @override Widget build(BuildContext context) { + final emailErrorTextState = useSameState(null); + final passwordErrorTextState = useSameState(null); + + final emailError = emailErrorTextState.value; + final passwordError = passwordErrorTextState.value; + + useEffect(() { + // TODO Correct button enabling after validation + final isValid = emailError == null && passwordError == null; + print("is valid = $isValid"); + onValidChanged?.call(isValid); + }, [emailError, passwordError]); + return SurfaceStyles.snippetCard( child: PaddingStyles.regular( Column( children: [ const SizedBox(height: Dimens.l), TextInputField( - label: 'Login', - onChanged: onLoginChanged, + label: 'Email', + onChanged: onEmailChanged, + validator: (input) { + final error = + isEmail(input ?? '') ? null : 'Provide valid email phrase'; + emailErrorTextState.value = error; + return error; + }, ), const SizedBox(height: Dimens.xl), TextInputField( label: 'Password', onChanged: onPasswordChanged, isPassword: true, + validator: (input) { + final error = input.length >= 8 + ? null + : 'Password must have at least 8 characters'; + passwordErrorTextState.value = error; + return error; + }, ), const SizedBox(height: Dimens.l), ], diff --git a/flutter_module/lib/presentation/widgets/text_input_field.dart b/flutter_module/lib/presentation/widgets/text_input_field.dart index 1dbb55e..da03ef8 100644 --- a/flutter_module/lib/presentation/widgets/text_input_field.dart +++ b/flutter_module/lib/presentation/widgets/text_input_field.dart @@ -2,41 +2,66 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_module/presentation/styles/color_styles.dart'; import 'package:flutter_module/presentation/styles/dimens.dart'; +import 'package:flutter_module/utils/hooks/use_same_state.dart'; typedef TextInputCallback = Function(String value); class TextInputField extends HookWidget { - const TextInputField({ + TextInputField({ Key? key, required this.label, this.isPassword = false, this.onChanged, + this.validator, }) : super(key: key); final String label; final bool isPassword; final TextInputCallback? onChanged; + final FormFieldValidator? validator; @override Widget build(BuildContext context) { final controller = useTextEditingController(); - final value = useValueListenable(controller); final shouldShow = useState(false); + final error = useState(null); final passwordVisible = shouldShow.value; useEffect(() { - onChanged?.call(value.text); - return null; - }, [value.text]); + controller.addListener(() { + onChanged?.call(controller.text); + }); + }, []); - return TextField( + useEffect(() { + error.value = + controller.text.isNotEmpty ? validator?.call(controller.text) : null; + }, [controller.text]); + + return TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, obscureText: isPassword && !shouldShow.value, controller: controller, cursorColor: ColorStyles.accent(), decoration: InputDecoration( + errorText: error.value, + focusedErrorBorder: const OutlineInputBorder( + borderSide: BorderSide( + color: Colors.red, + width: Dimens.inputBorderWidth, + ), + ), + errorBorder: const OutlineInputBorder( + borderSide: BorderSide( + color: Colors.red, + width: Dimens.inputBorderWidth, + ), + ), labelText: label, - floatingLabelStyle: TextStyle(color: ColorStyles.accent()), - enabledBorder: OutlineInputBorder( + floatingLabelStyle: TextStyle( + color: error.value == null ? ColorStyles.accent() : Colors.red, + ), + enabledBorder: const OutlineInputBorder( borderSide: BorderSide( color: Colors.grey, width: Dimens.inputBorderWidth, diff --git a/flutter_module/pubspec.lock b/flutter_module/pubspec.lock index 6741a1d..bad356b 100644 --- a/flutter_module/pubspec.lock +++ b/flutter_module/pubspec.lock @@ -497,6 +497,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.1" + validators: + dependency: "direct main" + description: + name: validators + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" vector_math: dependency: transitive description: diff --git a/flutter_module/pubspec.yaml b/flutter_module/pubspec.yaml index de069a8..3a95aa0 100644 --- a/flutter_module/pubspec.yaml +++ b/flutter_module/pubspec.yaml @@ -28,6 +28,7 @@ dependencies: test: ^1.21.4 go_router: ^5.1.5 go_router_plus: ^2.0.0 + validators: ^3.0.0 dev_dependencies: flutter_test: From 5562cd1355d70edf89a88c40b505fa7d27ad53b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Sun, 11 Dec 2022 13:48:45 +0100 Subject: [PATCH 20/66] Created simple login flow --- .../snipmeandroid/bridge/Bridge.java | 179 ++++++++++++++++++ .../snipmeandroid/bridge/login/LoginModel.kt | 95 ++++++++++ .../bridge/login/LoginModelPlugin.kt | 44 +++++ .../snipmeandroid/di/ModelModule.kt | 2 + .../snipmeandroid/ui/login/LoginFragment.kt | 2 - .../snipmeandroid/ui/login/LoginViewModel.kt | 12 +- .../snipmeandroid/ui/main/MainActivity.kt | 2 + flutter_module/bridge/main_model.dart | 3 +- flutter_module/lib/main.dart | 6 +- flutter_module/lib/model/main_model.dart | 142 ++++++++++++++ .../presentation/screens/login_screen.dart | 33 +++- .../utils/extensions/state_extensions.dart | 7 + 12 files changed, 514 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModel.kt create mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModelPlugin.kt diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java index 2b056d0..7b6b1d4 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java @@ -130,6 +130,16 @@ private DetailModelEvent(final int index) { } } + public enum LoginModelEvent { + NONE(0), + LOGGED(1); + + private int index; + private LoginModelEvent(final int index) { + this.index = index; + } + } + /** Generated class from Pigeon that represents data sent in messages. */ public static class Snippet { private @Nullable String uuid; @@ -954,6 +964,69 @@ public static final class Builder { return pigeonResult; } } + + /** Generated class from Pigeon that represents data sent in messages. */ + public static class LoginModelEventData { + private @Nullable LoginModelEvent event; + public @Nullable LoginModelEvent getEvent() { return event; } + public void setEvent(@Nullable LoginModelEvent setterArg) { + this.event = setterArg; + } + + private @Nullable Long oldHash; + public @Nullable Long getOldHash() { return oldHash; } + public void setOldHash(@Nullable Long setterArg) { + this.oldHash = setterArg; + } + + private @Nullable Long newHash; + public @Nullable Long getNewHash() { return newHash; } + public void setNewHash(@Nullable Long setterArg) { + this.newHash = setterArg; + } + + public static final class Builder { + private @Nullable LoginModelEvent event; + public @NonNull Builder setEvent(@Nullable LoginModelEvent setterArg) { + this.event = setterArg; + return this; + } + private @Nullable Long oldHash; + public @NonNull Builder setOldHash(@Nullable Long setterArg) { + this.oldHash = setterArg; + return this; + } + private @Nullable Long newHash; + public @NonNull Builder setNewHash(@Nullable Long setterArg) { + this.newHash = setterArg; + return this; + } + public @NonNull LoginModelEventData build() { + LoginModelEventData pigeonReturn = new LoginModelEventData(); + pigeonReturn.setEvent(event); + pigeonReturn.setOldHash(oldHash); + pigeonReturn.setNewHash(newHash); + return pigeonReturn; + } + } + @NonNull Map toMap() { + Map toMapResult = new HashMap<>(); + toMapResult.put("event", event == null ? null : event.index); + toMapResult.put("oldHash", oldHash); + toMapResult.put("newHash", newHash); + return toMapResult; + } + static @NonNull LoginModelEventData fromMap(@NonNull Map map) { + LoginModelEventData pigeonResult = new LoginModelEventData(); + Object event = map.get("event"); + pigeonResult.setEvent(event == null ? null : LoginModelEvent.values()[(int)event]); + Object oldHash = map.get("oldHash"); + pigeonResult.setOldHash((oldHash == null) ? null : ((oldHash instanceof Integer) ? (Integer)oldHash : (Long)oldHash)); + Object newHash = map.get("newHash"); + pigeonResult.setNewHash((newHash == null) ? null : ((newHash instanceof Integer) ? (Integer)newHash : (Long)newHash)); + return pigeonResult; + } + } private static class MainModelBridgeCodec extends StandardMessageCodec { public static final MainModelBridgeCodec INSTANCE = new MainModelBridgeCodec(); private MainModelBridgeCodec() {} @@ -1454,6 +1527,112 @@ static void setup(BinaryMessenger binaryMessenger, DetailModelBridge api) { } } } + private static class LoginModelBridgeCodec extends StandardMessageCodec { + public static final LoginModelBridgeCodec INSTANCE = new LoginModelBridgeCodec(); + private LoginModelBridgeCodec() {} + @Override + protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) { + switch (type) { + case (byte)128: + return LoginModelEventData.fromMap((Map) readValue(buffer)); + + default: + return super.readValueOfType(type, buffer); + + } + } + @Override + protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) { + if (value instanceof LoginModelEventData) { + stream.write(128); + writeValue(stream, ((LoginModelEventData) value).toMap()); + } else +{ + super.writeValue(stream, value); + } + } + } + + /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ + public interface LoginModelBridge { + @NonNull LoginModelEventData getEvent(); + void loginOrRegister(@NonNull String email, @NonNull String password); + void resetEvent(); + + /** The codec used by LoginModelBridge. */ + static MessageCodec getCodec() { + return LoginModelBridgeCodec.INSTANCE; } + /**Sets up an instance of `LoginModelBridge` to handle messages through the `binaryMessenger`. */ + static void setup(BinaryMessenger binaryMessenger, LoginModelBridge api) { + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.LoginModelBridge.getEvent", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + LoginModelEventData output = api.getEvent(); + wrapped.put("result", output); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.LoginModelBridge.loginOrRegister", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList)message; + assert args != null; + String emailArg = (String)args.get(0); + if (emailArg == null) { + throw new NullPointerException("emailArg unexpectedly null."); + } + String passwordArg = (String)args.get(1); + if (passwordArg == null) { + throw new NullPointerException("passwordArg unexpectedly null."); + } + api.loginOrRegister(emailArg, passwordArg); + wrapped.put("result", null); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.LoginModelBridge.resetEvent", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + api.resetEvent(); + wrapped.put("result", null); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + } + } @NonNull private static Map wrapError(@NonNull Throwable exception) { Map errorMap = new HashMap<>(); errorMap.put("message", exception.toString()); diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModel.kt new file mode 100644 index 0000000..adf73ce --- /dev/null +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModel.kt @@ -0,0 +1,95 @@ +package pl.tkadziolka.snipmeandroid.bridge.login + +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.disposables.Disposable +import io.reactivex.rxkotlin.plusAssign +import io.reactivex.rxkotlin.subscribeBy +import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.flow.MutableStateFlow +import pl.tkadziolka.snipmeandroid.domain.auth.LoginInteractor +import pl.tkadziolka.snipmeandroid.domain.error.exception.* +import pl.tkadziolka.snipmeandroid.domain.message.ErrorMessages +import pl.tkadziolka.snipmeandroid.ui.error.ErrorParsable +import pl.tkadziolka.snipmeandroid.ui.login.* +import pl.tkadziolka.snipmeandroid.util.extension.inProgress +import timber.log.Timber + +class LoginModel( + private val errorMessages: ErrorMessages, + private val interactor: LoginInteractor, +): ErrorParsable { + private val disposables = CompositeDisposable() + private var identifyDisposable: Disposable? = null + private var loginDisposable: Disposable? = null + private var registerDisposable: Disposable? = null + + private val mutableEvent = MutableStateFlow(Idle) + val event = mutableEvent + + override fun parseError(throwable: Throwable) { + when (throwable) { + is ConnectionException -> setEvent(Error(errorMessages.parse(throwable))) + is ContentNotFoundException -> setEvent(Error(errorMessages.parse(throwable))) + is ForbiddenActionException -> setEvent(Error(errorMessages.alreadyRegistered)) + is NetworkNotAvailableException -> setEvent(Error(errorMessages.parse(throwable))) + is NotAuthorizedException -> setEvent(Error(errorMessages.parse(throwable))) + is RemoteException -> setEvent(Error(errorMessages.parse(throwable))) + is SessionExpiredException -> setEvent(Error(errorMessages.parse(throwable))) + else -> setEvent(Error(errorMessages.parse(throwable))) + } + } + + fun loginOrRegister(email: String, password: String) { + if (identifyDisposable.inProgress()) return + + identifyDisposable = interactor.identify(email) + .subscribeOn(Schedulers.io()) + .subscribeBy( + onSuccess = { identified -> publishIdentified(email, password, identified) }, + onError = { + Timber.d("Couldn't identify user = $email, error = $it") + parseError(it) + } + ).also { disposables += it } + } + + fun login(email: String, password: String) { + if (loginDisposable.inProgress()) return + + loginDisposable = interactor.login(email, password) + .subscribeOn(Schedulers.io()) + .subscribeBy( + onComplete = { setEvent(Logged) }, + onError = { + Timber.d("Couldn't login user = $email, error = $it") + parseError(it) + } + ).also { disposables += it } + } + + fun register(email: String, password: String) { + if (registerDisposable.inProgress()) return + + registerDisposable = interactor.register(email, password, email) + .subscribeOn(Schedulers.io()) + .subscribeBy( + onComplete = { setEvent(Logged) }, + onError = { + Timber.d("Couldn't register user = $email, error = $it") + parseError(it) + } + ).also { disposables += it } + } + + private fun publishIdentified(email: String, password: String, identified: Boolean) { + if (identified) { + login(email, password) + } else { + register(email, password) + } + } + + private fun setEvent(event: LoginEvent) { + mutableEvent.value = event + } +} diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModelPlugin.kt new file mode 100644 index 0000000..b85eebd --- /dev/null +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModelPlugin.kt @@ -0,0 +1,44 @@ +package pl.tkadziolka.snipmeandroid.bridge.login + +import io.flutter.plugin.common.BinaryMessenger +import org.koin.core.component.inject +import pl.tkadziolka.snipmeandroid.bridge.Bridge +import pl.tkadziolka.snipmeandroid.bridge.ModelPlugin +import pl.tkadziolka.snipmeandroid.ui.login.Idle +import pl.tkadziolka.snipmeandroid.ui.login.Logged +import pl.tkadziolka.snipmeandroid.ui.login.LoginEvent + +class LoginModelPlugin : ModelPlugin(), Bridge.LoginModelBridge { + private var oldEvent: LoginEvent? = null + private val model: LoginModel by inject() + + override fun getEvent(): Bridge.LoginModelEventData = getModelEvent(model.event.value) + + override fun resetEvent() { + model.event.value = Idle + } + + override fun onSetup(messenger: BinaryMessenger, bridge: Bridge.LoginModelBridge?) { + Bridge.LoginModelBridge.setup(messenger, bridge) + } + + override fun loginOrRegister(email: String, password: String) { + model.loginOrRegister(email, password) + } + + private fun getModelEvent(loginEvent: LoginEvent): Bridge.LoginModelEventData { + return Bridge.LoginModelEventData().apply { + event = loginEvent.toModelLoginEvent() + oldHash = oldEvent?.hashCode()?.toLong() ?: 0 + newHash = loginEvent.hashCode().toLong() + }.also { + oldEvent = loginEvent + } + } + + private fun LoginEvent.toModelLoginEvent() = + when (this) { + Logged -> Bridge.LoginModelEvent.LOGGED + else -> Bridge.LoginModelEvent.NONE + } +} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt index 605058c..0ecdd1b 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt @@ -2,11 +2,13 @@ package pl.tkadziolka.snipmeandroid.di import org.koin.dsl.module import pl.tkadziolka.snipmeandroid.bridge.detail.DetailModel +import pl.tkadziolka.snipmeandroid.bridge.login.LoginModel import pl.tkadziolka.snipmeandroid.bridge.main.MainModel import pl.tkadziolka.snipmeandroid.bridge.session.SessionModel internal val modelModule = module { single { SessionModel(get()) } + single { LoginModel(get(), get()) } single { MainModel(get(), get(), get(), get(), get(), get(), get()) } single { DetailModel(get(), get(), get(), get(), get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginFragment.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginFragment.kt index bf393ad..d5f913d 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginFragment.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginFragment.kt @@ -57,9 +57,7 @@ class LoginFragment : ViewModelFragment(LoginViewModel::class) { viewModel.event.observeNotNull(this) { event -> hideLoading() when (event) { - is Alert -> showToast(event.message) is Error -> viewModel.goToError(findNavController(), event.message) - is Dialog -> showDialog(event.message) } } } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginViewModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginViewModel.kt index 8b705fa..dcf3586 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginViewModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginViewModel.kt @@ -42,11 +42,11 @@ class LoginViewModel( when (throwable) { is ConnectionException -> setEvent(Error(errorMessages.parse(throwable))) is ContentNotFoundException -> setEvent(Error(errorMessages.parse(throwable))) - is ForbiddenActionException -> setEvent(Dialog(errorMessages.alreadyRegistered)) + is ForbiddenActionException -> setEvent(Error(errorMessages.alreadyRegistered)) is NetworkNotAvailableException -> setEvent(Error(errorMessages.parse(throwable))) - is NotAuthorizedException -> setEvent(Alert(errorMessages.parse(throwable))) + is NotAuthorizedException -> setEvent(Error(errorMessages.parse(throwable))) is RemoteException -> setEvent(Error(errorMessages.parse(throwable))) - is SessionExpiredException -> setEvent(Alert(errorMessages.parse(throwable))) + is SessionExpiredException -> setEvent(Error(errorMessages.parse(throwable))) else -> setEvent(Error(errorMessages.parse(throwable))) } } @@ -216,6 +216,6 @@ data class Completed( ) : LoginViewState() sealed class LoginEvent -data class Error(val message: String?) : LoginEvent() -data class Alert(val message: String) : LoginEvent() -data class Dialog(val message: String) : LoginEvent() \ No newline at end of file +object Idle : LoginEvent() +object Logged : LoginEvent() +data class Error(val message: String?) : LoginEvent() \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainActivity.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainActivity.kt index b3f7902..63b5753 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainActivity.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainActivity.kt @@ -9,6 +9,7 @@ import io.flutter.embedding.engine.dart.DartExecutor import pl.tkadziolka.snipmeandroid.bridge.main.MainModelPlugin import pl.tkadziolka.snipmeandroid.R import pl.tkadziolka.snipmeandroid.bridge.detail.DetailModelPlugin +import pl.tkadziolka.snipmeandroid.bridge.login.LoginModelPlugin class MainActivity : AppCompatActivity() { private lateinit var flutterEngine : FlutterEngine @@ -27,6 +28,7 @@ class MainActivity : AppCompatActivity() { DartExecutor.DartEntrypoint.createDefault() ) + flutterEngine.plugins.add(LoginModelPlugin()) flutterEngine.plugins.add(MainModelPlugin()) flutterEngine.plugins.add(DetailModelPlugin()) diff --git a/flutter_module/bridge/main_model.dart b/flutter_module/bridge/main_model.dart index cbc1bda..e0ed1b3 100644 --- a/flutter_module/bridge/main_model.dart +++ b/flutter_module/bridge/main_model.dart @@ -143,7 +143,6 @@ class DetailModelEventData { class LoginModelEventData { LoginModelEvent? event; - String? value; int? oldHash; int? newHash; } @@ -198,4 +197,6 @@ abstract class LoginModelBridge { LoginModelEventData getEvent(); void loginOrRegister(String email, String password); + + void resetEvent(); } \ No newline at end of file diff --git a/flutter_module/lib/main.dart b/flutter_module/lib/main.dart index 08479bf..5382aad 100644 --- a/flutter_module/lib/main.dart +++ b/flutter_module/lib/main.dart @@ -13,6 +13,7 @@ void main() => runApp(MyApp()); class MyApp extends StatelessWidget { MyApp({Key? key}) : super(key: key); + final loginModel = LoginModelBridge(); final mainModel = MainModelBridge(); final detailModel = DetailModelBridge(); @@ -22,7 +23,10 @@ class MyApp extends StatelessWidget { final detailsNavigator = DetailsNavigator(); final router = createGoRouter( screens: [ - LoginScreen(loginNavigator), + LoginScreen( + navigator: loginNavigator, + model: loginModel, + ), MainScreen( loginNavigator: loginNavigator, detailsNavigator: detailsNavigator, diff --git a/flutter_module/lib/model/main_model.dart b/flutter_module/lib/model/main_model.dart index 7eb0d05..42b5d9f 100644 --- a/flutter_module/lib/model/main_model.dart +++ b/flutter_module/lib/model/main_model.dart @@ -84,6 +84,11 @@ enum DetailModelEvent { saved, } +enum LoginModelEvent { + none, + logged, +} + class Snippet { Snippet({ this.uuid, @@ -448,6 +453,37 @@ class DetailModelEventData { } } +class LoginModelEventData { + LoginModelEventData({ + this.event, + this.oldHash, + this.newHash, + }); + + LoginModelEvent? event; + int? oldHash; + int? newHash; + + Object encode() { + final Map pigeonMap = {}; + pigeonMap['event'] = event?.index; + pigeonMap['oldHash'] = oldHash; + pigeonMap['newHash'] = newHash; + return pigeonMap; + } + + static LoginModelEventData decode(Object message) { + final Map pigeonMap = message as Map; + return LoginModelEventData( + event: pigeonMap['event'] != null + ? LoginModelEvent.values[pigeonMap['event']! as int] + : null, + oldHash: pigeonMap['oldHash'] as int?, + newHash: pigeonMap['newHash'] as int?, + ); + } +} + class _MainModelBridgeCodec extends StandardMessageCodec{ const _MainModelBridgeCodec(); @override @@ -980,3 +1016,109 @@ class DetailModelBridge { } } } + +class _LoginModelBridgeCodec extends StandardMessageCodec{ + const _LoginModelBridgeCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is LoginModelEventData) { + buffer.putUint8(128); + writeValue(buffer, value.encode()); + } else +{ + super.writeValue(buffer, value); + } + } + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 128: + return LoginModelEventData.decode(readValue(buffer)!); + + default: + return super.readValueOfType(type, buffer); + + } + } +} + +class LoginModelBridge { + /// Constructor for [LoginModelBridge]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + LoginModelBridge({BinaryMessenger? binaryMessenger}) : _binaryMessenger = binaryMessenger; + final BinaryMessenger? _binaryMessenger; + + static const MessageCodec codec = _LoginModelBridgeCodec(); + + Future getEvent() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.LoginModelBridge.getEvent', codec, binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send(null) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else if (replyMap['result'] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (replyMap['result'] as LoginModelEventData?)!; + } + } + + Future loginOrRegister(String arg_email, String arg_password) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.LoginModelBridge.loginOrRegister', codec, binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_email, arg_password]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future resetEvent() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.LoginModelBridge.resetEvent', codec, binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send(null) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } +} diff --git a/flutter_module/lib/presentation/screens/login_screen.dart b/flutter_module/lib/presentation/screens/login_screen.dart index f6bd7c5..9547b61 100644 --- a/flutter_module/lib/presentation/screens/login_screen.dart +++ b/flutter_module/lib/presentation/screens/login_screen.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_module/generated/assets.dart'; +import 'package:flutter_module/model/main_model.dart'; import 'package:flutter_module/presentation/navigation/login/login_navigator.dart'; import 'package:flutter_module/presentation/screens/named_screen.dart'; import 'package:flutter_module/presentation/styles/dimens.dart'; @@ -11,19 +12,28 @@ import 'package:flutter_module/presentation/styles/text_styles.dart'; import 'package:flutter_module/presentation/widgets/login_input_card.dart'; import 'package:flutter_module/presentation/widgets/no_overscroll_single_child_scroll_view.dart'; import 'package:flutter_module/presentation/widgets/rounded_action_button.dart'; +import 'package:flutter_module/utils/extensions/state_extensions.dart'; import 'package:flutter_module/utils/hooks/use_navigator.dart'; +import 'package:flutter_module/utils/hooks/use_observable_state_hook.dart'; import 'package:go_router/go_router.dart'; import 'package:go_router_plus/go_router_plus.dart'; class LoginScreen extends NamedScreen implements InitialScreen, GuestScreen { - LoginScreen(this.navigator) : super(name); + LoginScreen({ + required this.navigator, + required this.model, + }) : super(name); static String name = 'login'; final LoginNavigator navigator; + final LoginModelBridge model; @override Widget builder(BuildContext context, GoRouterState state) { - return _MainPage(navigator: navigator); + return _MainPage( + navigator: navigator, + model: model, + ); } } @@ -31,9 +41,11 @@ class _MainPage extends HookWidget { const _MainPage({ Key? key, required this.navigator, + required this.model, }) : super(key: key); final LoginNavigator navigator; + final LoginModelBridge model; @override Widget build(BuildContext context) { @@ -47,6 +59,19 @@ class _MainPage extends HookWidget { final password = useState(''); final validationCorrect = useState(false); + final event = useObservableState( + LoginModelEventData(), + () => model.getEvent(), + (current, newState) => (current as LoginModelEventData).equals(newState), + ).value; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (event.event == LoginModelEvent.logged) { + model.resetEvent(); + navigator.login(); + } + }); + return Scaffold( body: SafeArea( child: NoOverscrollSingleChildScrollView( @@ -83,7 +108,9 @@ class _MainPage extends HookWidget { enabled: validationCorrect.value && email.value.isNotEmpty && password.value.isNotEmpty, - onPressed: navigator.login, + onPressed: () { + model.loginOrRegister(email.value, password.value); + }, ), ), const SizedBox(height: Dimens.xxl), diff --git a/flutter_module/lib/utils/extensions/state_extensions.dart b/flutter_module/lib/utils/extensions/state_extensions.dart index 1fe0c21..91314fe 100644 --- a/flutter_module/lib/utils/extensions/state_extensions.dart +++ b/flutter_module/lib/utils/extensions/state_extensions.dart @@ -27,3 +27,10 @@ extension DetailModelEventDataExtension on DetailModelEventData { return other.oldHash == other.newHash; } } + +extension LoginModelEventDataExtension on LoginModelEventData { + bool equals(Object other) { + if (other is! LoginModelEventData) return false; + return other.oldHash == other.newHash; + } +} From 1faac0cc194f4c8b3685024c74f9d73a2d334ff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Sun, 11 Dec 2022 15:12:47 +0100 Subject: [PATCH 21/66] Handled on login redirection --- .../snipmeandroid/bridge/Bridge.java | 145 ++++++++++++++++++ .../snipmeandroid/bridge/login/LoginModel.kt | 32 +++- .../bridge/login/LoginModelPlugin.kt | 28 +++- .../bridge/main/MainModelPlugin.kt | 4 + .../snipmeandroid/di/ModelModule.kt | 2 +- .../snipmeandroid/ui/login/LoginViewModel.kt | 17 +- .../snipmeandroid/ui/main/MainViewModel.kt | 1 + flutter_module/bridge/main_model.dart | 13 ++ flutter_module/lib/model/main_model.dart | 113 ++++++++++++++ .../presentation/screens/login_screen.dart | 97 +++++++----- .../lib/presentation/screens/main_screen.dart | 11 +- .../utils/extensions/state_extensions.dart | 7 + 12 files changed, 412 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java index 7b6b1d4..0cc7219 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java @@ -965,6 +965,84 @@ public static final class Builder { } } + /** Generated class from Pigeon that represents data sent in messages. */ + public static class LoginModelStateData { + private @Nullable ModelState state; + public @Nullable ModelState getState() { return state; } + public void setState(@Nullable ModelState setterArg) { + this.state = setterArg; + } + + private @Nullable Boolean is_loading; + public @Nullable Boolean getIs_loading() { return is_loading; } + public void setIs_loading(@Nullable Boolean setterArg) { + this.is_loading = setterArg; + } + + private @Nullable Long oldHash; + public @Nullable Long getOldHash() { return oldHash; } + public void setOldHash(@Nullable Long setterArg) { + this.oldHash = setterArg; + } + + private @Nullable Long newHash; + public @Nullable Long getNewHash() { return newHash; } + public void setNewHash(@Nullable Long setterArg) { + this.newHash = setterArg; + } + + public static final class Builder { + private @Nullable ModelState state; + public @NonNull Builder setState(@Nullable ModelState setterArg) { + this.state = setterArg; + return this; + } + private @Nullable Boolean is_loading; + public @NonNull Builder setIs_loading(@Nullable Boolean setterArg) { + this.is_loading = setterArg; + return this; + } + private @Nullable Long oldHash; + public @NonNull Builder setOldHash(@Nullable Long setterArg) { + this.oldHash = setterArg; + return this; + } + private @Nullable Long newHash; + public @NonNull Builder setNewHash(@Nullable Long setterArg) { + this.newHash = setterArg; + return this; + } + public @NonNull LoginModelStateData build() { + LoginModelStateData pigeonReturn = new LoginModelStateData(); + pigeonReturn.setState(state); + pigeonReturn.setIs_loading(is_loading); + pigeonReturn.setOldHash(oldHash); + pigeonReturn.setNewHash(newHash); + return pigeonReturn; + } + } + @NonNull Map toMap() { + Map toMapResult = new HashMap<>(); + toMapResult.put("state", state == null ? null : state.index); + toMapResult.put("is_loading", is_loading); + toMapResult.put("oldHash", oldHash); + toMapResult.put("newHash", newHash); + return toMapResult; + } + static @NonNull LoginModelStateData fromMap(@NonNull Map map) { + LoginModelStateData pigeonResult = new LoginModelStateData(); + Object state = map.get("state"); + pigeonResult.setState(state == null ? null : ModelState.values()[(int)state]); + Object is_loading = map.get("is_loading"); + pigeonResult.setIs_loading((Boolean)is_loading); + Object oldHash = map.get("oldHash"); + pigeonResult.setOldHash((oldHash == null) ? null : ((oldHash instanceof Integer) ? (Integer)oldHash : (Long)oldHash)); + Object newHash = map.get("newHash"); + pigeonResult.setNewHash((newHash == null) ? null : ((newHash instanceof Integer) ? (Integer)newHash : (Long)newHash)); + return pigeonResult; + } + } + /** Generated class from Pigeon that represents data sent in messages. */ public static class LoginModelEventData { private @Nullable LoginModelEvent event; @@ -1106,6 +1184,7 @@ protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) public interface MainModelBridge { @NonNull MainModelStateData getState(); @NonNull MainModelEventData getEvent(); + void resetEvent(); void initState(); void loadNextPage(); void filter(@NonNull SnippetFilter filter); @@ -1155,6 +1234,25 @@ static void setup(BinaryMessenger binaryMessenger, MainModelBridge api) { channel.setMessageHandler(null); } } + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.MainModelBridge.resetEvent", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + api.resetEvent(); + wrapped.put("result", null); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } { BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); BasicMessageChannel channel = @@ -1536,6 +1634,9 @@ protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) { case (byte)128: return LoginModelEventData.fromMap((Map) readValue(buffer)); + case (byte)129: + return LoginModelStateData.fromMap((Map) readValue(buffer)); + default: return super.readValueOfType(type, buffer); @@ -1547,6 +1648,10 @@ protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) stream.write(128); writeValue(stream, ((LoginModelEventData) value).toMap()); } else + if (value instanceof LoginModelStateData) { + stream.write(129); + writeValue(stream, ((LoginModelStateData) value).toMap()); + } else { super.writeValue(stream, value); } @@ -1555,8 +1660,10 @@ protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ public interface LoginModelBridge { + @NonNull LoginModelStateData getState(); @NonNull LoginModelEventData getEvent(); void loginOrRegister(@NonNull String email, @NonNull String password); + void checkLoginState(); void resetEvent(); /** The codec used by LoginModelBridge. */ @@ -1564,6 +1671,25 @@ static MessageCodec getCodec() { return LoginModelBridgeCodec.INSTANCE; } /**Sets up an instance of `LoginModelBridge` to handle messages through the `binaryMessenger`. */ static void setup(BinaryMessenger binaryMessenger, LoginModelBridge api) { + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.LoginModelBridge.getState", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + LoginModelStateData output = api.getState(); + wrapped.put("result", output); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } { BasicMessageChannel channel = new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.LoginModelBridge.getEvent", getCodec()); @@ -1612,6 +1738,25 @@ static void setup(BinaryMessenger binaryMessenger, LoginModelBridge api) { channel.setMessageHandler(null); } } + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.LoginModelBridge.checkLoginState", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + api.checkLoginState(); + wrapped.put("result", null); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } { BasicMessageChannel channel = new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.LoginModelBridge.resetEvent", getCodec()); diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModel.kt index adf73ce..01e2b6a 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModel.kt @@ -6,23 +6,30 @@ import io.reactivex.rxkotlin.plusAssign import io.reactivex.rxkotlin.subscribeBy import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.flow.MutableStateFlow +import pl.tkadziolka.snipmeandroid.domain.auth.InitialLoginUseCase import pl.tkadziolka.snipmeandroid.domain.auth.LoginInteractor import pl.tkadziolka.snipmeandroid.domain.error.exception.* import pl.tkadziolka.snipmeandroid.domain.message.ErrorMessages import pl.tkadziolka.snipmeandroid.ui.error.ErrorParsable import pl.tkadziolka.snipmeandroid.ui.login.* +import pl.tkadziolka.snipmeandroid.ui.splash.NotAuthorized import pl.tkadziolka.snipmeandroid.util.extension.inProgress import timber.log.Timber +import java.util.concurrent.TimeUnit class LoginModel( private val errorMessages: ErrorMessages, private val interactor: LoginInteractor, -): ErrorParsable { + private val initialLogin: InitialLoginUseCase, +) : ErrorParsable { private val disposables = CompositeDisposable() private var identifyDisposable: Disposable? = null private var loginDisposable: Disposable? = null private var registerDisposable: Disposable? = null + private val mutableState = MutableStateFlow(Loading) + val state = mutableState + private val mutableEvent = MutableStateFlow(Idle) val event = mutableEvent @@ -39,9 +46,26 @@ class LoginModel( } } + fun init() { + initialLogin() + .delay(3, TimeUnit.SECONDS) + .subscribeOn(Schedulers.io()) + .doOnEvent { setState(Loaded) } + .subscribeBy( + onComplete = { setEvent(Logged) }, + onError = { + if (it !is NotAuthorizedException) { + Timber.e("Couldn't get token or user, error = $it") + } + } + ).also { disposables += it } + } + fun loginOrRegister(email: String, password: String) { if (identifyDisposable.inProgress()) return + setState(Loading) + identifyDisposable = interactor.identify(email) .subscribeOn(Schedulers.io()) .subscribeBy( @@ -58,6 +82,7 @@ class LoginModel( loginDisposable = interactor.login(email, password) .subscribeOn(Schedulers.io()) + .doOnEvent { setState(Loaded) } .subscribeBy( onComplete = { setEvent(Logged) }, onError = { @@ -72,6 +97,7 @@ class LoginModel( registerDisposable = interactor.register(email, password, email) .subscribeOn(Schedulers.io()) + .doOnEvent { setState(Loaded) } .subscribeBy( onComplete = { setEvent(Logged) }, onError = { @@ -92,4 +118,8 @@ class LoginModel( private fun setEvent(event: LoginEvent) { mutableEvent.value = event } + + private fun setState(state: LoginState) { + mutableState.value = state + } } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModelPlugin.kt index b85eebd..e4c831c 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModelPlugin.kt @@ -4,14 +4,16 @@ import io.flutter.plugin.common.BinaryMessenger import org.koin.core.component.inject import pl.tkadziolka.snipmeandroid.bridge.Bridge import pl.tkadziolka.snipmeandroid.bridge.ModelPlugin -import pl.tkadziolka.snipmeandroid.ui.login.Idle -import pl.tkadziolka.snipmeandroid.ui.login.Logged -import pl.tkadziolka.snipmeandroid.ui.login.LoginEvent +import pl.tkadziolka.snipmeandroid.ui.detail.DetailViewState +import pl.tkadziolka.snipmeandroid.ui.login.* class LoginModelPlugin : ModelPlugin(), Bridge.LoginModelBridge { private var oldEvent: LoginEvent? = null + private var oldState: LoginState? = null private val model: LoginModel by inject() + override fun getState(): Bridge.LoginModelStateData = getModelState(model.state.value) + override fun getEvent(): Bridge.LoginModelEventData = getModelEvent(model.event.value) override fun resetEvent() { @@ -22,6 +24,10 @@ class LoginModelPlugin : ModelPlugin(), Bridge.LoginMod Bridge.LoginModelBridge.setup(messenger, bridge) } + override fun checkLoginState() { + model.init() + } + override fun loginOrRegister(email: String, password: String) { model.loginOrRegister(email, password) } @@ -36,6 +42,22 @@ class LoginModelPlugin : ModelPlugin(), Bridge.LoginMod } } + private fun getModelState(loginState: LoginState): Bridge.LoginModelStateData { + return Bridge.LoginModelStateData().apply { + state = loginState.toModelLoginState() + oldHash = oldState?.hashCode()?.toLong() ?: 0 + newHash = loginState.hashCode().toLong() + }.also { + oldState = loginState + } + } + + private fun LoginState.toModelLoginState() = + when (this) { + Loaded -> Bridge.ModelState.LOADED + else -> Bridge.ModelState.LOADING + } + private fun LoginEvent.toModelLoginEvent() = when (this) { Logged -> Bridge.LoginModelEvent.LOGGED diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt index 71fa42a..156b632 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt @@ -25,6 +25,10 @@ class MainModelPlugin : ModelPlugin(), Bridge.MainModelB override fun getEvent(): Bridge.MainModelEventData = getEvent(model.event.value) + override fun resetEvent() { + model.event.value = Startup + } + override fun initState() { model.initState() } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt index 0ecdd1b..36bab26 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt @@ -8,7 +8,7 @@ import pl.tkadziolka.snipmeandroid.bridge.session.SessionModel internal val modelModule = module { single { SessionModel(get()) } - single { LoginModel(get(), get()) } + single { LoginModel(get(), get(), get()) } single { MainModel(get(), get(), get(), get(), get(), get(), get()) } single { DetailModel(get(), get(), get(), get(), get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginViewModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginViewModel.kt index dcf3586..cf1397e 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginViewModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginViewModel.kt @@ -30,7 +30,7 @@ class LoginViewModel( private val messages: ValidationMessages, private val interactor: LoginInteractor, private val navigator: LoginNavigator -) : StateViewModel(), ErrorParsable { +) : StateViewModel(), ErrorParsable { private var identifyDisposable: Disposable? = null private var loginDisposable: Disposable? = null private var registerDisposable: Disposable? = null @@ -185,19 +185,20 @@ class LoginViewModel( } } -sealed class LoginViewState -object Loading : LoginViewState() +sealed class LoginState +object Loading : LoginState() +object Loaded : LoginState() data class Startup( val login: String, val error: String? -) : LoginViewState() +) : LoginState() data class Login( val login: String, val password: String, val error: String? -) : LoginViewState() +) : LoginState() data class Register( val login: String, @@ -205,15 +206,15 @@ data class Register( val repeatedPassword: String, val email: String, val error: String? -) : LoginViewState() +) : LoginState() data class UserFound( val login: String -) : LoginViewState() +) : LoginState() data class Completed( val login: String -) : LoginViewState() +) : LoginState() sealed class LoginEvent object Idle : LoginEvent() diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainViewModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainViewModel.kt index d5d3668..c0c705e 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainViewModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainViewModel.kt @@ -195,6 +195,7 @@ data class Loaded( val pages: Int, val scope: SnippetScope ) : MainViewState() + data class Error(val message: String?) : MainViewState() sealed class MainEvent diff --git a/flutter_module/bridge/main_model.dart b/flutter_module/bridge/main_model.dart index e0ed1b3..ed0e7d2 100644 --- a/flutter_module/bridge/main_model.dart +++ b/flutter_module/bridge/main_model.dart @@ -141,6 +141,13 @@ class DetailModelEventData { int? newHash; } +class LoginModelStateData { + ModelState? state; + bool? is_loading; + int? oldHash; + int? newHash; +} + class LoginModelEventData { LoginModelEvent? event; int? oldHash; @@ -155,6 +162,8 @@ abstract class MainModelBridge { MainModelEventData getEvent(); + void resetEvent(); + @TaskQueue(type: TaskQueueType.serialBackgroundThread) void initState(); @@ -194,9 +203,13 @@ abstract class DetailModelBridge { @HostApi() abstract class LoginModelBridge { + LoginModelStateData getState(); + LoginModelEventData getEvent(); void loginOrRegister(String email, String password); + void checkLoginState(); + void resetEvent(); } \ No newline at end of file diff --git a/flutter_module/lib/model/main_model.dart b/flutter_module/lib/model/main_model.dart index 42b5d9f..731f5b6 100644 --- a/flutter_module/lib/model/main_model.dart +++ b/flutter_module/lib/model/main_model.dart @@ -453,6 +453,41 @@ class DetailModelEventData { } } +class LoginModelStateData { + LoginModelStateData({ + this.state, + this.is_loading, + this.oldHash, + this.newHash, + }); + + ModelState? state; + bool? is_loading; + int? oldHash; + int? newHash; + + Object encode() { + final Map pigeonMap = {}; + pigeonMap['state'] = state?.index; + pigeonMap['is_loading'] = is_loading; + pigeonMap['oldHash'] = oldHash; + pigeonMap['newHash'] = newHash; + return pigeonMap; + } + + static LoginModelStateData decode(Object message) { + final Map pigeonMap = message as Map; + return LoginModelStateData( + state: pigeonMap['state'] != null + ? ModelState.values[pigeonMap['state']! as int] + : null, + is_loading: pigeonMap['is_loading'] as bool?, + oldHash: pigeonMap['oldHash'] as int?, + newHash: pigeonMap['newHash'] as int?, + ); + } +} + class LoginModelEventData { LoginModelEventData({ this.event, @@ -621,6 +656,28 @@ class MainModelBridge { } } + Future resetEvent() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.MainModelBridge.resetEvent', codec, binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send(null) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + Future initState() async { final BasicMessageChannel channel = BasicMessageChannel( 'dev.flutter.pigeon.MainModelBridge.initState', codec, binaryMessenger: _binaryMessenger); @@ -1025,6 +1082,10 @@ class _LoginModelBridgeCodec extends StandardMessageCodec{ buffer.putUint8(128); writeValue(buffer, value.encode()); } else + if (value is LoginModelStateData) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else { super.writeValue(buffer, value); } @@ -1035,6 +1096,9 @@ class _LoginModelBridgeCodec extends StandardMessageCodec{ case 128: return LoginModelEventData.decode(readValue(buffer)!); + case 129: + return LoginModelStateData.decode(readValue(buffer)!); + default: return super.readValueOfType(type, buffer); @@ -1051,6 +1115,33 @@ class LoginModelBridge { static const MessageCodec codec = _LoginModelBridgeCodec(); + Future getState() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.LoginModelBridge.getState', codec, binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send(null) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else if (replyMap['result'] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (replyMap['result'] as LoginModelStateData?)!; + } + } + Future getEvent() async { final BasicMessageChannel channel = BasicMessageChannel( 'dev.flutter.pigeon.LoginModelBridge.getEvent', codec, binaryMessenger: _binaryMessenger); @@ -1100,6 +1191,28 @@ class LoginModelBridge { } } + Future checkLoginState() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.LoginModelBridge.checkLoginState', codec, binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send(null) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + Future resetEvent() async { final BasicMessageChannel channel = BasicMessageChannel( 'dev.flutter.pigeon.LoginModelBridge.resetEvent', codec, binaryMessenger: _binaryMessenger); diff --git a/flutter_module/lib/presentation/screens/login_screen.dart b/flutter_module/lib/presentation/screens/login_screen.dart index 9547b61..a6c6c20 100644 --- a/flutter_module/lib/presentation/screens/login_screen.dart +++ b/flutter_module/lib/presentation/screens/login_screen.dart @@ -12,6 +12,7 @@ import 'package:flutter_module/presentation/styles/text_styles.dart'; import 'package:flutter_module/presentation/widgets/login_input_card.dart'; import 'package:flutter_module/presentation/widgets/no_overscroll_single_child_scroll_view.dart'; import 'package:flutter_module/presentation/widgets/rounded_action_button.dart'; +import 'package:flutter_module/presentation/widgets/view_state_wrapper.dart'; import 'package:flutter_module/utils/extensions/state_extensions.dart'; import 'package:flutter_module/utils/hooks/use_navigator.dart'; import 'package:flutter_module/utils/hooks/use_observable_state_hook.dart'; @@ -59,12 +60,22 @@ class _MainPage extends HookWidget { final password = useState(''); final validationCorrect = useState(false); + final state = useObservableState( + LoginModelStateData(), + () => model.getState(), + (current, newState) => (current as LoginModelStateData).equals(newState), + ).value; + final event = useObservableState( LoginModelEventData(), () => model.getEvent(), (current, newState) => (current as LoginModelEventData).equals(newState), ).value; + useEffect(() { + model.checkLoginState(); + }, []); + WidgetsBinding.instance.addPostFrameCallback((_) { if (event.event == LoginModelEvent.logged) { model.resetEvent(); @@ -74,50 +85,54 @@ class _MainPage extends HookWidget { return Scaffold( body: SafeArea( - child: NoOverscrollSingleChildScrollView( - child: Expanded( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: Dimens.xxl), - TextStyles.appLogo('SnipMe'), - const SizedBox(height: Dimens.xxl), - Image.asset(Assets.appLogo), - const SizedBox(height: Dimens.xxl), - const TextStyles.secondary('Snip your favorite code'), - PaddingStyles.regular( - LoginInputCard( - onEmailChanged: (emailValue) { - email.value = emailValue; - stream.add(emailValue); - }, - onPasswordChanged: (passwordValue) { - password.value = passwordValue; - stream.add(passwordValue); - }, - onValidChanged: (isValid) { - validationCorrect.value = isValid; - }, + child: ViewStateWrapper( + isLoading: state.state == ModelState.loading, + data: state.state, + builder: (BuildContext context, _) { + return NoOverscrollSingleChildScrollView( + child: Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: Dimens.xxl), + TextStyles.appLogo('SnipMe'), + const SizedBox(height: Dimens.xxl), + Image.asset(Assets.appLogo), + const SizedBox(height: Dimens.xxl), + const TextStyles.secondary('Snip your favorite code'), + PaddingStyles.regular( + LoginInputCard( + onEmailChanged: (emailValue) { + email.value = emailValue; + stream.add(emailValue); + }, + onPasswordChanged: (passwordValue) { + password.value = passwordValue; + stream.add(passwordValue); + }, + onValidChanged: (isValid) { + validationCorrect.value = isValid; + }, + ), ), - ), - Center( - child: RoundedActionButton( - icon: Icons.check_circle, - title: 'Login', - enabled: validationCorrect.value && - email.value.isNotEmpty && - password.value.isNotEmpty, - onPressed: () { - model.loginOrRegister(email.value, password.value); - }, + Center( + child: RoundedActionButton( + icon: Icons.check_circle, + title: 'Login', + enabled: validationCorrect.value && + email.value.isNotEmpty && + password.value.isNotEmpty, + onPressed: () { + model.loginOrRegister(email.value, password.value); + }, + ), ), - ), - const SizedBox(height: Dimens.xxl), - ], + const SizedBox(height: Dimens.xxl), + ], + ), ), - ), - ), + ); + }, ), ), ); diff --git a/flutter_module/lib/presentation/screens/main_screen.dart b/flutter_module/lib/presentation/screens/main_screen.dart index 14fb021..9eb51a4 100644 --- a/flutter_module/lib/presentation/screens/main_screen.dart +++ b/flutter_module/lib/presentation/screens/main_screen.dart @@ -69,15 +69,18 @@ class _MainPage extends HookWidget { model.initState(); }, []); - if (event.event == MainModelEvent.logout) { - loginNavigator.logout(); - } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (event.event == MainModelEvent.logout) { + model.resetEvent(); + loginNavigator.logout(); + } + }); return Scaffold( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.logout), - onPressed: loginNavigator.logout, + onPressed: model.logOut, ), title: const Text("SnipMe"), ), diff --git a/flutter_module/lib/utils/extensions/state_extensions.dart b/flutter_module/lib/utils/extensions/state_extensions.dart index 91314fe..e4960f9 100644 --- a/flutter_module/lib/utils/extensions/state_extensions.dart +++ b/flutter_module/lib/utils/extensions/state_extensions.dart @@ -28,6 +28,13 @@ extension DetailModelEventDataExtension on DetailModelEventData { } } +extension LoginModelStateDataExtension on LoginModelStateData { + bool equals(Object other) { + if (other is! LoginModelStateData) return false; + return other.oldHash == other.newHash; + } +} + extension LoginModelEventDataExtension on LoginModelEventData { bool equals(Object other) { if (other is! LoginModelEventData) return false; From 0cfd7b72608a22e46a903c4c41a9a8e2bdc09850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Fri, 16 Dec 2022 09:27:57 +0100 Subject: [PATCH 22/66] Created coordinated header with list --- .../lib/presentation/screens/main_screen.dart | 131 +++++++++++++++--- .../widgets/filter_list_view.dart | 27 ++++ .../widgets/snippet_list_item.dart | 1 + 3 files changed, 141 insertions(+), 18 deletions(-) create mode 100644 flutter_module/lib/presentation/widgets/filter_list_view.dart diff --git a/flutter_module/lib/presentation/screens/main_screen.dart b/flutter_module/lib/presentation/screens/main_screen.dart index 9eb51a4..03bd7e9 100644 --- a/flutter_module/lib/presentation/screens/main_screen.dart +++ b/flutter_module/lib/presentation/screens/main_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_module/model/main_model.dart'; import 'package:flutter_module/presentation/navigation/details/details_navigator.dart'; @@ -6,6 +7,7 @@ import 'package:flutter_module/presentation/navigation/login/login_navigator.dar import 'package:flutter_module/presentation/screens/named_screen.dart'; import 'package:flutter_module/presentation/styles/color_styles.dart'; import 'package:flutter_module/presentation/styles/dimens.dart'; +import 'package:flutter_module/presentation/widgets/filter_list_view.dart'; import 'package:flutter_module/presentation/widgets/snippet_list_item.dart'; import 'package:flutter_module/presentation/widgets/view_state_wrapper.dart'; import 'package:flutter_module/utils/extensions/state_extensions.dart'; @@ -86,7 +88,8 @@ class _MainPage extends HookWidget { ), backgroundColor: ColorStyles.pageBackground(), body: ViewStateWrapper>( - isLoading: state.state == ModelState.loading || state.is_loading == true, + isLoading: + state.state == ModelState.loading || state.is_loading == true, error: state.error, data: state.data?.cast(), builder: (_, snippets) { @@ -105,7 +108,7 @@ class _MainPage extends HookWidget { } } -class _MainPageData extends StatelessWidget { +class _MainPageData extends HookWidget { const _MainPageData({ Key? key, required this.navigator, @@ -117,23 +120,115 @@ class _MainPageData extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView.builder( - itemCount: snippets.length, - itemBuilder: (_, index) { - final snippet = snippets[index]; - return Padding( - padding: const EdgeInsets.symmetric( - vertical: Dimens.s, - horizontal: Dimens.m, - ), - child: SnippetListTile( - snippet: snippet, - onTap: () { - navigator.goToDetails(context, snippet.uuid!); - }, - ), - ); + return NestedScrollView( + headerSliverBuilder: (_, __) { + return [ + SliverAppBar( + floating: true, + expandedHeight: 120, + flexibleSpace: FlexibleSpaceBar( + collapseMode: CollapseMode.parallax, + background: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row(children: [Text("Language")]), + // FilterListView( + // filters: ['a', 'b'], + // selected: ['a'], + // ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [Text("Language2")], + ), + Text("Languag3"), + ], + ), + )) + ]; }, + body: CustomScrollView( + // scrollBehavior: const ConstantScrollBehavior(), + slivers: [ + SliverList( + delegate: SliverChildListDelegate([ + ...snippets.map( + (snippet) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: Dimens.s, + horizontal: Dimens.m, + ), + child: SnippetListTile( + snippet: snippet, + onTap: () { + navigator.goToDetails(context, snippet.uuid!); + }, + ), + ); + }, + ).toList() + ]), + ), + ], + ), ); } } + +class MySliverPersistentHeaderDelegate extends SliverPersistentHeaderDelegate { + final Widget child; + + MySliverPersistentHeaderDelegate({required this.child}); + + @override + Widget build( + BuildContext context, double shrinkOffset, bool overlapsContent) { + print("Offset: $shrinkOffset"); + + return OverflowBox( + maxWidth: double.infinity, + alignment: Alignment.center, + maxHeight: 150 - shrinkOffset, + child: FittedBox( + fit: BoxFit.cover, + alignment: Alignment.center, + child: Container(color: Colors.red, child: child), + ), + ); + } + + @override + double get maxExtent => 150.0; + + @override + double get minExtent => 50.0; + + @override + bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { + return true; + } +} + +class MyRenderSliverFloatingPersistentHeader + extends RenderSliverFloatingPinnedPersistentHeader { + MyRenderSliverFloatingPersistentHeader({ + required super.child, + }); + + @override + double get maxExtent => 250.0; + + @override + double get minExtent => 100.0; +} + +// Column( +// children: [ +// Text("Language"), +// FilterListView( +// filters: ['a', 'b'], +// selected: ['a'], +// ), +// Text("Language"), +// ], +// ), diff --git a/flutter_module/lib/presentation/widgets/filter_list_view.dart b/flutter_module/lib/presentation/widgets/filter_list_view.dart new file mode 100644 index 0000000..5ced56d --- /dev/null +++ b/flutter_module/lib/presentation/widgets/filter_list_view.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class FilterListView extends StatelessWidget { + const FilterListView({ + Key? key, + required this.filters, + required this.selected, + }) : super(key: key); + + final List filters; + final List selected; + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: filters.length, + scrollDirection: Axis.horizontal, + itemBuilder: (BuildContext context, int index) { + final filter = filters[index]; + return ChoiceChip( + label: Text(filter), + selected: selected.contains(filter), + ); + }, + ); + } +} diff --git a/flutter_module/lib/presentation/widgets/snippet_list_item.dart b/flutter_module/lib/presentation/widgets/snippet_list_item.dart index 25472c7..bb601dd 100644 --- a/flutter_module/lib/presentation/widgets/snippet_list_item.dart +++ b/flutter_module/lib/presentation/widgets/snippet_list_item.dart @@ -48,6 +48,7 @@ class SnippetListTile extends HookWidget { CodeTextView.preview( code: snippet.code?.raw ?? "", tokens: snippet.code?.tokens, + options: TextSelectionOptions.copyable(), ), ), ), From 58319730d8a7cc0a43b2e63d7ca84b192caa9154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Fri, 16 Dec 2022 09:31:53 +0100 Subject: [PATCH 23/66] Corrected floating --- flutter_module/lib/presentation/screens/main_screen.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flutter_module/lib/presentation/screens/main_screen.dart b/flutter_module/lib/presentation/screens/main_screen.dart index 03bd7e9..11a0058 100644 --- a/flutter_module/lib/presentation/screens/main_screen.dart +++ b/flutter_module/lib/presentation/screens/main_screen.dart @@ -1,3 +1,4 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -121,6 +122,7 @@ class _MainPageData extends HookWidget { @override Widget build(BuildContext context) { return NestedScrollView( + floatHeaderSlivers: true, headerSliverBuilder: (_, __) { return [ SliverAppBar( From 6fd17d8594f747055926d16ade8032e1968a79e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Fri, 16 Dec 2022 10:45:49 +0100 Subject: [PATCH 24/66] Added main page components --- .../lib/presentation/screens/main_screen.dart | 57 +++++++++++++------ .../lib/presentation/styles/text_styles.dart | 13 ++++- 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/flutter_module/lib/presentation/screens/main_screen.dart b/flutter_module/lib/presentation/screens/main_screen.dart index 11a0058..9be5fff 100644 --- a/flutter_module/lib/presentation/screens/main_screen.dart +++ b/flutter_module/lib/presentation/screens/main_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter_module/presentation/navigation/login/login_navigator.dar import 'package:flutter_module/presentation/screens/named_screen.dart'; import 'package:flutter_module/presentation/styles/color_styles.dart'; import 'package:flutter_module/presentation/styles/dimens.dart'; +import 'package:flutter_module/presentation/styles/text_styles.dart'; import 'package:flutter_module/presentation/widgets/filter_list_view.dart'; import 'package:flutter_module/presentation/widgets/snippet_list_item.dart'; import 'package:flutter_module/presentation/widgets/view_state_wrapper.dart'; @@ -80,13 +81,6 @@ class _MainPage extends HookWidget { }); return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const Icon(Icons.logout), - onPressed: model.logOut, - ), - title: const Text("SnipMe"), - ), backgroundColor: ColorStyles.pageBackground(), body: ViewStateWrapper>( isLoading: @@ -96,14 +90,16 @@ class _MainPage extends HookWidget { builder: (_, snippets) { return _MainPageData( navigator: detailsNavigator, + model: model, snippets: snippets ?? List.empty(), ); }, ), - floatingActionButton: FloatingActionButton( + floatingActionButton: FloatingActionButton.small( onPressed: () => model.loadNextPage(), - tooltip: 'Increment', - child: const Icon(Icons.add), + tooltip: 'Scroll to top', + backgroundColor: ColorStyles.surfacePrimary(), + child: const Icon(Icons.arrow_upward_outlined, color: Colors.black,), ), ); } @@ -113,10 +109,12 @@ class _MainPageData extends HookWidget { const _MainPageData({ Key? key, required this.navigator, + required this.model, required this.snippets, }) : super(key: key); final DetailsNavigator navigator; + final MainModelBridge model; final List snippets; @override @@ -125,24 +123,47 @@ class _MainPageData extends HookWidget { floatHeaderSlivers: true, headerSliverBuilder: (_, __) { return [ + SliverAppBar( + centerTitle: true, + title: TextStyles.appBarLogo('SnipMe'), + backgroundColor: ColorStyles.surfacePrimary(), + leading: IconButton( + icon: const Icon(Icons.logout), + color: Colors.black, + onPressed: model.logOut, + ), + actions: [ + IconButton( + icon: const Icon(Icons.close_fullscreen_outlined), + color: Colors.black, + onPressed: () { + // TODO Handle collapse items + }, + ), + ], + ), SliverAppBar( floating: true, expandedHeight: 120, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(Dimens.m), + bottomRight: Radius.circular(Dimens.m), + ), + ), flexibleSpace: FlexibleSpaceBar( collapseMode: CollapseMode.parallax, background: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Row(children: [Text("Language")]), - // FilterListView( - // filters: ['a', 'b'], - // selected: ['a'], - // ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [Text("Language2")], + SizedBox( + height: 64, + child: FilterListView( + filters: ['a', 'b' * 200], + selected: ['a'], + ), ), - Text("Languag3"), ], ), )) diff --git a/flutter_module/lib/presentation/styles/text_styles.dart b/flutter_module/lib/presentation/styles/text_styles.dart index 452ca53..00961c4 100644 --- a/flutter_module/lib/presentation/styles/text_styles.dart +++ b/flutter_module/lib/presentation/styles/text_styles.dart @@ -65,8 +65,19 @@ class TextStyles extends Text { key: key, style: TextStyle( fontFamily: 'Kanit', - fontSize: 24, + fontSize: 24.0, color: ColorStyles.accent(), ), ); + + TextStyles.appBarLogo(this.text, {Key? key}) + : super( + text, + key: key, + style: const TextStyle( + fontFamily: 'Kanit', + fontSize: 18.0, + color: Colors.black, + ), + ); } From c082f349f3074ebad6356326412ed3db6f111565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Fri, 16 Dec 2022 11:22:09 +0100 Subject: [PATCH 25/66] Polished view --- .../lib/presentation/screens/main_screen.dart | 85 +++++-------------- .../widgets/snippet_list_item.dart | 3 +- 2 files changed, 21 insertions(+), 67 deletions(-) diff --git a/flutter_module/lib/presentation/screens/main_screen.dart b/flutter_module/lib/presentation/screens/main_screen.dart index 9be5fff..c7c1a3c 100644 --- a/flutter_module/lib/presentation/screens/main_screen.dart +++ b/flutter_module/lib/presentation/screens/main_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_module/generated/assets.dart'; import 'package:flutter_module/model/main_model.dart'; import 'package:flutter_module/presentation/navigation/details/details_navigator.dart'; import 'package:flutter_module/presentation/navigation/login/login_navigator.dart'; @@ -99,7 +100,10 @@ class _MainPage extends HookWidget { onPressed: () => model.loadNextPage(), tooltip: 'Scroll to top', backgroundColor: ColorStyles.surfacePrimary(), - child: const Icon(Icons.arrow_upward_outlined, color: Colors.black,), + child: const Icon( + Icons.arrow_upward_outlined, + color: Colors.black, + ), ), ); } @@ -124,8 +128,13 @@ class _MainPageData extends HookWidget { headerSliverBuilder: (_, __) { return [ SliverAppBar( + elevation: 0.0, centerTitle: true, - title: TextStyles.appBarLogo('SnipMe'), + title: Row(mainAxisSize: MainAxisSize.min, children: [ + Image.asset(Assets.appLogo, width: 18.0), + const SizedBox(width: Dimens.m), + TextStyles.appBarLogo('SnipMe'), + ]), backgroundColor: ColorStyles.surfacePrimary(), leading: IconButton( icon: const Icon(Icons.logout), @@ -145,10 +154,12 @@ class _MainPageData extends HookWidget { SliverAppBar( floating: true, expandedHeight: 120, - shape: RoundedRectangleBorder( + elevation: Dimens.m, + backgroundColor: ColorStyles.surfacePrimary(), + shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(Dimens.m), - bottomRight: Radius.circular(Dimens.m), + bottomLeft: Radius.circular(Dimens.l), + bottomRight: Radius.circular(Dimens.l), ), ), flexibleSpace: FlexibleSpaceBar( @@ -156,7 +167,7 @@ class _MainPageData extends HookWidget { background: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Row(children: [Text("Language")]), + Row(children: [const Text("Language")]), SizedBox( height: 64, child: FilterListView( @@ -170,7 +181,9 @@ class _MainPageData extends HookWidget { ]; }, body: CustomScrollView( - // scrollBehavior: const ConstantScrollBehavior(), + scrollBehavior: const ScrollBehavior( + androidOverscrollIndicator: AndroidOverscrollIndicator.stretch, + ), slivers: [ SliverList( delegate: SliverChildListDelegate([ @@ -197,61 +210,3 @@ class _MainPageData extends HookWidget { ); } } - -class MySliverPersistentHeaderDelegate extends SliverPersistentHeaderDelegate { - final Widget child; - - MySliverPersistentHeaderDelegate({required this.child}); - - @override - Widget build( - BuildContext context, double shrinkOffset, bool overlapsContent) { - print("Offset: $shrinkOffset"); - - return OverflowBox( - maxWidth: double.infinity, - alignment: Alignment.center, - maxHeight: 150 - shrinkOffset, - child: FittedBox( - fit: BoxFit.cover, - alignment: Alignment.center, - child: Container(color: Colors.red, child: child), - ), - ); - } - - @override - double get maxExtent => 150.0; - - @override - double get minExtent => 50.0; - - @override - bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { - return true; - } -} - -class MyRenderSliverFloatingPersistentHeader - extends RenderSliverFloatingPinnedPersistentHeader { - MyRenderSliverFloatingPersistentHeader({ - required super.child, - }); - - @override - double get maxExtent => 250.0; - - @override - double get minExtent => 100.0; -} - -// Column( -// children: [ -// Text("Language"), -// FilterListView( -// filters: ['a', 'b'], -// selected: ['a'], -// ), -// Text("Language"), -// ], -// ), diff --git a/flutter_module/lib/presentation/widgets/snippet_list_item.dart b/flutter_module/lib/presentation/widgets/snippet_list_item.dart index bb601dd..df38166 100644 --- a/flutter_module/lib/presentation/widgets/snippet_list_item.dart +++ b/flutter_module/lib/presentation/widgets/snippet_list_item.dart @@ -44,8 +44,7 @@ class SnippetListTile extends HookWidget { horizontal: Dimens.l, ), child: Expanded( - child: - CodeTextView.preview( + child: CodeTextView.preview( code: snippet.code?.raw ?? "", tokens: snippet.code?.tokens, options: TextSelectionOptions.copyable(), From 74df000012045ce6b4e29aebca994bba0ee50979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Fri, 16 Dec 2022 11:34:35 +0100 Subject: [PATCH 26/66] Corrected small design errors --- flutter_module/lib/presentation/screens/main_screen.dart | 7 ++++--- .../lib/presentation/widgets/snippet_list_item.dart | 1 - 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flutter_module/lib/presentation/screens/main_screen.dart b/flutter_module/lib/presentation/screens/main_screen.dart index c7c1a3c..ca203e4 100644 --- a/flutter_module/lib/presentation/screens/main_screen.dart +++ b/flutter_module/lib/presentation/screens/main_screen.dart @@ -109,7 +109,7 @@ class _MainPage extends HookWidget { } } -class _MainPageData extends HookWidget { +class _MainPageData extends StatelessWidget { const _MainPageData({ Key? key, required this.navigator, @@ -153,8 +153,9 @@ class _MainPageData extends HookWidget { ), SliverAppBar( floating: true, + forceElevated: true, expandedHeight: 120, - elevation: Dimens.m, + elevation: Dimens.s / 2, backgroundColor: ColorStyles.surfacePrimary(), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( @@ -165,7 +166,7 @@ class _MainPageData extends HookWidget { flexibleSpace: FlexibleSpaceBar( collapseMode: CollapseMode.parallax, background: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.end, children: [ Row(children: [const Text("Language")]), SizedBox( diff --git a/flutter_module/lib/presentation/widgets/snippet_list_item.dart b/flutter_module/lib/presentation/widgets/snippet_list_item.dart index df38166..88fb028 100644 --- a/flutter_module/lib/presentation/widgets/snippet_list_item.dart +++ b/flutter_module/lib/presentation/widgets/snippet_list_item.dart @@ -47,7 +47,6 @@ class SnippetListTile extends HookWidget { child: CodeTextView.preview( code: snippet.code?.raw ?? "", tokens: snippet.code?.tokens, - options: TextSelectionOptions.copyable(), ), ), ), From 8377e9230070f1ed23c93f34eeecacde1ba2623e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Fri, 16 Dec 2022 14:30:37 +0100 Subject: [PATCH 27/66] Handled filtering snippets by language --- .../snipmeandroid/bridge/Bridge.java | 104 ++++++++++-------- .../snipmeandroid/bridge/main/MainModel.kt | 81 ++++++++++---- .../bridge/main/MainModelPlugin.kt | 20 ++-- .../snipmeandroid/di/ModelModule.kt | 2 +- .../snipmeandroid/di/UseCaseModule.kt | 7 ++ .../filter/FilterSnippetsByLanguageUseCase.kt | 10 ++ .../filter/GetLanguageFiltersUseCase.kt | 18 +++ .../domain/filter/GetSnippetFiltersUseCase.kt | 7 ++ .../UpdateSnippetFiltersLanguageUseCase.kt | 24 ++++ .../domain/snippets/SnippetFilters.kt | 7 ++ .../snipmeandroid/ui/main/MainViewModel.kt | 39 +++---- flutter_module/bridge/main_model.dart | 9 +- flutter_module/lib/model/main_model.dart | 48 +++----- .../lib/presentation/screens/main_screen.dart | 16 ++- .../lib/presentation/styles/color_styles.dart | 1 + .../lib/presentation/styles/dimens.dart | 1 + .../widgets/filter_list_view.dart | 21 +++- 17 files changed, 272 insertions(+), 143 deletions(-) create mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/FilterSnippetsByLanguageUseCase.kt create mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/GetLanguageFiltersUseCase.kt create mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/GetSnippetFiltersUseCase.kt create mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/UpdateSnippetFiltersLanguageUseCase.kt create mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippets/SnippetFilters.kt diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java index 0cc7219..6f9dba0 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java @@ -4,20 +4,21 @@ package pl.tkadziolka.snipmeandroid.bridge; import android.util.Log; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import io.flutter.plugin.common.BasicMessageChannel; -import io.flutter.plugin.common.BinaryMessenger; -import io.flutter.plugin.common.MessageCodec; -import io.flutter.plugin.common.StandardMessageCodec; + import java.io.ByteArrayOutputStream; import java.nio.ByteBuffer; -import java.util.Arrays; import java.util.ArrayList; -import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.HashMap; + +import io.flutter.plugin.common.BasicMessageChannel; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MessageCodec; +import io.flutter.plugin.common.StandardMessageCodec; /**Generated class from Pigeon. */ @SuppressWarnings({"unused", "unchecked", "CodeBlock2Expr", "RedundantSuppression"}) @@ -562,33 +563,48 @@ public static final class Builder { /** Generated class from Pigeon that represents data sent in messages. */ public static class SnippetFilter { - private @Nullable SnippetFilterType type; - public @Nullable SnippetFilterType getType() { return type; } - public void setType(@Nullable SnippetFilterType setterArg) { - this.type = setterArg; + private @Nullable List languages; + public @Nullable List getLanguages() { return languages; } + public void setLanguages(@Nullable List setterArg) { + this.languages = setterArg; + } + + private @Nullable List selectedLanguages; + public @Nullable List getSelectedLanguages() { return selectedLanguages; } + public void setSelectedLanguages(@Nullable List setterArg) { + this.selectedLanguages = setterArg; } public static final class Builder { - private @Nullable SnippetFilterType type; - public @NonNull Builder setType(@Nullable SnippetFilterType setterArg) { - this.type = setterArg; + private @Nullable List languages; + public @NonNull Builder setLanguages(@Nullable List setterArg) { + this.languages = setterArg; + return this; + } + private @Nullable List selectedLanguages; + public @NonNull Builder setSelectedLanguages(@Nullable List setterArg) { + this.selectedLanguages = setterArg; return this; } public @NonNull SnippetFilter build() { SnippetFilter pigeonReturn = new SnippetFilter(); - pigeonReturn.setType(type); + pigeonReturn.setLanguages(languages); + pigeonReturn.setSelectedLanguages(selectedLanguages); return pigeonReturn; } } @NonNull Map toMap() { Map toMapResult = new HashMap<>(); - toMapResult.put("type", type == null ? null : type.index); + toMapResult.put("languages", languages); + toMapResult.put("selectedLanguages", selectedLanguages); return toMapResult; } static @NonNull SnippetFilter fromMap(@NonNull Map map) { SnippetFilter pigeonResult = new SnippetFilter(); - Object type = map.get("type"); - pigeonResult.setType(type == null ? null : SnippetFilterType.values()[(int)type]); + Object languages = map.get("languages"); + pigeonResult.setLanguages((List)languages); + Object selectedLanguages = map.get("selectedLanguages"); + pigeonResult.setSelectedLanguages((List)selectedLanguages); return pigeonResult; } } @@ -613,6 +629,12 @@ public void setData(@Nullable List setterArg) { this.data = setterArg; } + private @Nullable SnippetFilter filter; + public @Nullable SnippetFilter getFilter() { return filter; } + public void setFilter(@Nullable SnippetFilter setterArg) { + this.filter = setterArg; + } + private @Nullable String error; public @Nullable String getError() { return error; } public void setError(@Nullable String setterArg) { @@ -647,6 +669,11 @@ public static final class Builder { this.data = setterArg; return this; } + private @Nullable SnippetFilter filter; + public @NonNull Builder setFilter(@Nullable SnippetFilter setterArg) { + this.filter = setterArg; + return this; + } private @Nullable String error; public @NonNull Builder setError(@Nullable String setterArg) { this.error = setterArg; @@ -667,6 +694,7 @@ public static final class Builder { pigeonReturn.setState(state); pigeonReturn.setIs_loading(is_loading); pigeonReturn.setData(data); + pigeonReturn.setFilter(filter); pigeonReturn.setError(error); pigeonReturn.setOldHash(oldHash); pigeonReturn.setNewHash(newHash); @@ -678,6 +706,7 @@ public static final class Builder { toMapResult.put("state", state == null ? null : state.index); toMapResult.put("is_loading", is_loading); toMapResult.put("data", data); + toMapResult.put("filter", (filter == null) ? null : filter.toMap()); toMapResult.put("error", error); toMapResult.put("oldHash", oldHash); toMapResult.put("newHash", newHash); @@ -691,6 +720,8 @@ public static final class Builder { pigeonResult.setIs_loading((Boolean)is_loading); Object data = map.get("data"); pigeonResult.setData((List)data); + Object filter = map.get("filter"); + pigeonResult.setFilter((filter == null) ? null : SnippetFilter.fromMap((Map)filter)); Object error = map.get("error"); pigeonResult.setError((String)error); Object oldHash = map.get("oldHash"); @@ -1186,8 +1217,7 @@ public interface MainModelBridge { @NonNull MainModelEventData getEvent(); void resetEvent(); void initState(); - void loadNextPage(); - void filter(@NonNull SnippetFilter filter); + void filterLanguage(@NonNull String language, @NonNull Boolean isSelected); void logOut(); void refreshSnippetUpdates(); @@ -1276,38 +1306,22 @@ static void setup(BinaryMessenger binaryMessenger, MainModelBridge api) { { BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); BasicMessageChannel channel = - new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.MainModelBridge.loadNextPage", getCodec(), taskQueue); - if (api != null) { - channel.setMessageHandler((message, reply) -> { - Map wrapped = new HashMap<>(); - try { - api.loadNextPage(); - wrapped.put("result", null); - } - catch (Error | RuntimeException exception) { - wrapped.put("error", wrapError(exception)); - } - reply.reply(wrapped); - }); - } else { - channel.setMessageHandler(null); - } - } - { - BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); - BasicMessageChannel channel = - new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.MainModelBridge.filter", getCodec(), taskQueue); + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.MainModelBridge.filterLanguage", getCodec(), taskQueue); if (api != null) { channel.setMessageHandler((message, reply) -> { Map wrapped = new HashMap<>(); try { ArrayList args = (ArrayList)message; assert args != null; - SnippetFilter filterArg = (SnippetFilter)args.get(0); - if (filterArg == null) { - throw new NullPointerException("filterArg unexpectedly null."); + String languageArg = (String)args.get(0); + if (languageArg == null) { + throw new NullPointerException("languageArg unexpectedly null."); + } + Boolean isSelectedArg = (Boolean)args.get(1); + if (isSelectedArg == null) { + throw new NullPointerException("isSelectedArg unexpectedly null."); } - api.filter(filterArg); + api.filterLanguage(languageArg, isSelectedArg); wrapped.put("result", null); } catch (Error | RuntimeException exception) { diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt index 0c2b3b5..7395ba6 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt @@ -7,12 +7,14 @@ import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.flow.MutableStateFlow import pl.tkadziolka.snipmeandroid.bridge.session.SessionModel import pl.tkadziolka.snipmeandroid.domain.error.exception.* +import pl.tkadziolka.snipmeandroid.domain.filter.FilterSnippetsByLanguageUseCase +import pl.tkadziolka.snipmeandroid.domain.filter.GetLanguageFiltersUseCase +import pl.tkadziolka.snipmeandroid.domain.filter.SNIPPET_LANGUAGE_FILTER_ALL +import pl.tkadziolka.snipmeandroid.domain.filter.UpdateSnippetFiltersLanguageUseCase import pl.tkadziolka.snipmeandroid.domain.message.ErrorMessages import pl.tkadziolka.snipmeandroid.domain.snippet.ObserveUpdatedSnippetPageUseCase import pl.tkadziolka.snipmeandroid.domain.snippet.ResetUpdatedSnippetPageUseCase -import pl.tkadziolka.snipmeandroid.domain.snippets.GetSnippetsUseCase -import pl.tkadziolka.snipmeandroid.domain.snippets.HasMoreSnippetPagesUseCase -import pl.tkadziolka.snipmeandroid.domain.snippets.SnippetScope +import pl.tkadziolka.snipmeandroid.domain.snippets.* import pl.tkadziolka.snipmeandroid.domain.user.GetSingleUserUseCase import pl.tkadziolka.snipmeandroid.domain.user.User import pl.tkadziolka.snipmeandroid.ui.error.ErrorParsable @@ -29,10 +31,12 @@ class MainModel( private val observeUpdatedPage: ObserveUpdatedSnippetPageUseCase, private val resetUpdatedPage: ResetUpdatedSnippetPageUseCase, private val hasMore: HasMoreSnippetPagesUseCase, + private val getLanguageFilters: GetLanguageFiltersUseCase, + private val filterSnippetsByLanguage: FilterSnippetsByLanguageUseCase, + private val updateFilterLanguage: UpdateSnippetFiltersLanguageUseCase, private val session: SessionModel ) : ErrorParsable { private val disposables = CompositeDisposable() - private var shouldRefresh = false private val mutableEvent = MutableStateFlow(Startup) val event = mutableEvent @@ -40,12 +44,23 @@ class MainModel( private val mutableState = MutableStateFlow(Loading) val state = mutableState + private var cachedSnippets = emptyList() + private var shouldRefresh = false + private var filterState = SnippetFilters( + languages = listOf(SNIPPET_LANGUAGE_FILTER_ALL), + selectedLanguages = listOf(SNIPPET_LANGUAGE_FILTER_ALL), + scope = SnippetScope.ALL + ) + override fun parseError(throwable: Throwable) { when (throwable) { is ConnectionException -> mutableState.value = Error(errorMessages.parse(throwable)) - is ContentNotFoundException -> mutableState.value = Error(errorMessages.parse(throwable)) - is ForbiddenActionException -> mutableState.value = Error(errorMessages.parse(throwable)) - is NetworkNotAvailableException -> mutableState.value = Error(errorMessages.parse(throwable)) + is ContentNotFoundException -> mutableState.value = + Error(errorMessages.parse(throwable)) + is ForbiddenActionException -> mutableState.value = + Error(errorMessages.parse(throwable)) + is NetworkNotAvailableException -> mutableState.value = + Error(errorMessages.parse(throwable)) is NotAuthorizedException -> session.logOut { mutableEvent.value = Logout } is RemoteException -> mutableState.value = Error(errorMessages.parse(throwable)) is SessionExpiredException -> session.logOut { mutableEvent.value = Logout } @@ -54,7 +69,7 @@ class MainModel( } fun initState() { - mutableState.value = (Loading) + mutableState.value = Loading getUser() .subscribeOn(Schedulers.io()) .subscribeBy( @@ -66,23 +81,15 @@ class MainModel( ).also { disposables += it } } - fun loadNextPage() { - getLoadedState()?.let { state -> - hasMore(state.scope, state.pages) - .subscribeOn(Schedulers.io()) - .subscribeBy( - onSuccess = { hasMore -> - if (hasMore) loadSnippets(state.user, pages = state.pages + ONE_PAGE) - }, - onError = { - Timber.e("Couldn't check next page, error = $it") - mutableEvent.value = Alert(errorMessages.parse(it)) - }) - .also { disposables += it } + fun filterLanguage(language: String, isSelected: Boolean) { + getLoadedState()?.let { + filterState = updateFilterLanguage(filterState, language, isSelected) + val filteredSnippets = filterSnippetsByLanguage(cachedSnippets, filterState.selectedLanguages) + state.value = it.copy(snippets = filteredSnippets, filters = filterState) } } - fun filter(filter: SnippetFilter) { + fun filterScope(filter: SnippetFilter) { val scope = filterToScope(filter) getLoadedState()?.let { state -> loadSnippets(state.user, pages = ONE_PAGE, scope = scope) @@ -109,6 +116,24 @@ class MainModel( } } + private fun loadNextPage() { + getLoadedState()?.let { state -> + hasMore(state.filters.scope, state.pages) + .subscribeOn(Schedulers.io()) + .subscribeBy( + onSuccess = { hasMore -> + if (hasMore) { + loadSnippets(state.user, pages = state.pages + ONE_PAGE) + } + }, + onError = { + Timber.e("Couldn't check next page, error = $it") + mutableEvent.value = Alert(errorMessages.parse(it)) + }) + .also { disposables += it } + } + } + private fun loadSnippets( user: User, pages: Int = 1, @@ -118,7 +143,15 @@ class MainModel( .subscribeOn(Schedulers.io()) .subscribeBy( onSuccess = { - mutableState.value = (Loaded(user, it, pages, scope)) + cachedSnippets = it + val updatedFilters = getLanguageFilters(cachedSnippets) + filterState = filterState.copy(languages = updatedFilters) + mutableState.value = Loaded( + user, + it, + pages, + filterState + ) loadNextPage() if (shouldRefresh) { mutableEvent.value = ListRefreshed @@ -143,7 +176,7 @@ class MainModel( private fun getScope(): SnippetScope { getLoadedState()?.let { - return it.scope + return it.filters.scope } return SnippetScope.ALL } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt index 156b632..db8ef0e 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt @@ -6,6 +6,7 @@ import pl.tkadziolka.snipmeandroid.bridge.Bridge import pl.tkadziolka.snipmeandroid.bridge.ModelPlugin import pl.tkadziolka.snipmeandroid.bridge.toModelData import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet +import pl.tkadziolka.snipmeandroid.domain.snippets.SnippetFilters import pl.tkadziolka.snipmeandroid.ui.main.* import pl.tkadziolka.snipmeandroid.util.view.SnippetFilter @@ -33,14 +34,8 @@ class MainModelPlugin : ModelPlugin(), Bridge.MainModelB model.initState() } - override fun loadNextPage() { - model.loadNextPage() - } - - override fun filter(filter: Bridge.SnippetFilter) { - val type = (filter.type?.name ?: Bridge.SnippetFilterType.ALL.name).uppercase() - val snippetFilter = SnippetFilter.valueOf(type) - model.filter(snippetFilter) + override fun filterLanguage(language: String, isSelected: Boolean) { + model.filterLanguage(language, isSelected) } override fun logOut() { @@ -56,6 +51,7 @@ class MainModelPlugin : ModelPlugin(), Bridge.MainModelB state = viewState.toModelState() is_loading = viewState is Loading data = (viewState as? Loaded)?.snippets?.toModelData() + filter = (viewState as? Loaded)?.filters?.toModelFilter() oldHash = oldState?.hashCode()?.toLong() newHash = viewState.hashCode().toLong() }.also { @@ -89,4 +85,12 @@ class MainModelPlugin : ModelPlugin(), Bridge.MainModelB } private fun List.toModelData() = map { it.toModelData() } + + private fun SnippetFilters.toModelFilter(): Bridge.SnippetFilter { + val it = this + return Bridge.SnippetFilter().apply { + languages = it.languages + selectedLanguages = it.selectedLanguages + } + } } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt index 36bab26..d12d7a1 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt @@ -9,6 +9,6 @@ import pl.tkadziolka.snipmeandroid.bridge.session.SessionModel internal val modelModule = module { single { SessionModel(get()) } single { LoginModel(get(), get(), get()) } - single { MainModel(get(), get(), get(), get(), get(), get(), get()) } + single { MainModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } single { DetailModel(get(), get(), get(), get(), get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt index ff8d837..2208ec5 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt @@ -13,6 +13,9 @@ import pl.tkadziolka.snipmeandroid.domain.share.ShareInteractor import pl.tkadziolka.snipmeandroid.domain.share.ShareSnippetCodeUseCase import pl.tkadziolka.snipmeandroid.domain.share.ShareSnippetUseCase import pl.tkadziolka.snipmeandroid.domain.snippet.* +import pl.tkadziolka.snipmeandroid.domain.filter.FilterSnippetsByLanguageUseCase +import pl.tkadziolka.snipmeandroid.domain.filter.GetLanguageFiltersUseCase +import pl.tkadziolka.snipmeandroid.domain.filter.UpdateSnippetFiltersLanguageUseCase import pl.tkadziolka.snipmeandroid.domain.snippets.GetSnippetsUseCase import pl.tkadziolka.snipmeandroid.domain.snippets.HasMoreSnippetPagesUseCase import pl.tkadziolka.snipmeandroid.domain.user.GetShareUsersUseCase @@ -52,6 +55,10 @@ internal val useCaseModule = module { factory { GetFromClipboardUseCase(get()) } // Save factory { SaveSnippetUseCase(get()) } + // Filter + factory { GetLanguageFiltersUseCase() } + factory { FilterSnippetsByLanguageUseCase() } + factory { UpdateSnippetFiltersLanguageUseCase() } } internal val interactorModule = module { diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/FilterSnippetsByLanguageUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/FilterSnippetsByLanguageUseCase.kt new file mode 100644 index 0000000..02625ec --- /dev/null +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/FilterSnippetsByLanguageUseCase.kt @@ -0,0 +1,10 @@ +package pl.tkadziolka.snipmeandroid.domain.filter + +import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet + +class FilterSnippetsByLanguageUseCase { + operator fun invoke(snippets: List, languages: List): List { + if (languages.contains(SNIPPET_LANGUAGE_FILTER_ALL)) return snippets + return snippets.filter { languages.contains(it.language.raw) } + } +} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/GetLanguageFiltersUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/GetLanguageFiltersUseCase.kt new file mode 100644 index 0000000..a9ab6fb --- /dev/null +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/GetLanguageFiltersUseCase.kt @@ -0,0 +1,18 @@ +package pl.tkadziolka.snipmeandroid.domain.filter + +import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet + +const val SNIPPET_LANGUAGE_FILTER_ALL = "All" + +class GetLanguageFiltersUseCase { + + operator fun invoke(snippets: List): List { + return listOf(SNIPPET_LANGUAGE_FILTER_ALL) + + snippets.groupBy { it.language.raw } + .map { it.key to it.value.count() } + .sortedBy { it.second }.reversed() + .map { it.first } + .filter { it.isNotBlank() } + .distinct() + } +} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/GetSnippetFiltersUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/GetSnippetFiltersUseCase.kt new file mode 100644 index 0000000..abbe62c --- /dev/null +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/GetSnippetFiltersUseCase.kt @@ -0,0 +1,7 @@ +package pl.tkadziolka.snipmeandroid.domain.filter + +class GetSnippetFiltersUseCase { + operator fun invoke() { + + } +} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/UpdateSnippetFiltersLanguageUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/UpdateSnippetFiltersLanguageUseCase.kt new file mode 100644 index 0000000..734a5cc --- /dev/null +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/UpdateSnippetFiltersLanguageUseCase.kt @@ -0,0 +1,24 @@ +package pl.tkadziolka.snipmeandroid.domain.filter + +import pl.tkadziolka.snipmeandroid.domain.snippets.SnippetFilters + +class UpdateSnippetFiltersLanguageUseCase { + + operator fun invoke( + filter: SnippetFilters, + language: String, + isSelected: Boolean + ): SnippetFilters = when { + language == SNIPPET_LANGUAGE_FILTER_ALL -> + filter.copy(selectedLanguages = listOf(SNIPPET_LANGUAGE_FILTER_ALL)) + isSelected.not() -> filter.copy(selectedLanguages = filter.selectedLanguages - language) + else -> + filter.copy( + selectedLanguages = ( + filter.selectedLanguages + - SNIPPET_LANGUAGE_FILTER_ALL + + language + ).distinct() + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippets/SnippetFilters.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippets/SnippetFilters.kt new file mode 100644 index 0000000..ade6398 --- /dev/null +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippets/SnippetFilters.kt @@ -0,0 +1,7 @@ +package pl.tkadziolka.snipmeandroid.domain.snippets + +data class SnippetFilters( + val languages: List, + val selectedLanguages: List, + val scope: SnippetScope +) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainViewModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainViewModel.kt index c0c705e..4eb5a11 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainViewModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainViewModel.kt @@ -9,10 +9,7 @@ import pl.tkadziolka.snipmeandroid.domain.error.exception.* import pl.tkadziolka.snipmeandroid.domain.message.ErrorMessages import pl.tkadziolka.snipmeandroid.domain.snippet.ObserveUpdatedSnippetPageUseCase import pl.tkadziolka.snipmeandroid.domain.snippet.ResetUpdatedSnippetPageUseCase -import pl.tkadziolka.snipmeandroid.domain.snippets.GetSnippetsUseCase -import pl.tkadziolka.snipmeandroid.domain.snippets.HasMoreSnippetPagesUseCase -import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet -import pl.tkadziolka.snipmeandroid.domain.snippets.SnippetScope +import pl.tkadziolka.snipmeandroid.domain.snippets.* import pl.tkadziolka.snipmeandroid.domain.user.GetSingleUserUseCase import pl.tkadziolka.snipmeandroid.domain.user.User import pl.tkadziolka.snipmeandroid.ui.error.ErrorParsable @@ -102,20 +99,20 @@ class MainViewModel( } fun loadNextPage() { - getLoadedState()?.let { state -> - hasMore(state.scope, state.pages) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy( - onSuccess = { hasMore -> - if (hasMore) loadSnippets(state.user, pages = state.pages + ONE_PAGE) - }, - onError = { - Timber.e("Couldn't check next page, error = $it") - mutableEvent.value = Alert(errorMessages.parse(it)) - }) - .also { disposables += it } - } +// getLoadedState()?.let { state -> +// hasMore(state.filters, state.pages) +// .subscribeOn(Schedulers.io()) +// .observeOn(AndroidSchedulers.mainThread()) +// .subscribeBy( +// onSuccess = { hasMore -> +// if (hasMore) loadSnippets(state.user, pages = state.pages + ONE_PAGE) +// }, +// onError = { +// Timber.e("Couldn't check next page, error = $it") +// mutableEvent.value = Alert(errorMessages.parse(it)) +// }) +// .also { disposables += it } +// } } fun filter(filter: SnippetFilter) { @@ -157,7 +154,7 @@ class MainViewModel( .observeOn(AndroidSchedulers.mainThread()) .subscribeBy( onSuccess = { - setState(Loaded(user, it, pages, scope)) +// setState(Loaded(user, it, pages)) if (shouldRefresh) { mutableEvent.value = ListRefreshed shouldRefresh = false @@ -181,7 +178,7 @@ class MainViewModel( private fun getScope(): SnippetScope { getLoadedState()?.let { - return it.scope +// return it.scope } return SnippetScope.ALL } @@ -193,7 +190,7 @@ data class Loaded( val user: User, val snippets: List, val pages: Int, - val scope: SnippetScope + val filters: SnippetFilters ) : MainViewState() data class Error(val message: String?) : MainViewState() diff --git a/flutter_module/bridge/main_model.dart b/flutter_module/bridge/main_model.dart index ed0e7d2..38ec0ff 100644 --- a/flutter_module/bridge/main_model.dart +++ b/flutter_module/bridge/main_model.dart @@ -90,7 +90,8 @@ enum SnippetLanguageType { enum SnippetFilterType { all, mine, shared } class SnippetFilter { - SnippetFilterType? type; + List? languages; + List? selectedLanguages; } enum UserReaction { @@ -113,6 +114,7 @@ class MainModelStateData { ModelState? state; bool? is_loading; List? data; + SnippetFilter? filter; String? error; int? oldHash; int? newHash; @@ -168,10 +170,7 @@ abstract class MainModelBridge { void initState(); @TaskQueue(type: TaskQueueType.serialBackgroundThread) - void loadNextPage(); - - @TaskQueue(type: TaskQueueType.serialBackgroundThread) - void filter(SnippetFilter filter); + void filterLanguage(String language, bool isSelected); @TaskQueue(type: TaskQueueType.serialBackgroundThread) void logOut(); diff --git a/flutter_module/lib/model/main_model.dart b/flutter_module/lib/model/main_model.dart index 731f5b6..4563eb8 100644 --- a/flutter_module/lib/model/main_model.dart +++ b/flutter_module/lib/model/main_model.dart @@ -274,23 +274,25 @@ class Owner { class SnippetFilter { SnippetFilter({ - this.type, + this.languages, + this.selectedLanguages, }); - SnippetFilterType? type; + List? languages; + List? selectedLanguages; Object encode() { final Map pigeonMap = {}; - pigeonMap['type'] = type?.index; + pigeonMap['languages'] = languages; + pigeonMap['selectedLanguages'] = selectedLanguages; return pigeonMap; } static SnippetFilter decode(Object message) { final Map pigeonMap = message as Map; return SnippetFilter( - type: pigeonMap['type'] != null - ? SnippetFilterType.values[pigeonMap['type']! as int] - : null, + languages: (pigeonMap['languages'] as List?)?.cast(), + selectedLanguages: (pigeonMap['selectedLanguages'] as List?)?.cast(), ); } } @@ -300,6 +302,7 @@ class MainModelStateData { this.state, this.is_loading, this.data, + this.filter, this.error, this.oldHash, this.newHash, @@ -308,6 +311,7 @@ class MainModelStateData { ModelState? state; bool? is_loading; List? data; + SnippetFilter? filter; String? error; int? oldHash; int? newHash; @@ -317,6 +321,7 @@ class MainModelStateData { pigeonMap['state'] = state?.index; pigeonMap['is_loading'] = is_loading; pigeonMap['data'] = data; + pigeonMap['filter'] = filter?.encode(); pigeonMap['error'] = error; pigeonMap['oldHash'] = oldHash; pigeonMap['newHash'] = newHash; @@ -331,6 +336,9 @@ class MainModelStateData { : null, is_loading: pigeonMap['is_loading'] as bool?, data: (pigeonMap['data'] as List?)?.cast(), + filter: pigeonMap['filter'] != null + ? SnippetFilter.decode(pigeonMap['filter']!) + : null, error: pigeonMap['error'] as String?, oldHash: pigeonMap['oldHash'] as int?, newHash: pigeonMap['newHash'] as int?, @@ -700,33 +708,11 @@ class MainModelBridge { } } - Future loadNextPage() async { - final BasicMessageChannel channel = BasicMessageChannel( - 'dev.flutter.pigeon.MainModelBridge.loadNextPage', codec, binaryMessenger: _binaryMessenger); - final Map? replyMap = - await channel.send(null) as Map?; - if (replyMap == null) { - throw PlatformException( - code: 'channel-error', - message: 'Unable to establish connection on channel.', - ); - } else if (replyMap['error'] != null) { - final Map error = (replyMap['error'] as Map?)!; - throw PlatformException( - code: (error['code'] as String?)!, - message: error['message'] as String?, - details: error['details'], - ); - } else { - return; - } - } - - Future filter(SnippetFilter arg_filter) async { + Future filterLanguage(String arg_language, bool arg_isSelected) async { final BasicMessageChannel channel = BasicMessageChannel( - 'dev.flutter.pigeon.MainModelBridge.filter', codec, binaryMessenger: _binaryMessenger); + 'dev.flutter.pigeon.MainModelBridge.filterLanguage', codec, binaryMessenger: _binaryMessenger); final Map? replyMap = - await channel.send([arg_filter]) as Map?; + await channel.send([arg_language, arg_isSelected]) as Map?; if (replyMap == null) { throw PlatformException( code: 'channel-error', diff --git a/flutter_module/lib/presentation/screens/main_screen.dart b/flutter_module/lib/presentation/screens/main_screen.dart index ca203e4..7d13204 100644 --- a/flutter_module/lib/presentation/screens/main_screen.dart +++ b/flutter_module/lib/presentation/screens/main_screen.dart @@ -93,11 +93,14 @@ class _MainPage extends HookWidget { navigator: detailsNavigator, model: model, snippets: snippets ?? List.empty(), + filter: state.filter ?? SnippetFilter(), ); }, ), floatingActionButton: FloatingActionButton.small( - onPressed: () => model.loadNextPage(), + onPressed: () { + // TODO Scroll to top + }, tooltip: 'Scroll to top', backgroundColor: ColorStyles.surfacePrimary(), child: const Icon( @@ -115,11 +118,13 @@ class _MainPageData extends StatelessWidget { required this.navigator, required this.model, required this.snippets, + required this.filter, }) : super(key: key); final DetailsNavigator navigator; final MainModelBridge model; final List snippets; + final SnippetFilter filter; @override Widget build(BuildContext context) { @@ -170,10 +175,13 @@ class _MainPageData extends StatelessWidget { children: [ Row(children: [const Text("Language")]), SizedBox( - height: 64, + height: Dimens.filterListHeight, child: FilterListView( - filters: ['a', 'b' * 200], - selected: ['a'], + filters: filter.languages ?? List.empty(), + selected: filter.selectedLanguages ?? List.empty(), + onSelected: (filter, isSelected) { + model.filterLanguage(filter, isSelected); + }, ), ), ], diff --git a/flutter_module/lib/presentation/styles/color_styles.dart b/flutter_module/lib/presentation/styles/color_styles.dart index 0c49d73..dc42a39 100644 --- a/flutter_module/lib/presentation/styles/color_styles.dart +++ b/flutter_module/lib/presentation/styles/color_styles.dart @@ -10,4 +10,5 @@ class ColorStyles extends Color { ColorStyles.codeBackground(): super(0xFFF8F8F8); + ColorStyles.filterBackgroundColor(): super(0xFF212121); } \ No newline at end of file diff --git a/flutter_module/lib/presentation/styles/dimens.dart b/flutter_module/lib/presentation/styles/dimens.dart index 3514a0c..c3c4954 100644 --- a/flutter_module/lib/presentation/styles/dimens.dart +++ b/flutter_module/lib/presentation/styles/dimens.dart @@ -6,4 +6,5 @@ class Dimens { static const xxl = 32.0; static const inputBorderWidth = 1.0; + static const filterListHeight = 64.0; } \ No newline at end of file diff --git a/flutter_module/lib/presentation/widgets/filter_list_view.dart b/flutter_module/lib/presentation/widgets/filter_list_view.dart index 5ced56d..758f4db 100644 --- a/flutter_module/lib/presentation/widgets/filter_list_view.dart +++ b/flutter_module/lib/presentation/widgets/filter_list_view.dart @@ -1,25 +1,38 @@ import 'package:flutter/material.dart'; +import 'package:flutter_module/presentation/styles/color_styles.dart'; +import 'package:flutter_module/presentation/styles/dimens.dart'; + +typedef FilterSelectedListener = Function(String filter, bool selected); class FilterListView extends StatelessWidget { const FilterListView({ Key? key, required this.filters, required this.selected, + this.onSelected, }) : super(key: key); - final List filters; - final List selected; + final List filters; + final List selected; + final FilterSelectedListener? onSelected; @override Widget build(BuildContext context) { - return ListView.builder( + print("Filters = $filters"); + print("Selected = $selected"); + return ListView.separated( + physics: const BouncingScrollPhysics(), itemCount: filters.length, scrollDirection: Axis.horizontal, + separatorBuilder: (_, __) => const SizedBox(width: Dimens.m,), itemBuilder: (BuildContext context, int index) { final filter = filters[index]; return ChoiceChip( - label: Text(filter), + disabledColor: ColorStyles.filterBackgroundColor().withOpacity(0.08), + selectedColor: ColorStyles.filterBackgroundColor().withOpacity(0.25), + label: Text(filter ?? ''), selected: selected.contains(filter), + onSelected: (isSelected) => onSelected?.call(filter ?? '', isSelected), ); }, ); From ea6a0a827cf5fca4c6e8234070a1f8beb5f911dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Fri, 16 Dec 2022 14:32:09 +0100 Subject: [PATCH 28/66] Removed unused use case --- .../domain/filter/GetSnippetFiltersUseCase.kt | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/GetSnippetFiltersUseCase.kt diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/GetSnippetFiltersUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/GetSnippetFiltersUseCase.kt deleted file mode 100644 index abbe62c..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/GetSnippetFiltersUseCase.kt +++ /dev/null @@ -1,7 +0,0 @@ -package pl.tkadziolka.snipmeandroid.domain.filter - -class GetSnippetFiltersUseCase { - operator fun invoke() { - - } -} \ No newline at end of file From 65624bbb5513c8ec05a551946ad485143205a8da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Sun, 18 Dec 2022 08:00:06 +0100 Subject: [PATCH 29/66] Added dropdown view --- .../lib/presentation/screens/main_screen.dart | 25 +++++++++++++- .../lib/presentation/styles/dimens.dart | 3 +- .../presentation/widgets/filter_dropdown.dart | 34 +++++++++++++++++++ .../widgets/filter_list_view.dart | 2 -- 4 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 flutter_module/lib/presentation/widgets/filter_dropdown.dart diff --git a/flutter_module/lib/presentation/screens/main_screen.dart b/flutter_module/lib/presentation/screens/main_screen.dart index 7d13204..29ba7f4 100644 --- a/flutter_module/lib/presentation/screens/main_screen.dart +++ b/flutter_module/lib/presentation/screens/main_screen.dart @@ -9,7 +9,9 @@ import 'package:flutter_module/presentation/navigation/login/login_navigator.dar import 'package:flutter_module/presentation/screens/named_screen.dart'; import 'package:flutter_module/presentation/styles/color_styles.dart'; import 'package:flutter_module/presentation/styles/dimens.dart'; +import 'package:flutter_module/presentation/styles/padding_styles.dart'; import 'package:flutter_module/presentation/styles/text_styles.dart'; +import 'package:flutter_module/presentation/widgets/filter_dropdown.dart'; import 'package:flutter_module/presentation/widgets/filter_list_view.dart'; import 'package:flutter_module/presentation/widgets/snippet_list_item.dart'; import 'package:flutter_module/presentation/widgets/view_state_wrapper.dart'; @@ -173,7 +175,28 @@ class _MainPageData extends StatelessWidget { background: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ - Row(children: [const Text("Language")]), + PaddingStyles.small( + Row(children: const [Text("Scope")]), + ), + Row( + children: [ + SizedBox( + height: Dimens.filterDropdownHeight, + child: FilterDropdown( + filters: ['a', 'b', 'c'], + selected: 'a', + // filters: filter.scopes, + // selected: filter.selectedScope, + onSelected: (item) { + + }, + ), + ), + ], + ), + PaddingStyles.small( + Row(children: const [Text("Language")]), + ), SizedBox( height: Dimens.filterListHeight, child: FilterListView( diff --git a/flutter_module/lib/presentation/styles/dimens.dart b/flutter_module/lib/presentation/styles/dimens.dart index c3c4954..079597d 100644 --- a/flutter_module/lib/presentation/styles/dimens.dart +++ b/flutter_module/lib/presentation/styles/dimens.dart @@ -6,5 +6,6 @@ class Dimens { static const xxl = 32.0; static const inputBorderWidth = 1.0; - static const filterListHeight = 64.0; + static const filterDropdownHeight = 32.0; + static const filterListHeight = 48.0; } \ No newline at end of file diff --git a/flutter_module/lib/presentation/widgets/filter_dropdown.dart b/flutter_module/lib/presentation/widgets/filter_dropdown.dart new file mode 100644 index 0000000..d6011b6 --- /dev/null +++ b/flutter_module/lib/presentation/widgets/filter_dropdown.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +typedef FilterSelectedItemListener = Function(String); + +class FilterDropdown extends StatelessWidget { + const FilterDropdown({ + Key? key, + required this.filters, + required this.selected, + this.onSelected, + }) : super(key: key); + + final List filters; + final String selected; + final FilterSelectedItemListener? onSelected; + + @override + Widget build(BuildContext context) { + return DropdownButtonHideUnderline( + child: DropdownButton( + onChanged: (filter) => onSelected?.call(filter ?? ''), + value: selected, + items: [ + // TODO Hardcoded list works, why? + ...filters.map( + (filter) => DropdownMenuItem( + child: Text(filter ?? ''), + ), + ) + ], + ), + ); + } +} diff --git a/flutter_module/lib/presentation/widgets/filter_list_view.dart b/flutter_module/lib/presentation/widgets/filter_list_view.dart index 758f4db..11e82e8 100644 --- a/flutter_module/lib/presentation/widgets/filter_list_view.dart +++ b/flutter_module/lib/presentation/widgets/filter_list_view.dart @@ -18,8 +18,6 @@ class FilterListView extends StatelessWidget { @override Widget build(BuildContext context) { - print("Filters = $filters"); - print("Selected = $selected"); return ListView.separated( physics: const BouncingScrollPhysics(), itemCount: filters.length, From c7b8b46f5d37d8a1cf842b744dff185a9d8feb01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Sun, 18 Dec 2022 11:52:40 +0100 Subject: [PATCH 30/66] Corrected design of app bar header --- .../lib/presentation/screens/main_screen.dart | 76 ++++++++++--------- .../lib/presentation/styles/dimens.dart | 3 +- .../lib/presentation/styles/text_styles.dart | 26 +++++-- .../presentation/widgets/filter_dropdown.dart | 8 +- 4 files changed, 67 insertions(+), 46 deletions(-) diff --git a/flutter_module/lib/presentation/screens/main_screen.dart b/flutter_module/lib/presentation/screens/main_screen.dart index 29ba7f4..05963f8 100644 --- a/flutter_module/lib/presentation/screens/main_screen.dart +++ b/flutter_module/lib/presentation/screens/main_screen.dart @@ -161,7 +161,7 @@ class _MainPageData extends StatelessWidget { SliverAppBar( floating: true, forceElevated: true, - expandedHeight: 120, + expandedHeight: Dimens.extendedAppBarHeight, elevation: Dimens.s / 2, backgroundColor: ColorStyles.surfacePrimary(), shape: const RoundedRectangleBorder( @@ -172,42 +172,50 @@ class _MainPageData extends StatelessWidget { ), flexibleSpace: FlexibleSpaceBar( collapseMode: CollapseMode.parallax, - background: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - PaddingStyles.small( - Row(children: const [Text("Scope")]), - ), - Row( - children: [ - SizedBox( - height: Dimens.filterDropdownHeight, - child: FilterDropdown( - filters: ['a', 'b', 'c'], - selected: 'a', - // filters: filter.scopes, - // selected: filter.selectedScope, - onSelected: (item) { + background: Padding( + padding: const EdgeInsets.symmetric(horizontal: Dimens.m), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + PaddingStyles.small( + Row(children: [TextStyles.bold("Scope")]), + ), + PaddingStyles.small( + Row( + children: [ + Expanded( + child: SizedBox( + height: Dimens.filterDropdownHeight, + child: FilterDropdown( + filters: const ['All', 'Private', 'Public', 'Others'], + selected: 'All', + // filters: filter.scopes, + // selected: filter.selectedScope, + onSelected: (item) { - }, - ), + }, + ), + ), + ), + ], ), - ], - ), - PaddingStyles.small( - Row(children: const [Text("Language")]), - ), - SizedBox( - height: Dimens.filterListHeight, - child: FilterListView( - filters: filter.languages ?? List.empty(), - selected: filter.selectedLanguages ?? List.empty(), - onSelected: (filter, isSelected) { - model.filterLanguage(filter, isSelected); - }, ), - ), - ], + PaddingStyles.small( + Row(children: [TextStyles.bold("Language")]), + ), + SizedBox( + height: Dimens.filterListHeight, + child: FilterListView( + filters: filter.languages ?? List.empty(), + selected: filter.selectedLanguages ?? List.empty(), + onSelected: (filter, isSelected) { + model.filterLanguage(filter, isSelected); + }, + ), + ), + const SizedBox(height: Dimens.m,) + ], + ), ), )) ]; diff --git a/flutter_module/lib/presentation/styles/dimens.dart b/flutter_module/lib/presentation/styles/dimens.dart index 079597d..a6eb885 100644 --- a/flutter_module/lib/presentation/styles/dimens.dart +++ b/flutter_module/lib/presentation/styles/dimens.dart @@ -6,6 +6,7 @@ class Dimens { static const xxl = 32.0; static const inputBorderWidth = 1.0; - static const filterDropdownHeight = 32.0; + static const filterDropdownHeight = 24.0; static const filterListHeight = 48.0; + static const extendedAppBarHeight = 128.0; } \ No newline at end of file diff --git a/flutter_module/lib/presentation/styles/text_styles.dart b/flutter_module/lib/presentation/styles/text_styles.dart index 00961c4..aaa035a 100644 --- a/flutter_module/lib/presentation/styles/text_styles.dart +++ b/flutter_module/lib/presentation/styles/text_styles.dart @@ -28,6 +28,16 @@ class TextStyles extends Text { style: TextStyle(color: color), ); + TextStyles.bold(this.text, {Key? key, Color? color}) + : super( + text, + key: key, + style: TextStyle( + color: color, + fontWeight: FontWeight.w600, + ), + ); + const TextStyles.secondary(this.text, {Key? key}) : super( text, @@ -72,12 +82,12 @@ class TextStyles extends Text { TextStyles.appBarLogo(this.text, {Key? key}) : super( - text, - key: key, - style: const TextStyle( - fontFamily: 'Kanit', - fontSize: 18.0, - color: Colors.black, - ), - ); + text, + key: key, + style: const TextStyle( + fontFamily: 'Kanit', + fontSize: 18.0, + color: Colors.black, + ), + ); } diff --git a/flutter_module/lib/presentation/widgets/filter_dropdown.dart b/flutter_module/lib/presentation/widgets/filter_dropdown.dart index d6011b6..cf1854e 100644 --- a/flutter_module/lib/presentation/widgets/filter_dropdown.dart +++ b/flutter_module/lib/presentation/widgets/filter_dropdown.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_module/presentation/styles/text_styles.dart'; typedef FilterSelectedItemListener = Function(String); @@ -18,13 +19,14 @@ class FilterDropdown extends StatelessWidget { Widget build(BuildContext context) { return DropdownButtonHideUnderline( child: DropdownButton( + isExpanded: true, onChanged: (filter) => onSelected?.call(filter ?? ''), value: selected, items: [ - // TODO Hardcoded list works, why? ...filters.map( - (filter) => DropdownMenuItem( - child: Text(filter ?? ''), + (filter) => DropdownMenuItem( + value: filter, + child: Center(child: TextStyles.title(filter ?? '')), ), ) ], From 3b5b201613d25d3ddcb5bf980ab6d5d9ccd5ab63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Sun, 18 Dec 2022 17:13:19 +0100 Subject: [PATCH 31/66] Completed simple filtering and corrected save button --- .../snipmeandroid/bridge/Bridge.java | 57 +++++++++++++++++++ .../snipmeandroid/bridge/main/MainModel.kt | 38 +++++-------- .../bridge/main/MainModelPlugin.kt | 7 ++- .../snipmeandroid/di/ModelModule.kt | 2 +- .../snipmeandroid/di/UseCaseModule.kt | 2 + .../filter/FilterSnippetsByLanguageUseCase.kt | 2 +- .../filter/FilterSnippetsByScopeUseCase.kt | 11 ++++ .../filter/GetLanguageFiltersUseCase.kt | 4 +- .../UpdateSnippetFiltersLanguageUseCase.kt | 6 +- .../domain/snippets/SnippetFilters.kt | 3 +- flutter_module/bridge/main_model.dart | 5 ++ flutter_module/lib/model/main_model.dart | 30 ++++++++++ .../lib/presentation/screens/main_screen.dart | 14 ++--- .../lib/presentation/styles/dimens.dart | 2 +- .../presentation/widgets/filter_dropdown.dart | 8 ++- .../widgets/snippet_action_bar.dart | 2 +- 16 files changed, 149 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/FilterSnippetsByScopeUseCase.kt diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java index 6f9dba0..8339682 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java @@ -575,6 +575,18 @@ public void setSelectedLanguages(@Nullable List setterArg) { this.selectedLanguages = setterArg; } + private @Nullable List scopes; + public @Nullable List getScopes() { return scopes; } + public void setScopes(@Nullable List setterArg) { + this.scopes = setterArg; + } + + private @Nullable String selectedScope; + public @Nullable String getSelectedScope() { return selectedScope; } + public void setSelectedScope(@Nullable String setterArg) { + this.selectedScope = setterArg; + } + public static final class Builder { private @Nullable List languages; public @NonNull Builder setLanguages(@Nullable List setterArg) { @@ -586,10 +598,22 @@ public static final class Builder { this.selectedLanguages = setterArg; return this; } + private @Nullable List scopes; + public @NonNull Builder setScopes(@Nullable List setterArg) { + this.scopes = setterArg; + return this; + } + private @Nullable String selectedScope; + public @NonNull Builder setSelectedScope(@Nullable String setterArg) { + this.selectedScope = setterArg; + return this; + } public @NonNull SnippetFilter build() { SnippetFilter pigeonReturn = new SnippetFilter(); pigeonReturn.setLanguages(languages); pigeonReturn.setSelectedLanguages(selectedLanguages); + pigeonReturn.setScopes(scopes); + pigeonReturn.setSelectedScope(selectedScope); return pigeonReturn; } } @@ -597,6 +621,8 @@ public static final class Builder { Map toMapResult = new HashMap<>(); toMapResult.put("languages", languages); toMapResult.put("selectedLanguages", selectedLanguages); + toMapResult.put("scopes", scopes); + toMapResult.put("selectedScope", selectedScope); return toMapResult; } static @NonNull SnippetFilter fromMap(@NonNull Map map) { @@ -605,6 +631,10 @@ public static final class Builder { pigeonResult.setLanguages((List)languages); Object selectedLanguages = map.get("selectedLanguages"); pigeonResult.setSelectedLanguages((List)selectedLanguages); + Object scopes = map.get("scopes"); + pigeonResult.setScopes((List)scopes); + Object selectedScope = map.get("selectedScope"); + pigeonResult.setSelectedScope((String)selectedScope); return pigeonResult; } } @@ -1218,6 +1248,7 @@ public interface MainModelBridge { void resetEvent(); void initState(); void filterLanguage(@NonNull String language, @NonNull Boolean isSelected); + void filterScope(@NonNull String scope); void logOut(); void refreshSnippetUpdates(); @@ -1333,6 +1364,32 @@ static void setup(BinaryMessenger binaryMessenger, MainModelBridge api) { channel.setMessageHandler(null); } } + { + BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.MainModelBridge.filterScope", getCodec(), taskQueue); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList)message; + assert args != null; + String scopeArg = (String)args.get(0); + if (scopeArg == null) { + throw new NullPointerException("scopeArg unexpectedly null."); + } + api.filterScope(scopeArg); + wrapped.put("result", null); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } { BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); BasicMessageChannel channel = diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt index 7395ba6..b5699f1 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt @@ -7,10 +7,7 @@ import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.flow.MutableStateFlow import pl.tkadziolka.snipmeandroid.bridge.session.SessionModel import pl.tkadziolka.snipmeandroid.domain.error.exception.* -import pl.tkadziolka.snipmeandroid.domain.filter.FilterSnippetsByLanguageUseCase -import pl.tkadziolka.snipmeandroid.domain.filter.GetLanguageFiltersUseCase -import pl.tkadziolka.snipmeandroid.domain.filter.SNIPPET_LANGUAGE_FILTER_ALL -import pl.tkadziolka.snipmeandroid.domain.filter.UpdateSnippetFiltersLanguageUseCase +import pl.tkadziolka.snipmeandroid.domain.filter.* import pl.tkadziolka.snipmeandroid.domain.message.ErrorMessages import pl.tkadziolka.snipmeandroid.domain.snippet.ObserveUpdatedSnippetPageUseCase import pl.tkadziolka.snipmeandroid.domain.snippet.ResetUpdatedSnippetPageUseCase @@ -19,7 +16,6 @@ import pl.tkadziolka.snipmeandroid.domain.user.GetSingleUserUseCase import pl.tkadziolka.snipmeandroid.domain.user.User import pl.tkadziolka.snipmeandroid.ui.error.ErrorParsable import pl.tkadziolka.snipmeandroid.ui.main.* -import pl.tkadziolka.snipmeandroid.util.view.SnippetFilter import timber.log.Timber private const val ONE_PAGE = 1 @@ -33,6 +29,7 @@ class MainModel( private val hasMore: HasMoreSnippetPagesUseCase, private val getLanguageFilters: GetLanguageFiltersUseCase, private val filterSnippetsByLanguage: FilterSnippetsByLanguageUseCase, + private val filterSnippetsByScope: FilterSnippetsByScopeUseCase, private val updateFilterLanguage: UpdateSnippetFiltersLanguageUseCase, private val session: SessionModel ) : ErrorParsable { @@ -47,9 +44,10 @@ class MainModel( private var cachedSnippets = emptyList() private var shouldRefresh = false private var filterState = SnippetFilters( - languages = listOf(SNIPPET_LANGUAGE_FILTER_ALL), - selectedLanguages = listOf(SNIPPET_LANGUAGE_FILTER_ALL), - scope = SnippetScope.ALL + languages = listOf(SNIPPET_FILTER_ALL), + selectedLanguages = listOf(SNIPPET_FILTER_ALL), + scopes = listOf("All", "Private", "Public"), + selectedScope = "All" ) override fun parseError(throwable: Throwable) { @@ -89,11 +87,13 @@ class MainModel( } } - fun filterScope(filter: SnippetFilter) { - val scope = filterToScope(filter) - getLoadedState()?.let { state -> - loadSnippets(state.user, pages = ONE_PAGE, scope = scope) - mutableEvent.value = ListRefreshed + fun filterScope(scope: String) { + getLoadedState()?.let { + filterState = filterState.copy(selectedScope = scope) + val filteredSnippets = filterSnippetsByScope(cachedSnippets, scope) + state.value = it.copy(snippets = filteredSnippets, filters = filterState) +// loadSnippets(state.user, pages = ONE_PAGE, scope = SnippetScope.ALL) +// mutableEvent.value = ListRefreshed } } @@ -118,7 +118,7 @@ class MainModel( private fun loadNextPage() { getLoadedState()?.let { state -> - hasMore(state.filters.scope, state.pages) + hasMore(SnippetScope.ALL, state.pages) .subscribeOn(Schedulers.io()) .subscribeBy( onSuccess = { hasMore -> @@ -167,17 +167,7 @@ class MainModel( private fun getLoadedState(): Loaded? = state.value as? Loaded - private fun filterToScope(filter: SnippetFilter) = - when (filter) { - SnippetFilter.ALL -> SnippetScope.ALL - SnippetFilter.MINE -> SnippetScope.OWNED - else -> SnippetScope.SHARED_FOR - } - private fun getScope(): SnippetScope { - getLoadedState()?.let { - return it.filters.scope - } return SnippetScope.ALL } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt index db8ef0e..bd66123 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt @@ -8,7 +8,6 @@ import pl.tkadziolka.snipmeandroid.bridge.toModelData import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet import pl.tkadziolka.snipmeandroid.domain.snippets.SnippetFilters import pl.tkadziolka.snipmeandroid.ui.main.* -import pl.tkadziolka.snipmeandroid.util.view.SnippetFilter class MainModelPlugin : ModelPlugin(), Bridge.MainModelBridge { private val model: MainModel by inject() @@ -38,6 +37,10 @@ class MainModelPlugin : ModelPlugin(), Bridge.MainModelB model.filterLanguage(language, isSelected) } + override fun filterScope(scope: String) { + model.filterScope(scope) + } + override fun logOut() { model.logOut() } @@ -91,6 +94,8 @@ class MainModelPlugin : ModelPlugin(), Bridge.MainModelB return Bridge.SnippetFilter().apply { languages = it.languages selectedLanguages = it.selectedLanguages + scopes = it.scopes + selectedScope = it.selectedScope } } } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt index d12d7a1..0025e11 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt @@ -9,6 +9,6 @@ import pl.tkadziolka.snipmeandroid.bridge.session.SessionModel internal val modelModule = module { single { SessionModel(get()) } single { LoginModel(get(), get(), get()) } - single { MainModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + single { MainModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } single { DetailModel(get(), get(), get(), get(), get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt index 2208ec5..9fa665a 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt @@ -14,6 +14,7 @@ import pl.tkadziolka.snipmeandroid.domain.share.ShareSnippetCodeUseCase import pl.tkadziolka.snipmeandroid.domain.share.ShareSnippetUseCase import pl.tkadziolka.snipmeandroid.domain.snippet.* import pl.tkadziolka.snipmeandroid.domain.filter.FilterSnippetsByLanguageUseCase +import pl.tkadziolka.snipmeandroid.domain.filter.FilterSnippetsByScopeUseCase import pl.tkadziolka.snipmeandroid.domain.filter.GetLanguageFiltersUseCase import pl.tkadziolka.snipmeandroid.domain.filter.UpdateSnippetFiltersLanguageUseCase import pl.tkadziolka.snipmeandroid.domain.snippets.GetSnippetsUseCase @@ -58,6 +59,7 @@ internal val useCaseModule = module { // Filter factory { GetLanguageFiltersUseCase() } factory { FilterSnippetsByLanguageUseCase() } + factory { FilterSnippetsByScopeUseCase() } factory { UpdateSnippetFiltersLanguageUseCase() } } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/FilterSnippetsByLanguageUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/FilterSnippetsByLanguageUseCase.kt index 02625ec..9aa3a9f 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/FilterSnippetsByLanguageUseCase.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/FilterSnippetsByLanguageUseCase.kt @@ -4,7 +4,7 @@ import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet class FilterSnippetsByLanguageUseCase { operator fun invoke(snippets: List, languages: List): List { - if (languages.contains(SNIPPET_LANGUAGE_FILTER_ALL)) return snippets + if (languages.contains(SNIPPET_FILTER_ALL)) return snippets return snippets.filter { languages.contains(it.language.raw) } } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/FilterSnippetsByScopeUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/FilterSnippetsByScopeUseCase.kt new file mode 100644 index 0000000..1ed7b11 --- /dev/null +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/FilterSnippetsByScopeUseCase.kt @@ -0,0 +1,11 @@ +package pl.tkadziolka.snipmeandroid.domain.filter + +import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet + +class FilterSnippetsByScopeUseCase { + operator fun invoke(snippets: List, scope: String): List { + if (scope == SNIPPET_FILTER_ALL) return snippets + + return snippets.filter { it.visibility.name.equals(scope, ignoreCase = true) } + } +} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/GetLanguageFiltersUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/GetLanguageFiltersUseCase.kt index a9ab6fb..4d1da57 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/GetLanguageFiltersUseCase.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/GetLanguageFiltersUseCase.kt @@ -2,12 +2,12 @@ package pl.tkadziolka.snipmeandroid.domain.filter import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet -const val SNIPPET_LANGUAGE_FILTER_ALL = "All" +const val SNIPPET_FILTER_ALL = "All" class GetLanguageFiltersUseCase { operator fun invoke(snippets: List): List { - return listOf(SNIPPET_LANGUAGE_FILTER_ALL) + + return listOf(SNIPPET_FILTER_ALL) + snippets.groupBy { it.language.raw } .map { it.key to it.value.count() } .sortedBy { it.second }.reversed() diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/UpdateSnippetFiltersLanguageUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/UpdateSnippetFiltersLanguageUseCase.kt index 734a5cc..0420835 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/UpdateSnippetFiltersLanguageUseCase.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/UpdateSnippetFiltersLanguageUseCase.kt @@ -9,14 +9,14 @@ class UpdateSnippetFiltersLanguageUseCase { language: String, isSelected: Boolean ): SnippetFilters = when { - language == SNIPPET_LANGUAGE_FILTER_ALL -> - filter.copy(selectedLanguages = listOf(SNIPPET_LANGUAGE_FILTER_ALL)) + language == SNIPPET_FILTER_ALL -> + filter.copy(selectedLanguages = listOf(SNIPPET_FILTER_ALL)) isSelected.not() -> filter.copy(selectedLanguages = filter.selectedLanguages - language) else -> filter.copy( selectedLanguages = ( filter.selectedLanguages - - SNIPPET_LANGUAGE_FILTER_ALL + - SNIPPET_FILTER_ALL + language ).distinct() ) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippets/SnippetFilters.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippets/SnippetFilters.kt index ade6398..1c8f3ca 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippets/SnippetFilters.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippets/SnippetFilters.kt @@ -3,5 +3,6 @@ package pl.tkadziolka.snipmeandroid.domain.snippets data class SnippetFilters( val languages: List, val selectedLanguages: List, - val scope: SnippetScope + val scopes: List, + val selectedScope: String ) diff --git a/flutter_module/bridge/main_model.dart b/flutter_module/bridge/main_model.dart index 38ec0ff..48fba79 100644 --- a/flutter_module/bridge/main_model.dart +++ b/flutter_module/bridge/main_model.dart @@ -92,6 +92,8 @@ enum SnippetFilterType { all, mine, shared } class SnippetFilter { List? languages; List? selectedLanguages; + List? scopes; + String? selectedScope; } enum UserReaction { @@ -172,6 +174,9 @@ abstract class MainModelBridge { @TaskQueue(type: TaskQueueType.serialBackgroundThread) void filterLanguage(String language, bool isSelected); + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + void filterScope(String scope); + @TaskQueue(type: TaskQueueType.serialBackgroundThread) void logOut(); diff --git a/flutter_module/lib/model/main_model.dart b/flutter_module/lib/model/main_model.dart index 4563eb8..fae1fdf 100644 --- a/flutter_module/lib/model/main_model.dart +++ b/flutter_module/lib/model/main_model.dart @@ -276,15 +276,21 @@ class SnippetFilter { SnippetFilter({ this.languages, this.selectedLanguages, + this.scopes, + this.selectedScope, }); List? languages; List? selectedLanguages; + List? scopes; + String? selectedScope; Object encode() { final Map pigeonMap = {}; pigeonMap['languages'] = languages; pigeonMap['selectedLanguages'] = selectedLanguages; + pigeonMap['scopes'] = scopes; + pigeonMap['selectedScope'] = selectedScope; return pigeonMap; } @@ -293,6 +299,8 @@ class SnippetFilter { return SnippetFilter( languages: (pigeonMap['languages'] as List?)?.cast(), selectedLanguages: (pigeonMap['selectedLanguages'] as List?)?.cast(), + scopes: (pigeonMap['scopes'] as List?)?.cast(), + selectedScope: pigeonMap['selectedScope'] as String?, ); } } @@ -730,6 +738,28 @@ class MainModelBridge { } } + Future filterScope(String arg_scope) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.MainModelBridge.filterScope', codec, binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_scope]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + Future logOut() async { final BasicMessageChannel channel = BasicMessageChannel( 'dev.flutter.pigeon.MainModelBridge.logOut', codec, binaryMessenger: _binaryMessenger); diff --git a/flutter_module/lib/presentation/screens/main_screen.dart b/flutter_module/lib/presentation/screens/main_screen.dart index 05963f8..4f45dc0 100644 --- a/flutter_module/lib/presentation/screens/main_screen.dart +++ b/flutter_module/lib/presentation/screens/main_screen.dart @@ -187,12 +187,10 @@ class _MainPageData extends StatelessWidget { child: SizedBox( height: Dimens.filterDropdownHeight, child: FilterDropdown( - filters: const ['All', 'Private', 'Public', 'Others'], - selected: 'All', - // filters: filter.scopes, - // selected: filter.selectedScope, - onSelected: (item) { - + filters: filter.scopes ?? List.empty(), + selected: filter.selectedScope ?? '', + onSelected: (scope) { + model.filterScope(scope); }, ), ), @@ -208,8 +206,8 @@ class _MainPageData extends StatelessWidget { child: FilterListView( filters: filter.languages ?? List.empty(), selected: filter.selectedLanguages ?? List.empty(), - onSelected: (filter, isSelected) { - model.filterLanguage(filter, isSelected); + onSelected: (language, isSelected) { + model.filterLanguage(language, isSelected); }, ), ), diff --git a/flutter_module/lib/presentation/styles/dimens.dart b/flutter_module/lib/presentation/styles/dimens.dart index a6eb885..c95f99c 100644 --- a/flutter_module/lib/presentation/styles/dimens.dart +++ b/flutter_module/lib/presentation/styles/dimens.dart @@ -8,5 +8,5 @@ class Dimens { static const inputBorderWidth = 1.0; static const filterDropdownHeight = 24.0; static const filterListHeight = 48.0; - static const extendedAppBarHeight = 128.0; + static const extendedAppBarHeight = 144.0; } \ No newline at end of file diff --git a/flutter_module/lib/presentation/widgets/filter_dropdown.dart b/flutter_module/lib/presentation/widgets/filter_dropdown.dart index cf1854e..435cb65 100644 --- a/flutter_module/lib/presentation/widgets/filter_dropdown.dart +++ b/flutter_module/lib/presentation/widgets/filter_dropdown.dart @@ -26,7 +26,13 @@ class FilterDropdown extends StatelessWidget { ...filters.map( (filter) => DropdownMenuItem( value: filter, - child: Center(child: TextStyles.title(filter ?? '')), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(width: 24,), + TextStyles.title(filter ?? ''), + ], + ), ), ) ], diff --git a/flutter_module/lib/presentation/widgets/snippet_action_bar.dart b/flutter_module/lib/presentation/widgets/snippet_action_bar.dart index 7864af9..fe1283a 100644 --- a/flutter_module/lib/presentation/widgets/snippet_action_bar.dart +++ b/flutter_module/lib/presentation/widgets/snippet_action_bar.dart @@ -65,7 +65,7 @@ class SnippetActionBar extends StatelessWidget { bool? isSaved, GestureTapCallback? onSaveTap, ) { - if (isSaved == null) return null; + if (isSaved == false) return null; if (isSaved == true) return null; return onSaveTap; } From f690990859fadc3cba1048217e9826340d99d44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Mon, 19 Dec 2022 07:07:42 +0100 Subject: [PATCH 32/66] Completed filter logic --- .../snipmeandroid/bridge/main/MainModel.kt | 15 ++++++++++----- .../domain/filter/FilterSnippetsByScopeUseCase.kt | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt index b5699f1..ea9b3de 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt @@ -42,6 +42,7 @@ class MainModel( val state = mutableState private var cachedSnippets = emptyList() + private var scopedSnippets = emptyList() private var shouldRefresh = false private var filterState = SnippetFilters( languages = listOf(SNIPPET_FILTER_ALL), @@ -82,7 +83,8 @@ class MainModel( fun filterLanguage(language: String, isSelected: Boolean) { getLoadedState()?.let { filterState = updateFilterLanguage(filterState, language, isSelected) - val filteredSnippets = filterSnippetsByLanguage(cachedSnippets, filterState.selectedLanguages) + val filteredSnippets = + filterSnippetsByLanguage(scopedSnippets, filterState.selectedLanguages) state.value = it.copy(snippets = filteredSnippets, filters = filterState) } } @@ -90,10 +92,13 @@ class MainModel( fun filterScope(scope: String) { getLoadedState()?.let { filterState = filterState.copy(selectedScope = scope) - val filteredSnippets = filterSnippetsByScope(cachedSnippets, scope) - state.value = it.copy(snippets = filteredSnippets, filters = filterState) -// loadSnippets(state.user, pages = ONE_PAGE, scope = SnippetScope.ALL) -// mutableEvent.value = ListRefreshed + scopedSnippets = filterSnippetsByScope(cachedSnippets, scope) + val updatedFilters = getLanguageFilters(scopedSnippets) + filterState = filterState.copy( + languages = updatedFilters, + selectedLanguages = listOf(SNIPPET_FILTER_ALL), + ) + state.value = it.copy(snippets = scopedSnippets, filters = filterState) } } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/FilterSnippetsByScopeUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/FilterSnippetsByScopeUseCase.kt index 1ed7b11..7bee4c6 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/FilterSnippetsByScopeUseCase.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/filter/FilterSnippetsByScopeUseCase.kt @@ -6,6 +6,6 @@ class FilterSnippetsByScopeUseCase { operator fun invoke(snippets: List, scope: String): List { if (scope == SNIPPET_FILTER_ALL) return snippets - return snippets.filter { it.visibility.name.equals(scope, ignoreCase = true) } + return snippets.filter { it.isOwner && it.visibility.name.equals(scope, ignoreCase = true) } } } \ No newline at end of file From 839d9f642d419cb99fbc1d9d8b0a6546311b6476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Mon, 19 Dec 2022 07:26:10 +0100 Subject: [PATCH 33/66] Corrected initial filtering --- .../java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt index ea9b3de..4c35908 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt @@ -149,6 +149,7 @@ class MainModel( .subscribeBy( onSuccess = { cachedSnippets = it + scopedSnippets = cachedSnippets val updatedFilters = getLanguageFilters(cachedSnippets) filterState = filterState.copy(languages = updatedFilters) mutableState.value = Loaded( From 53d6d9a16d7187b4f45062fe283ae2fb0fec3263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Tue, 20 Dec 2022 20:16:39 +0100 Subject: [PATCH 34/66] Handled scroll to top and expand item --- .../lib/presentation/screens/main_screen.dart | 56 +++++++++++++------ .../lib/presentation/styles/dimens.dart | 2 + .../widgets/snippet_list_item.dart | 3 +- 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/flutter_module/lib/presentation/screens/main_screen.dart b/flutter_module/lib/presentation/screens/main_screen.dart index 4f45dc0..bc2cb20 100644 --- a/flutter_module/lib/presentation/screens/main_screen.dart +++ b/flutter_module/lib/presentation/screens/main_screen.dart @@ -1,4 +1,3 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -59,6 +58,7 @@ class _MainPage extends HookWidget { @override Widget build(BuildContext context) { useNavigator([loginNavigator, detailsNavigator]); + final state = useObservableState( MainModelStateData(), () => model.getState(), @@ -72,6 +72,9 @@ class _MainPage extends HookWidget { (current, newState) => (current as MainModelEventData).equals(newState), ).value; + final expandedState = useState(true); + final controller = useScrollController(); + useEffect(() { model.initState(); }, []); @@ -96,12 +99,19 @@ class _MainPage extends HookWidget { model: model, snippets: snippets ?? List.empty(), filter: state.filter ?? SnippetFilter(), + controller: controller, + expanded: expandedState.value, + onExpandChange: (expanded) => expandedState.value = expanded, ); }, ), floatingActionButton: FloatingActionButton.small( onPressed: () { - // TODO Scroll to top + controller.animateTo( + 0.0, + duration: const Duration(seconds: 1), + curve: const ElasticInCurve(), + ); }, tooltip: 'Scroll to top', backgroundColor: ColorStyles.surfacePrimary(), @@ -114,23 +124,32 @@ class _MainPage extends HookWidget { } } -class _MainPageData extends StatelessWidget { - const _MainPageData({ - Key? key, - required this.navigator, - required this.model, - required this.snippets, - required this.filter, - }) : super(key: key); +typedef ExpandChangeListener = Function(bool); + +class _MainPageData extends HookWidget { + const _MainPageData( + {Key? key, + required this.navigator, + required this.model, + required this.snippets, + required this.filter, + required this.controller, + required this.expanded, + required this.onExpandChange}) + : super(key: key); final DetailsNavigator navigator; final MainModelBridge model; final List snippets; final SnippetFilter filter; + final ScrollController controller; + final bool expanded; + final ExpandChangeListener onExpandChange; @override Widget build(BuildContext context) { return NestedScrollView( + controller: controller, floatHeaderSlivers: true, headerSliverBuilder: (_, __) { return [ @@ -138,7 +157,7 @@ class _MainPageData extends StatelessWidget { elevation: 0.0, centerTitle: true, title: Row(mainAxisSize: MainAxisSize.min, children: [ - Image.asset(Assets.appLogo, width: 18.0), + Image.asset(Assets.appLogo, width: Dimens.logoSignetSize), const SizedBox(width: Dimens.m), TextStyles.appBarLogo('SnipMe'), ]), @@ -150,11 +169,13 @@ class _MainPageData extends StatelessWidget { ), actions: [ IconButton( - icon: const Icon(Icons.close_fullscreen_outlined), + icon: Icon( + expanded + ? Icons.close_fullscreen_outlined + : Icons.open_in_full_outlined, + ), color: Colors.black, - onPressed: () { - // TODO Handle collapse items - }, + onPressed: () => onExpandChange(!expanded), ), ], ), @@ -211,7 +232,9 @@ class _MainPageData extends StatelessWidget { }, ), ), - const SizedBox(height: Dimens.m,) + const SizedBox( + height: Dimens.m, + ) ], ), ), @@ -233,6 +256,7 @@ class _MainPageData extends StatelessWidget { horizontal: Dimens.m, ), child: SnippetListTile( + isExpanded: expanded, snippet: snippet, onTap: () { navigator.goToDetails(context, snippet.uuid!); diff --git a/flutter_module/lib/presentation/styles/dimens.dart b/flutter_module/lib/presentation/styles/dimens.dart index c95f99c..b3b3966 100644 --- a/flutter_module/lib/presentation/styles/dimens.dart +++ b/flutter_module/lib/presentation/styles/dimens.dart @@ -9,4 +9,6 @@ class Dimens { static const filterDropdownHeight = 24.0; static const filterListHeight = 48.0; static const extendedAppBarHeight = 144.0; + + static const logoSignetSize = 18.0; } \ No newline at end of file diff --git a/flutter_module/lib/presentation/widgets/snippet_list_item.dart b/flutter_module/lib/presentation/widgets/snippet_list_item.dart index 88fb028..2e95810 100644 --- a/flutter_module/lib/presentation/widgets/snippet_list_item.dart +++ b/flutter_module/lib/presentation/widgets/snippet_list_item.dart @@ -13,11 +13,12 @@ class SnippetListTile extends HookWidget { Key? key, required this.snippet, required this.onTap, + this.isExpanded = true, }) : super(key: key); - final bool isExpanded = true; final Snippet snippet; final GestureTapCallback? onTap; + final bool isExpanded; @override Widget build(BuildContext context) { From c5d1b340b2ba116bd1e8ca6ff33ad73b2f402738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Fri, 23 Dec 2022 07:33:52 +0100 Subject: [PATCH 35/66] Created logic for deleting snippet --- .../snipmeandroid/bridge/Bridge.java | 38 ++++++++++++++----- .../bridge/detail/DetailModel.kt | 20 +++++++++- .../repository/snippet/SnippetRepository.kt | 2 + .../snippet/SnippetRepositoryReal.kt | 3 ++ .../domain/snippet/DeleteSnippetUseCase.kt | 8 ++++ .../infrastructure/remote/SnippetService.kt | 8 +++- .../ui/detail/DetailViewModel.kt | 1 + flutter_module/bridge/main_model.dart | 4 +- flutter_module/lib/model/main_model.dart | 23 +++++++++++ .../presentation/screens/details_screen.dart | 16 ++++++-- 10 files changed, 107 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/DeleteSnippetUseCase.kt diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java index 8339682..48704a0 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java @@ -4,21 +4,20 @@ package pl.tkadziolka.snipmeandroid.bridge; import android.util.Log; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; - +import io.flutter.plugin.common.BasicMessageChannel; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MessageCodec; +import io.flutter.plugin.common.StandardMessageCodec; import java.io.ByteArrayOutputStream; import java.nio.ByteBuffer; +import java.util.Arrays; import java.util.ArrayList; -import java.util.HashMap; +import java.util.Collections; import java.util.List; import java.util.Map; - -import io.flutter.plugin.common.BasicMessageChannel; -import io.flutter.plugin.common.BinaryMessenger; -import io.flutter.plugin.common.MessageCodec; -import io.flutter.plugin.common.StandardMessageCodec; +import java.util.HashMap; /**Generated class from Pigeon. */ @SuppressWarnings({"unused", "unchecked", "CodeBlock2Expr", "RedundantSuppression"}) @@ -123,7 +122,8 @@ private MainModelEvent(final int index) { public enum DetailModelEvent { NONE(0), - SAVED(1); + SAVED(1), + DELETED(2); private int index; private DetailModelEvent(final int index) { @@ -1511,6 +1511,7 @@ public interface DetailModelBridge { void save(); void copyToClipboard(); void share(); + void delete(); /** The codec used by DetailModelBridge. */ static MessageCodec getCodec() { @@ -1694,6 +1695,25 @@ static void setup(BinaryMessenger binaryMessenger, DetailModelBridge api) { channel.setMessageHandler(null); } } + { + BasicMessageChannel channel = + new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.DetailModelBridge.delete", getCodec()); + if (api != null) { + channel.setMessageHandler((message, reply) -> { + Map wrapped = new HashMap<>(); + try { + api.delete(); + wrapped.put("result", null); + } + catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } } } private static class LoginModelBridgeCodec extends StandardMessageCodec { diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt index 339d3ce..aaef309 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt @@ -13,6 +13,7 @@ import pl.tkadziolka.snipmeandroid.domain.reaction.GetTargetUserReactionUseCase import pl.tkadziolka.snipmeandroid.domain.reaction.SetUserReactionUseCase import pl.tkadziolka.snipmeandroid.domain.reaction.UserReaction import pl.tkadziolka.snipmeandroid.domain.share.ShareSnippetCodeUseCase +import pl.tkadziolka.snipmeandroid.domain.snippet.DeleteSnippetUseCase import pl.tkadziolka.snipmeandroid.domain.snippet.GetSingleSnippetUseCase import pl.tkadziolka.snipmeandroid.domain.snippet.SaveSnippetUseCase import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet @@ -28,6 +29,7 @@ class DetailModel( private val setUserReaction: SetUserReactionUseCase, private val saveSnippet: SaveSnippetUseCase, private val shareSnippet: ShareSnippetCodeUseCase, + private val deleteSnippet: DeleteSnippetUseCase private val session: SessionModel ) : ErrorParsable { private val disposables = CompositeDisposable() @@ -87,7 +89,6 @@ class DetailModel( onSuccess = { saved -> setState(Loaded(it)) mutableEvent.value = Saved(saved.uuid) - }, onError = { error -> Timber.e("Couldn't save snippet, error = $error") @@ -103,6 +104,23 @@ class DetailModel( } } + fun delete() { + getSnippet()?.let { + setState(Loading) + deleteSnippet(it.uuid) + .subscribeOn(Schedulers.io()) + .subscribeBy( + onComplete = { + mutableEvent.value = Deleted + }, + onError = { error -> + Timber.e("Couldn't save snippet, error = $error") + parseError(error) + } + ).also { disposables += it } + } + } + private fun changeReaction(newReaction: UserReaction) { // Immediately show change to user val previousState = getLoaded() ?: return diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepository.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepository.kt index ca088a2..d1d6d85 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepository.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepository.kt @@ -34,4 +34,6 @@ interface SnippetRepository { fun count(scope: SnippetScope): Single fun reaction(uuid: String, reaction: UserReaction): Completable + + fun delete(uuid: String): Completable } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepositoryReal.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepositoryReal.kt index f685356..3a58677 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepositoryReal.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepositoryReal.kt @@ -1,5 +1,6 @@ package pl.tkadziolka.snipmeandroid.domain.repository.snippet +import io.reactivex.Completable import io.reactivex.Single import io.reactivex.subjects.BehaviorSubject import pl.tkadziolka.snipmeandroid.domain.error.ErrorHandler @@ -55,6 +56,8 @@ class SnippetRepositoryReal( .mapError { errorHandler.handle(it) } .map { mapper(it) } + override fun delete(uuid: String): Completable = service.delete(uuid) + override fun count(scope: SnippetScope) = if (count != null) { Single.just(count!!) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/DeleteSnippetUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/DeleteSnippetUseCase.kt new file mode 100644 index 0000000..a87f537 --- /dev/null +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/DeleteSnippetUseCase.kt @@ -0,0 +1,8 @@ +package pl.tkadziolka.snipmeandroid.domain.snippet + +import pl.tkadziolka.snipmeandroid.domain.repository.snippet.SnippetRepository + +class DeleteSnippetUseCase(private val repository: SnippetRepository) { + + operator fun invoke(uuid: String) = repository.delete(uuid) +} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/infrastructure/remote/SnippetService.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/infrastructure/remote/SnippetService.kt index 87c5f80..a249816 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/infrastructure/remote/SnippetService.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/infrastructure/remote/SnippetService.kt @@ -26,8 +26,14 @@ interface SnippetService { fun create(@Body request: CreateSnippetRequest): Single @PUT("snippet/{$PATH_ID}/") - fun update(@Path(PATH_ID) id: String, @Body request: CreateSnippetRequest): Single + fun update( + @Path(PATH_ID) id: String, + @Body request: CreateSnippetRequest + ): Single @POST("snippet-rate/") fun rate(@Body request: RateSnippetRequest): Completable + + @DELETE("snippet/{$PATH_ID}/") + fun delete(@Path(PATH_ID) id: String): Completable } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailViewModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailViewModel.kt index a328cfd..9b3b611 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailViewModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailViewModel.kt @@ -126,6 +126,7 @@ data class Error(val error: String?) : DetailViewState() sealed class DetailEvent object Idle : DetailEvent() +object Deleted : DetailEvent() data class Alert(val message: String) : DetailEvent() data class Saved(val snippetId: String) : DetailEvent() object Logout : DetailEvent() \ No newline at end of file diff --git a/flutter_module/bridge/main_model.dart b/flutter_module/bridge/main_model.dart index 48fba79..012b850 100644 --- a/flutter_module/bridge/main_model.dart +++ b/flutter_module/bridge/main_model.dart @@ -108,7 +108,7 @@ enum ModelState { loading, loaded, error } enum MainModelEvent { none, alert, logout } -enum DetailModelEvent { none, saved } +enum DetailModelEvent { none, saved, deleted } enum LoginModelEvent { none, logged } @@ -203,6 +203,8 @@ abstract class DetailModelBridge { void copyToClipboard(); void share(); + + void delete(); } @HostApi() diff --git a/flutter_module/lib/model/main_model.dart b/flutter_module/lib/model/main_model.dart index fae1fdf..aebad03 100644 --- a/flutter_module/lib/model/main_model.dart +++ b/flutter_module/lib/model/main_model.dart @@ -82,6 +82,7 @@ enum MainModelEvent { enum DetailModelEvent { none, saved, + deleted, } enum LoginModelEvent { @@ -1088,6 +1089,28 @@ class DetailModelBridge { return; } } + + Future delete() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.DetailModelBridge.delete', codec, binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send(null) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyMap['error'] != null) { + final Map error = (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } } class _LoginModelBridgeCodec extends StandardMessageCodec{ diff --git a/flutter_module/lib/presentation/screens/details_screen.dart b/flutter_module/lib/presentation/screens/details_screen.dart index 18dbcaa..6244ac1 100644 --- a/flutter_module/lib/presentation/screens/details_screen.dart +++ b/flutter_module/lib/presentation/screens/details_screen.dart @@ -65,14 +65,17 @@ class _DetailsPage extends HookWidget { if (event.event == DetailModelEvent.saved) { final snippetId = event.value; if (snippetId == null) { - navigator.back(); - model.resetEvent(); + _exit(); return const SizedBox(); } - navigator.back(); + _exit(); navigator.goToDetails(context, snippetId); - model.resetEvent(); + } + + if (event.event == DetailModelEvent.deleted) { + _exit(); + return const SizedBox(); } useEffect(() { @@ -107,6 +110,11 @@ class _DetailsPage extends HookWidget { ), ); } + + void _exit() { + navigator.back(); + model.resetEvent(); + } } class _DetailPageData extends StatelessWidget { From b05b2b4f98de0955a534d759d7c893a10db7d43b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Fri, 23 Dec 2022 08:18:16 +0100 Subject: [PATCH 36/66] Added delete button and corrected updates --- .../snipmeandroid/bridge/Bridge.java | 36 +++++++---------- .../snipmeandroid/bridge/ModelPlugin.kt | 1 + .../bridge/detail/DetailModel.kt | 3 +- .../bridge/detail/DetailModelPlugin.kt | 5 +++ .../snipmeandroid/bridge/main/MainModel.kt | 40 +++++++++---------- .../bridge/main/MainModelPlugin.kt | 4 -- .../snipmeandroid/di/ModelModule.kt | 2 +- .../snipmeandroid/di/UseCaseModule.kt | 1 + .../snippet/SnippetRepositoryTest.kt | 5 ++- .../domain/snippet/DeleteSnippetUseCase.kt | 6 ++- flutter_module/bridge/main_model.dart | 4 +- flutter_module/lib/model/main_model.dart | 26 ++---------- .../presentation/screens/details_screen.dart | 2 +- .../widgets/snippet_action_bar.dart | 9 +++++ .../lib/presentation/widgets/state_icon.dart | 8 ++-- 15 files changed, 72 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java index 48704a0..3e6ab2f 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/Bridge.java @@ -221,6 +221,12 @@ public void setIsSaved(@Nullable Boolean setterArg) { this.isSaved = setterArg; } + private @Nullable Boolean isToDelete; + public @Nullable Boolean getIsToDelete() { return isToDelete; } + public void setIsToDelete(@Nullable Boolean setterArg) { + this.isToDelete = setterArg; + } + public static final class Builder { private @Nullable String uuid; public @NonNull Builder setUuid(@Nullable String setterArg) { @@ -287,6 +293,11 @@ public static final class Builder { this.isSaved = setterArg; return this; } + private @Nullable Boolean isToDelete; + public @NonNull Builder setIsToDelete(@Nullable Boolean setterArg) { + this.isToDelete = setterArg; + return this; + } public @NonNull Snippet build() { Snippet pigeonReturn = new Snippet(); pigeonReturn.setUuid(uuid); @@ -302,6 +313,7 @@ public static final class Builder { pigeonReturn.setIsLiked(isLiked); pigeonReturn.setIsDisliked(isDisliked); pigeonReturn.setIsSaved(isSaved); + pigeonReturn.setIsToDelete(isToDelete); return pigeonReturn; } } @@ -320,6 +332,7 @@ public static final class Builder { toMapResult.put("isLiked", isLiked); toMapResult.put("isDisliked", isDisliked); toMapResult.put("isSaved", isSaved); + toMapResult.put("isToDelete", isToDelete); return toMapResult; } static @NonNull Snippet fromMap(@NonNull Map map) { @@ -350,6 +363,8 @@ public static final class Builder { pigeonResult.setIsDisliked((Boolean)isDisliked); Object isSaved = map.get("isSaved"); pigeonResult.setIsSaved((Boolean)isSaved); + Object isToDelete = map.get("isToDelete"); + pigeonResult.setIsToDelete((Boolean)isToDelete); return pigeonResult; } } @@ -1250,7 +1265,6 @@ public interface MainModelBridge { void filterLanguage(@NonNull String language, @NonNull Boolean isSelected); void filterScope(@NonNull String scope); void logOut(); - void refreshSnippetUpdates(); /** The codec used by MainModelBridge. */ static MessageCodec getCodec() { @@ -1410,26 +1424,6 @@ static void setup(BinaryMessenger binaryMessenger, MainModelBridge api) { channel.setMessageHandler(null); } } - { - BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); - BasicMessageChannel channel = - new BasicMessageChannel<>(binaryMessenger, "dev.flutter.pigeon.MainModelBridge.refreshSnippetUpdates", getCodec(), taskQueue); - if (api != null) { - channel.setMessageHandler((message, reply) -> { - Map wrapped = new HashMap<>(); - try { - api.refreshSnippetUpdates(); - wrapped.put("result", null); - } - catch (Error | RuntimeException exception) { - wrapped.put("error", wrapError(exception)); - } - reply.reply(wrapped); - }); - } else { - channel.setMessageHandler(null); - } - } } } private static class DetailModelBridgeCodec extends StandardMessageCodec { diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/ModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/ModelPlugin.kt index 7cbf958..46a46d0 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/ModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/ModelPlugin.kt @@ -47,6 +47,7 @@ fun Snippet.toModelData(): Bridge.Snippet { isDisliked = it.userReaction.toModelReactionState(UserReaction.DISLIKE) isPrivate = it.visibility == SnippetVisibility.PRIVATE isSaved = calculateSavedState(it.isOwner, it.visibility) + isToDelete = it.isOwner timeAgo = DateUtils.getRelativeTimeSpanString( it.modifiedAt.time, Date().time, diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt index aaef309..6971c51 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt @@ -29,7 +29,7 @@ class DetailModel( private val setUserReaction: SetUserReactionUseCase, private val saveSnippet: SaveSnippetUseCase, private val shareSnippet: ShareSnippetCodeUseCase, - private val deleteSnippet: DeleteSnippetUseCase + private val deleteSnippet: DeleteSnippetUseCase, private val session: SessionModel ) : ErrorParsable { private val disposables = CompositeDisposable() @@ -116,6 +116,7 @@ class DetailModel( onError = { error -> Timber.e("Couldn't save snippet, error = $error") parseError(error) + mutableEvent.value = Deleted } ).also { disposables += it } } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt index f4b2eee..d1ef323 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModelPlugin.kt @@ -49,6 +49,10 @@ class DetailModelPlugin : ModelPlugin(), Bridge.Detail model.share() } + override fun delete() { + model.delete() + } + private fun getData(viewState: DetailViewState): Bridge.DetailModelStateData { return Bridge.DetailModelStateData().apply { state = viewState.toModelState() @@ -82,6 +86,7 @@ class DetailModelPlugin : ModelPlugin(), Bridge.Detail private fun DetailEvent.toModelEvent() = when (this) { is Saved -> Bridge.DetailModelEvent.SAVED + is Deleted -> Bridge.DetailModelEvent.DELETED else -> Bridge.DetailModelEvent.NONE } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt index 4c35908..557033f 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt @@ -25,7 +25,6 @@ class MainModel( private val getUser: GetSingleUserUseCase, private val getSnippets: GetSnippetsUseCase, private val observeUpdatedPage: ObserveUpdatedSnippetPageUseCase, - private val resetUpdatedPage: ResetUpdatedSnippetPageUseCase, private val hasMore: HasMoreSnippetPagesUseCase, private val getLanguageFilters: GetLanguageFiltersUseCase, private val filterSnippetsByLanguage: FilterSnippetsByLanguageUseCase, @@ -44,12 +43,7 @@ class MainModel( private var cachedSnippets = emptyList() private var scopedSnippets = emptyList() private var shouldRefresh = false - private var filterState = SnippetFilters( - languages = listOf(SNIPPET_FILTER_ALL), - selectedLanguages = listOf(SNIPPET_FILTER_ALL), - scopes = listOf("All", "Private", "Public"), - selectedScope = "All" - ) + private lateinit var filterState: SnippetFilters; override fun parseError(throwable: Throwable) { when (throwable) { @@ -67,8 +61,25 @@ class MainModel( } } + init { + observeUpdatedPage(getScope()) + .subscribeOn(Schedulers.io()) + .subscribeBy( + onNext = { updatedPage -> + initState() + }, + onError = { Timber.e("Couldn't refresh snippet updates, error = $it") } + ).also { disposables += it } + } + fun initState() { mutableState.value = Loading + filterState = SnippetFilters( + languages = listOf(SNIPPET_FILTER_ALL), + selectedLanguages = listOf(SNIPPET_FILTER_ALL), + scopes = listOf("All", "Private", "Public"), + selectedScope = "All" + ) getUser() .subscribeOn(Schedulers.io()) .subscribeBy( @@ -106,21 +117,6 @@ class MainModel( session.logOut { mutableEvent.value = Logout } } - fun refreshSnippetUpdates() { - getLoadedState()?.let { - observeUpdatedPage(getScope()) - .subscribeOn(Schedulers.io()) - .subscribeBy( - onNext = { updatedPage -> - shouldRefresh = true - loadSnippets(it.user, updatedPage, getScope()) - resetUpdatedPage() - }, - onError = { Timber.e("Couldn't refresh snippet updates, error = $it") } - ).also { disposables += it } - } - } - private fun loadNextPage() { getLoadedState()?.let { state -> hasMore(SnippetScope.ALL, state.pages) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt index bd66123..ad6ca24 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModelPlugin.kt @@ -45,10 +45,6 @@ class MainModelPlugin : ModelPlugin(), Bridge.MainModelB model.logOut() } - override fun refreshSnippetUpdates() { - model.refreshSnippetUpdates() - } - private fun getState(viewState: MainViewState): Bridge.MainModelStateData { return Bridge.MainModelStateData().apply { state = viewState.toModelState() diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt index 0025e11..955874b 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt @@ -10,5 +10,5 @@ internal val modelModule = module { single { SessionModel(get()) } single { LoginModel(get(), get(), get()) } single { MainModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } - single { DetailModel(get(), get(), get(), get(), get(), get(), get(), get()) } + single { DetailModel(get(), get(), get(), get(), get(), get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt index 9fa665a..dc31770 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt @@ -44,6 +44,7 @@ internal val useCaseModule = module { factory { ResetUpdatedSnippetPageUseCase(get()) } factory { GetTargetUserReactionUseCase() } factory { SetUserReactionUseCase(get(), get(), get()) } + factory { DeleteSnippetUseCase(get()) } // Language factory { GetLanguagesUseCase(get(), get(), get()) } // Share diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepositoryTest.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepositoryTest.kt index 39d050e..00af9d1 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepositoryTest.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/repository/snippet/SnippetRepositoryTest.kt @@ -91,7 +91,10 @@ class SnippetRepositoryTest(private val errorHandler: ErrorHandler) : SnippetRep override fun count(scope: SnippetScope): Single = Single.just(list.size) - override fun reaction(uuid: String, reaction: UserReaction): Completable = Completable.complete() + override fun reaction(uuid: String, reaction: UserReaction): Completable = + Completable.complete() + + override fun delete(uuid: String): Completable = Completable.complete() private fun getPreview(code: String): SpannableString { val preview = code.lines(PREVIEW_COUNT).joinToString(separator = newLineChar) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/DeleteSnippetUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/DeleteSnippetUseCase.kt index a87f537..8cb6d76 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/DeleteSnippetUseCase.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/DeleteSnippetUseCase.kt @@ -1,8 +1,12 @@ package pl.tkadziolka.snipmeandroid.domain.snippet import pl.tkadziolka.snipmeandroid.domain.repository.snippet.SnippetRepository +import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet class DeleteSnippetUseCase(private val repository: SnippetRepository) { - operator fun invoke(uuid: String) = repository.delete(uuid) + operator fun invoke(uuid: String) = + repository + .delete(uuid) + .andThen { repository.updateListener.onNext(Snippet.EMPTY) } } \ No newline at end of file diff --git a/flutter_module/bridge/main_model.dart b/flutter_module/bridge/main_model.dart index 012b850..0cc8750 100644 --- a/flutter_module/bridge/main_model.dart +++ b/flutter_module/bridge/main_model.dart @@ -16,6 +16,7 @@ class Snippet { bool? isLiked; bool? isDisliked; bool? isSaved; + bool? isToDelete; } class SnippetCode { @@ -179,9 +180,6 @@ abstract class MainModelBridge { @TaskQueue(type: TaskQueueType.serialBackgroundThread) void logOut(); - - @TaskQueue(type: TaskQueueType.serialBackgroundThread) - void refreshSnippetUpdates(); } @HostApi() diff --git a/flutter_module/lib/model/main_model.dart b/flutter_module/lib/model/main_model.dart index aebad03..541c16d 100644 --- a/flutter_module/lib/model/main_model.dart +++ b/flutter_module/lib/model/main_model.dart @@ -105,6 +105,7 @@ class Snippet { this.isLiked, this.isDisliked, this.isSaved, + this.isToDelete, }); String? uuid; @@ -120,6 +121,7 @@ class Snippet { bool? isLiked; bool? isDisliked; bool? isSaved; + bool? isToDelete; Object encode() { final Map pigeonMap = {}; @@ -136,6 +138,7 @@ class Snippet { pigeonMap['isLiked'] = isLiked; pigeonMap['isDisliked'] = isDisliked; pigeonMap['isSaved'] = isSaved; + pigeonMap['isToDelete'] = isToDelete; return pigeonMap; } @@ -163,6 +166,7 @@ class Snippet { isLiked: pigeonMap['isLiked'] as bool?, isDisliked: pigeonMap['isDisliked'] as bool?, isSaved: pigeonMap['isSaved'] as bool?, + isToDelete: pigeonMap['isToDelete'] as bool?, ); } } @@ -782,28 +786,6 @@ class MainModelBridge { return; } } - - Future refreshSnippetUpdates() async { - final BasicMessageChannel channel = BasicMessageChannel( - 'dev.flutter.pigeon.MainModelBridge.refreshSnippetUpdates', codec, binaryMessenger: _binaryMessenger); - final Map? replyMap = - await channel.send(null) as Map?; - if (replyMap == null) { - throw PlatformException( - code: 'channel-error', - message: 'Unable to establish connection on channel.', - ); - } else if (replyMap['error'] != null) { - final Map error = (replyMap['error'] as Map?)!; - throw PlatformException( - code: (error['code'] as String?)!, - message: error['message'] as String?, - details: error['details'], - ); - } else { - return; - } - } } class _DetailModelBridgeCodec extends StandardMessageCodec{ diff --git a/flutter_module/lib/presentation/screens/details_screen.dart b/flutter_module/lib/presentation/screens/details_screen.dart index 6244ac1..53ad98b 100644 --- a/flutter_module/lib/presentation/screens/details_screen.dart +++ b/flutter_module/lib/presentation/screens/details_screen.dart @@ -75,7 +75,6 @@ class _DetailsPage extends HookWidget { if (event.event == DetailModelEvent.deleted) { _exit(); - return const SizedBox(); } useEffect(() { @@ -156,6 +155,7 @@ class _DetailPageData extends StatelessWidget { onSaveTap: model.save, onCopyTap: model.copyToClipboard, onShareTap: model.share, + onDeleteTap: model.delete, ), ), ), diff --git a/flutter_module/lib/presentation/widgets/snippet_action_bar.dart b/flutter_module/lib/presentation/widgets/snippet_action_bar.dart index fe1283a..9331919 100644 --- a/flutter_module/lib/presentation/widgets/snippet_action_bar.dart +++ b/flutter_module/lib/presentation/widgets/snippet_action_bar.dart @@ -14,6 +14,7 @@ class SnippetActionBar extends StatelessWidget { this.onSaveTap, this.onCopyTap, this.onShareTap, + this.onDeleteTap, }) : super(key: key); final Snippet snippet; @@ -22,6 +23,7 @@ class SnippetActionBar extends StatelessWidget { final GestureTapCallback? onSaveTap; final GestureTapCallback? onCopyTap; final GestureTapCallback? onShareTap; + final GestureTapCallback? onDeleteTap; @override Widget build(BuildContext context) { @@ -56,6 +58,13 @@ class SnippetActionBar extends StatelessWidget { icon: Icons.share, onTap: onShareTap, ), + const SizedBox(width: Dimens.l), + StateIcon( + onTap: snippet.isToDelete == true ? onDeleteTap : null, + active: snippet.isToDelete == true ? null : false, + activeColor: Colors.redAccent, + icon: Icons.delete_outline_outlined, + ), ], ), ); diff --git a/flutter_module/lib/presentation/widgets/state_icon.dart b/flutter_module/lib/presentation/widgets/state_icon.dart index 9920f42..9598709 100644 --- a/flutter_module/lib/presentation/widgets/state_icon.dart +++ b/flutter_module/lib/presentation/widgets/state_icon.dart @@ -6,17 +6,19 @@ class StateIcon extends StatelessWidget { const StateIcon({ Key? key, required this.icon, + this.activeColor = Colors.black, this.active, this.onTap, }) : super(key: key); final IconData icon; + final Color activeColor; final bool? active; final GestureTapCallback? onTap; @override Widget build(BuildContext context) { - final color = getColorByState(active); + final color = getColorByState(active, activeColor); return SizedBox( width: 24, height: 24, @@ -31,8 +33,8 @@ class StateIcon extends StatelessWidget { ); } - Color getColorByState(bool? active) { - if (active == null) return Colors.black; + Color getColorByState(bool? active, Color activeColor) { + if (active == null) return activeColor; return active ? ColorStyles.accent() : Colors.grey; } } From a66f13f1d74b1a48402f25d32af223a6df0f6095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Fri, 23 Dec 2022 09:59:53 +0100 Subject: [PATCH 37/66] Corrected navigation --- .../bridge/detail/DetailModel.kt | 4 +-- .../snipmeandroid/bridge/main/MainModel.kt | 7 +---- .../snipmeandroid/di/ModelModule.kt | 2 +- .../domain/snippet/DeleteSnippetUseCase.kt | 5 ++-- .../navigation/details/details_navigator.dart | 1 - .../presentation/screens/details_screen.dart | 30 +++++++++++-------- 6 files changed, 25 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt index 6971c51..0a6a026 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt @@ -111,12 +111,12 @@ class DetailModel( .subscribeOn(Schedulers.io()) .subscribeBy( onComplete = { + Timber.d("Deleted ${it.uuid}") mutableEvent.value = Deleted }, onError = { error -> - Timber.e("Couldn't save snippet, error = $error") + Timber.e("Couldn't delete snippet, error = $error") parseError(error) - mutableEvent.value = Deleted } ).also { disposables += it } } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt index 557033f..c2ef6ea 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt @@ -42,7 +42,6 @@ class MainModel( private var cachedSnippets = emptyList() private var scopedSnippets = emptyList() - private var shouldRefresh = false private lateinit var filterState: SnippetFilters; override fun parseError(throwable: Throwable) { @@ -66,7 +65,7 @@ class MainModel( .subscribeOn(Schedulers.io()) .subscribeBy( onNext = { updatedPage -> - initState() +// initState() }, onError = { Timber.e("Couldn't refresh snippet updates, error = $it") } ).also { disposables += it } @@ -155,10 +154,6 @@ class MainModel( filterState ) loadNextPage() - if (shouldRefresh) { - mutableEvent.value = ListRefreshed - shouldRefresh = false - } }, onError = { Timber.e("Couldn't load snippets, error = $it") diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt index 955874b..87f3dd1 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ModelModule.kt @@ -9,6 +9,6 @@ import pl.tkadziolka.snipmeandroid.bridge.session.SessionModel internal val modelModule = module { single { SessionModel(get()) } single { LoginModel(get(), get(), get()) } - single { MainModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + single { MainModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } single { DetailModel(get(), get(), get(), get(), get(), get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/DeleteSnippetUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/DeleteSnippetUseCase.kt index 8cb6d76..1e3aab5 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/DeleteSnippetUseCase.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/DeleteSnippetUseCase.kt @@ -1,12 +1,13 @@ package pl.tkadziolka.snipmeandroid.domain.snippet +import io.reactivex.Completable import pl.tkadziolka.snipmeandroid.domain.repository.snippet.SnippetRepository import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet class DeleteSnippetUseCase(private val repository: SnippetRepository) { - operator fun invoke(uuid: String) = + operator fun invoke(uuid: String): Completable = repository .delete(uuid) - .andThen { repository.updateListener.onNext(Snippet.EMPTY) } +// .doOnComplete { repository.updateListener.onNext(Snippet.EMPTY.copy(uuid)) } } \ No newline at end of file diff --git a/flutter_module/lib/presentation/navigation/details/details_navigator.dart b/flutter_module/lib/presentation/navigation/details/details_navigator.dart index ab7ee0c..f937a15 100644 --- a/flutter_module/lib/presentation/navigation/details/details_navigator.dart +++ b/flutter_module/lib/presentation/navigation/details/details_navigator.dart @@ -13,6 +13,5 @@ class DetailsNavigator extends ScreenNavigator { void goToDetails(BuildContext context, String snippetId) { _snippetId = snippetId; router.push(DetailsScreen.name.route); - print("Navigated to $_snippetId"); } } diff --git a/flutter_module/lib/presentation/screens/details_screen.dart b/flutter_module/lib/presentation/screens/details_screen.dart index 53ad98b..924758f 100644 --- a/flutter_module/lib/presentation/screens/details_screen.dart +++ b/flutter_module/lib/presentation/screens/details_screen.dart @@ -62,20 +62,26 @@ class _DetailsPage extends HookWidget { (current, newState) => (current as DetailModelEventData).equals(newState), ).value; - if (event.event == DetailModelEvent.saved) { - final snippetId = event.value; - if (snippetId == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (event.event == DetailModelEvent.saved) { + final snippetId = event.value; + if (snippetId == null) { + _exit(); + return; + } + _exit(); - return const SizedBox(); + WidgetsBinding.instance.addPostFrameCallback((_) { + navigator.goToDetails(context, snippetId); + }); } + }); - _exit(); - navigator.goToDetails(context, snippetId); - } - - if (event.event == DetailModelEvent.deleted) { - _exit(); - } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (event.event == DetailModelEvent.deleted) { + _exit(); + } + }); useEffect(() { model.load(navigator.snippetId ?? ''); @@ -111,8 +117,8 @@ class _DetailsPage extends HookWidget { } void _exit() { - navigator.back(); model.resetEvent(); + navigator.back(); } } From e77d28d2778368c60ef78c454e30ca9144a2091d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Fri, 23 Dec 2022 10:27:08 +0100 Subject: [PATCH 38/66] Simplified observing changes --- .../snipmeandroid/bridge/detail/DetailModel.kt | 5 +---- .../pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt | 9 ++++----- .../java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt | 1 + .../snipmeandroid/domain/snippet/DeleteSnippetUseCase.kt | 2 +- .../domain/snippet/ObserveSnippetUpdatesUseCase.kt | 9 +++++++++ 5 files changed, 16 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/ObserveSnippetUpdatesUseCase.kt diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt index 0a6a026..3b1721b 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/detail/DetailModel.kt @@ -110,10 +110,7 @@ class DetailModel( deleteSnippet(it.uuid) .subscribeOn(Schedulers.io()) .subscribeBy( - onComplete = { - Timber.d("Deleted ${it.uuid}") - mutableEvent.value = Deleted - }, + onComplete = { mutableEvent.value = Deleted }, onError = { error -> Timber.e("Couldn't delete snippet, error = $error") parseError(error) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt index c2ef6ea..4ac150a 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/main/MainModel.kt @@ -9,6 +9,7 @@ import pl.tkadziolka.snipmeandroid.bridge.session.SessionModel import pl.tkadziolka.snipmeandroid.domain.error.exception.* import pl.tkadziolka.snipmeandroid.domain.filter.* import pl.tkadziolka.snipmeandroid.domain.message.ErrorMessages +import pl.tkadziolka.snipmeandroid.domain.snippet.ObserveSnippetUpdatesUseCase import pl.tkadziolka.snipmeandroid.domain.snippet.ObserveUpdatedSnippetPageUseCase import pl.tkadziolka.snipmeandroid.domain.snippet.ResetUpdatedSnippetPageUseCase import pl.tkadziolka.snipmeandroid.domain.snippets.* @@ -24,7 +25,7 @@ class MainModel( private val errorMessages: ErrorMessages, private val getUser: GetSingleUserUseCase, private val getSnippets: GetSnippetsUseCase, - private val observeUpdatedPage: ObserveUpdatedSnippetPageUseCase, + private val observeUpdates: ObserveSnippetUpdatesUseCase, private val hasMore: HasMoreSnippetPagesUseCase, private val getLanguageFilters: GetLanguageFiltersUseCase, private val filterSnippetsByLanguage: FilterSnippetsByLanguageUseCase, @@ -61,12 +62,10 @@ class MainModel( } init { - observeUpdatedPage(getScope()) + observeUpdates() .subscribeOn(Schedulers.io()) .subscribeBy( - onNext = { updatedPage -> -// initState() - }, + onNext = { initState() }, onError = { Timber.e("Couldn't refresh snippet updates, error = $it") } ).also { disposables += it } } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt index dc31770..65704ac 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UseCaseModule.kt @@ -41,6 +41,7 @@ internal val useCaseModule = module { factory { CreateSnippetUseCase(get(), get(), get()) } factory { UpdateSnippetUseCase(get(), get(), get()) } factory { ObserveUpdatedSnippetPageUseCase(get()) } + factory { ObserveSnippetUpdatesUseCase(get()) } factory { ResetUpdatedSnippetPageUseCase(get()) } factory { GetTargetUserReactionUseCase() } factory { SetUserReactionUseCase(get(), get(), get()) } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/DeleteSnippetUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/DeleteSnippetUseCase.kt index 1e3aab5..0cb0bf5 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/DeleteSnippetUseCase.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/DeleteSnippetUseCase.kt @@ -9,5 +9,5 @@ class DeleteSnippetUseCase(private val repository: SnippetRepository) { operator fun invoke(uuid: String): Completable = repository .delete(uuid) -// .doOnComplete { repository.updateListener.onNext(Snippet.EMPTY.copy(uuid)) } + .doOnComplete { repository.updateListener.onNext(Snippet.EMPTY.copy(uuid)) } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/ObserveSnippetUpdatesUseCase.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/ObserveSnippetUpdatesUseCase.kt new file mode 100644 index 0000000..354ccde --- /dev/null +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/domain/snippet/ObserveSnippetUpdatesUseCase.kt @@ -0,0 +1,9 @@ +package pl.tkadziolka.snipmeandroid.domain.snippet + +import io.reactivex.Observable +import pl.tkadziolka.snipmeandroid.domain.repository.snippet.SnippetRepository +import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet + +class ObserveSnippetUpdatesUseCase(private val repository: SnippetRepository) { + operator fun invoke(): Observable = repository.updateListener.share() +} \ No newline at end of file From 98e267b362d4fc70c5bd91ef1c55dfc10ba3f028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Fri, 20 Jan 2023 14:41:09 +0100 Subject: [PATCH 39/66] Removed less coupled code --- app/build.gradle | 56 +--------- .../snipmeandroid/di/NavigatorModule.kt | 6 - .../snipmeandroid/di/ViewModelModule.kt | 6 - .../ui/contact/ContactFragment.kt | 33 ------ .../ui/contact/ContactNavigator.kt | 24 ---- .../snipmeandroid/ui/donate/DonateFragment.kt | 36 ------ .../ui/donate/DonateNavigator.kt | 16 --- .../ui/donate/DonateViewModel.kt | 22 ---- .../snipmeandroid/ui/error/ErrorFragment.kt | 1 - .../ui/preview/PreviewFragment.kt | 97 ----------------- .../ui/preview/PreviewNavigator.kt | 11 -- .../ui/preview/PreviewViewModel.kt | 19 ---- .../snipmeandroid/ui/share/ShareFragment.kt | 103 ------------------ .../ui/share/ShareUserAdapter.kt | 62 ----------- .../snipmeandroid/ui/share/ShareViewModel.kt | 102 ----------------- .../util/extension/ViewExtensions.kt | 3 - 16 files changed, 3 insertions(+), 594 deletions(-) delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/contact/ContactFragment.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/contact/ContactNavigator.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/donate/DonateFragment.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/donate/DonateNavigator.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/donate/DonateViewModel.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/preview/PreviewFragment.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/preview/PreviewNavigator.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/preview/PreviewViewModel.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/share/ShareFragment.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/share/ShareUserAdapter.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/share/ShareViewModel.kt diff --git a/app/build.gradle b/app/build.gradle index edd4b8c..7416bdd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,21 +10,6 @@ plugins { def STRING = "String" android { - signingConfigs { - release { - // NOTE: Create your own keystore.properties file - def propsFile = rootProject.file('keystore.properties') - if (propsFile.exists()) { - def props = new Properties() - props.load(new FileInputStream(propsFile)) - storeFile = file('../snipplog_keystore.jks') - storePassword = props['storePassword'] - keyAlias = props['keyAlias'] - keyPassword = props['keyPassword'] - } - } - } - compileSdk 33 defaultConfig { @@ -39,60 +24,25 @@ android { buildTypes { def BASE_URL = "BASE_URL" - def CONTACT_US_PAGE = "CONTACT_US_PAGE" - def FACEBOOK_PAGE = "FACEBOOK_PAGE" - def INSTAGRAM_PAGE = "INSTAGRAM_PAGE" - def GITHUB_PAGE = "GITHUB_PAGE" - def TWITTER_PAGE = "TWITTER_PAGE" - - def PAYPAL_PAGE = "PAYPAL_PAGE" - def CARD_PAGE = "CARD_PAGE" - def API_BASE_URL = "\"http://91.195.93.3:8000/snip-api/\"" // Must end with '/' - def FACEBOOK_URL = "\"https://www.facebook.com/SnippLog-100576858857140\"" - def INSTAGRAM_URL = "\"https://www.instagram.com/snipp.log\"" - def GITHUB_URL = "\"https://github.com/SnippLog\"" - def TWITTER_URL = "\"https://twitter.com/SnippLog\"" - - def PAYPAL_PAY_URL = "\"https://www.paypal.com/donate?hosted_button_id=WZQNYKQFAHAJG\"" - def CARD_PAY_URL = "\"https://www.paypal.com/biz/fund?id=ACCDJCH2JAHDG\"" debug { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' buildConfigField STRING, BASE_URL, API_BASE_URL - buildConfigField STRING, CONTACT_US_PAGE, FACEBOOK_URL - buildConfigField STRING, FACEBOOK_PAGE, FACEBOOK_URL - buildConfigField STRING, INSTAGRAM_PAGE, INSTAGRAM_URL - buildConfigField STRING, GITHUB_PAGE, GITHUB_URL - buildConfigField STRING, TWITTER_PAGE, TWITTER_URL - buildConfigField STRING, PAYPAL_PAGE, PAYPAL_PAY_URL - buildConfigField STRING, CARD_PAGE, CARD_PAY_URL - } - - release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - - buildConfigField STRING, BASE_URL, API_BASE_URL - buildConfigField STRING, CONTACT_US_PAGE, FACEBOOK_URL - buildConfigField STRING, FACEBOOK_PAGE, FACEBOOK_URL - buildConfigField STRING, INSTAGRAM_PAGE, INSTAGRAM_URL - buildConfigField STRING, GITHUB_PAGE, GITHUB_URL - buildConfigField STRING, TWITTER_PAGE, TWITTER_URL - buildConfigField STRING, PAYPAL_PAGE, PAYPAL_PAY_URL - buildConfigField STRING, CARD_PAGE, CARD_PAY_URL - signingConfig signingConfigs.release } } + compileOptions { sourceCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_11 } + kotlinOptions { jvmTarget = "11" } + lintOptions { abortOnError false } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/NavigatorModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/NavigatorModule.kt index 85c4fb7..3ab4f54 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/NavigatorModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/NavigatorModule.kt @@ -1,22 +1,16 @@ package pl.tkadziolka.snipmeandroid.di import org.koin.dsl.module -import pl.tkadziolka.snipmeandroid.ui.contact.ContactNavigator import pl.tkadziolka.snipmeandroid.ui.detail.DetailNavigator -import pl.tkadziolka.snipmeandroid.ui.donate.DonateNavigator import pl.tkadziolka.snipmeandroid.ui.edit.EditNavigator import pl.tkadziolka.snipmeandroid.ui.login.LoginNavigator import pl.tkadziolka.snipmeandroid.ui.main.MainNavigator -import pl.tkadziolka.snipmeandroid.ui.preview.PreviewNavigator import pl.tkadziolka.snipmeandroid.ui.splash.SplashNavigator internal val navigatorModule = module { factory { SplashNavigator() } factory { LoginNavigator() } factory { MainNavigator() } - factory { PreviewNavigator() } factory { DetailNavigator() } factory { EditNavigator() } - factory { ContactNavigator() } - factory { DonateNavigator() } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt index 407d0a9..fa0240a 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt @@ -5,13 +5,10 @@ import org.koin.dsl.module import pl.tkadziolka.snipmeandroid.bridge.main.MainModel import pl.tkadziolka.snipmeandroid.bridge.session.SessionModel import pl.tkadziolka.snipmeandroid.ui.detail.DetailViewModel -import pl.tkadziolka.snipmeandroid.ui.donate.DonateViewModel import pl.tkadziolka.snipmeandroid.ui.edit.EditViewModel import pl.tkadziolka.snipmeandroid.ui.login.LoginViewModel import pl.tkadziolka.snipmeandroid.ui.main.MainViewModel -import pl.tkadziolka.snipmeandroid.ui.preview.PreviewViewModel import pl.tkadziolka.snipmeandroid.ui.session.SessionViewModel -import pl.tkadziolka.snipmeandroid.ui.share.ShareViewModel import pl.tkadziolka.snipmeandroid.ui.splash.SplashViewModel internal val viewModelModule = module { @@ -19,9 +16,6 @@ internal val viewModelModule = module { viewModel { LoginViewModel(get(), get(), get(), get()) } viewModel { SessionViewModel(get()) } viewModel { MainViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } - viewModel { PreviewViewModel(get(), get()) } viewModel { DetailViewModel(get(), get(), get(), get(), get(), get(), get()) } viewModel { EditViewModel(get(), get(), get(), get(), get(), get()) } - viewModel { ShareViewModel(get(), get(), get()) } - viewModel { DonateViewModel(get()) } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/contact/ContactFragment.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/contact/ContactFragment.kt deleted file mode 100644 index 8bb11ff..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/contact/ContactFragment.kt +++ /dev/null @@ -1,33 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.contact - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController -import androidx.navigation.ui.setupWithNavController -import kotlinx.android.synthetic.main.fragment_contact.* -import org.koin.android.ext.android.inject -import pl.tkadziolka.snipmeandroid.R - -class ContactFragment: Fragment() { - - private val navigator by inject() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = inflater.inflate(R.layout.fragment_contact, container, false) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - contactToolbar.setupWithNavController(findNavController()) - - contactFacebookAction.setOnClickListener { navigator.goToFacebook(requireContext()) } - contactInstagramAction.setOnClickListener { navigator.goToInstagram(requireContext()) } - contactGithubAction.setOnClickListener { navigator.goToGithub(requireContext()) } - contactTwitterAction.setOnClickListener { navigator.goToTwitter(requireContext()) } - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/contact/ContactNavigator.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/contact/ContactNavigator.kt deleted file mode 100644 index 17635c3..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/contact/ContactNavigator.kt +++ /dev/null @@ -1,24 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.contact - -import android.content.Context -import pl.tkadziolka.snipmeandroid.BuildConfig -import pl.tkadziolka.snipmeandroid.util.extension.safeOpenWebsite - -class ContactNavigator { - - fun goToFacebook(context: Context) { - context.safeOpenWebsite(BuildConfig.FACEBOOK_PAGE) - } - - fun goToInstagram(context: Context) { - context.safeOpenWebsite(BuildConfig.INSTAGRAM_PAGE) - } - - fun goToGithub(context: Context) { - context.safeOpenWebsite(BuildConfig.GITHUB_PAGE) - } - - fun goToTwitter(context: Context) { - context.safeOpenWebsite(BuildConfig.TWITTER_PAGE) - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/donate/DonateFragment.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/donate/DonateFragment.kt deleted file mode 100644 index e0854c3..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/donate/DonateFragment.kt +++ /dev/null @@ -1,36 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.donate - -import androidx.navigation.fragment.findNavController -import androidx.navigation.ui.setupWithNavController -import kotlinx.android.synthetic.main.fragment_donate.* -import pl.tkadziolka.snipmeandroid.R -import pl.tkadziolka.snipmeandroid.domain.payment.PaymentOption -import pl.tkadziolka.snipmeandroid.ui.viewmodel.ViewModelFragment -import pl.tkadziolka.snipmeandroid.util.extension.setOnClick - -class DonateFragment : ViewModelFragment(DonateViewModel::class) { - - override val layout: Int = R.layout.fragment_donate - - override fun onViewCreated() { - donateToolbar.setupWithNavController(findNavController()) - setupActions() - } - - override fun observeViewModel() = Unit - - private fun setupActions() { - donateOptions.setOnCheckedChangeListener { _, checkedId -> - when (checkedId) { - R.id.donateOptionGooglePay -> Unit - R.id.donateOptionPayPal -> viewModel.setPaymentOption(PaymentOption.PAYPAL) - R.id.donateOptionCreditCard -> viewModel.setPaymentOption(PaymentOption.CREDIT_CARD) - else -> Unit - } - } - - donateAction.setOnClick { - viewModel.proceedToPayment(requireContext()) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/donate/DonateNavigator.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/donate/DonateNavigator.kt deleted file mode 100644 index 119f869..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/donate/DonateNavigator.kt +++ /dev/null @@ -1,16 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.donate - -import android.content.Context -import pl.tkadziolka.snipmeandroid.BuildConfig -import pl.tkadziolka.snipmeandroid.util.extension.safeOpenWebsite - -class DonateNavigator { - - fun goToPayPal(context: Context) { - context.safeOpenWebsite(BuildConfig.PAYPAL_PAGE) - } - - fun goToCreditCard(context: Context) { - context.safeOpenWebsite(BuildConfig.CARD_PAGE) - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/donate/DonateViewModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/donate/DonateViewModel.kt deleted file mode 100644 index 2463087..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/donate/DonateViewModel.kt +++ /dev/null @@ -1,22 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.donate - -import android.content.Context -import androidx.lifecycle.ViewModel -import pl.tkadziolka.snipmeandroid.domain.payment.PaymentOption - -class DonateViewModel(private val navigator: DonateNavigator) : ViewModel() { - private var selectedOption = PaymentOption.PAYPAL - - - fun setPaymentOption(option: PaymentOption) { - selectedOption = option - } - - fun proceedToPayment(context: Context) { - when (selectedOption) { - PaymentOption.GOOGLE_PAY -> Unit - PaymentOption.PAYPAL -> navigator.goToPayPal(context) - PaymentOption.CREDIT_CARD -> navigator.goToCreditCard(context) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/error/ErrorFragment.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/error/ErrorFragment.kt index 2498b41..bf25448 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/error/ErrorFragment.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/error/ErrorFragment.kt @@ -31,7 +31,6 @@ class ErrorFragment: Fragment() { onBackPressed { navigateToPreviousSuccessScreen() } errorClose.setOnClick { navigateToPreviousSuccessScreen() } - errorContact.setOnClick { requireContext().safeOpenWebsite(BuildConfig.CONTACT_US_PAGE) } } private fun navigateToPreviousSuccessScreen() { diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/preview/PreviewFragment.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/preview/PreviewFragment.kt deleted file mode 100644 index fedaae5..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/preview/PreviewFragment.kt +++ /dev/null @@ -1,97 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.preview - -import android.os.Bundle -import android.view.* -import androidx.fragment.app.DialogFragment -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import io.github.kbiakov.codeview.highlight.ColorThemeData -import io.github.kbiakov.codeview.highlight.SyntaxColors -import kotlinx.android.synthetic.main.fragment_preview.* -import org.koin.android.ext.android.inject -import pl.tkadziolka.snipmeandroid.R -import pl.tkadziolka.snipmeandroid.util.SyntaxTheme -import pl.tkadziolka.snipmeandroid.util.SyntaxWindowTheme -import pl.tkadziolka.snipmeandroid.util.extension.* - -private const val HALF = 0.5 -private const val ONE_THIRD = 0.3 - -class PreviewFragment : DialogFragment() { - - private val viewModel: PreviewViewModel by inject() - - private val args: PreviewFragmentArgs by navArgs() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = inflater.inflate(R.layout.fragment_preview, container) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - showTransparentBackground() - showFullscreen() - - previewContainer.setOnOutsideClick(R.dimen.spacing_medium) { - dismiss() - } - - setupTitle() - setupCodeView() - setupActions() - } - - private fun setupTitle() { - with(previewTitle) { - text = args.title - maxWidth = getMaxWidthPercent(percent = HALF) - } - - with(previewLanguage) { - text = args.language - maxWidth = getMaxWidthPercent(percent = ONE_THIRD) - } - } - - private fun setupCodeView() { - previewCode.setCodeWithTheme(args.code, args.language) - } - - private fun setupActions() { - previewTitle.setOnClick { viewModel.goToDetail(findNavController(), args.uuid) } - previewCopy.setOnClick { - viewModel.copyToClipboard(args.title, args.code) - if (android.os.Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.R) { - // Android 12+ shows message itself - showToast(getString(R.string.message_copied, args.title)) - } - } - } - - private fun getMaxWidthPercent(percent: Double = HALF): Int { - val inset = resources.getDimensionPixelSize(R.dimen.spacing_medium) - val viewWidth = requireActivity().screenWidth - return ((viewWidth - 2 * inset) * percent).toInt() - } -} - -fun SyntaxTheme.toThemeData(window: SyntaxWindowTheme) = ColorThemeData( - syntaxColors = SyntaxColors( - type = keyword, - keyword = keyword, - literal = literal, - comment = comment, - string = text, - punctuation = keyword, - plain = code, - tag = code, - attrName = code, - attrValue = code, - declaration = code - ), - numColor = window.number, - noteColor = window.note, - bgNum = window.numberBackground, - bgContent = window.contentBackground -) \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/preview/PreviewNavigator.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/preview/PreviewNavigator.kt deleted file mode 100644 index 3ff0e74..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/preview/PreviewNavigator.kt +++ /dev/null @@ -1,11 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.preview - -import androidx.navigation.NavController -import pl.tkadziolka.snipmeandroid.util.extension.navigateSafe - -class PreviewNavigator { - - fun goToDetail(navController: NavController, snippetId: String) { - navController.navigateSafe(PreviewFragmentDirections.goToDetail(snippetId)) - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/preview/PreviewViewModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/preview/PreviewViewModel.kt deleted file mode 100644 index 1ce814d..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/preview/PreviewViewModel.kt +++ /dev/null @@ -1,19 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.preview - -import androidx.lifecycle.ViewModel -import androidx.navigation.NavController -import pl.tkadziolka.snipmeandroid.domain.clipboard.AddToClipboardUseCase - -class PreviewViewModel( - private val navigator: PreviewNavigator, - private val clipboard: AddToClipboardUseCase -): ViewModel() { - - fun goToDetail(nav: NavController, snippetId: String) { - navigator.goToDetail(nav, snippetId) - } - - fun copyToClipboard(label: String, code: String) { - clipboard(label, code) - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/share/ShareFragment.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/share/ShareFragment.kt deleted file mode 100644 index 9a0b193..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/share/ShareFragment.kt +++ /dev/null @@ -1,103 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.share - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.DialogFragment -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import kotlinx.android.synthetic.main.fragment_share.* -import org.koin.android.ext.android.inject -import pl.tkadziolka.snipmeandroid.R -import pl.tkadziolka.snipmeandroid.domain.share.ShareUser -import pl.tkadziolka.snipmeandroid.ui.viewmodel.observeNotNull -import pl.tkadziolka.snipmeandroid.util.extension.* - -class ShareFragment : DialogFragment() { - - private val viewModel: ShareViewModel by inject() - - private val args: ShareFragmentArgs by navArgs() - - private val adapter by lazy { - ShareUserAdapter { shareUser -> - viewModel.share(args.uuid, shareUser.user.id) - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = inflater.inflate(R.layout.fragment_share, container) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - showTransparentBackground() - showFullscreen() - - shareContainer.setOnOutsideClick(R.dimen.spacing_medium) { - dismiss() - } - - shareList.adapter = adapter - shareName.addTextChangedListener { editable -> - viewModel.search(editable.toString(), args.uuid) - } - - shareCloseAction.setOnClick { - dismiss() - } - - observeViewModel() - } - - private fun observeViewModel() { - viewModel.state.observeNotNull(this) { state -> - hideLoading() - when (state) { - is Loading -> showLoading() - is Loaded -> showData(state.users) - is Shared -> { - showToast(getString(R.string.message_shared, state.shareUser.user.login)) - dismiss() - } - is Error -> { - showToast(state.message ?: getString(R.string.error_message_generic)) - dismiss() - } - } - } - - viewModel.event.observeNotNull(this) { event -> - when (event) { - is Alert -> showToast(event.message) - is Logout -> viewModel.goToLogin(findNavController()) - } - } - } - - private fun showLoading() { - shareLoading.visible() - shareHelperImage.gone() - shareHelper.gone() - } - - private fun hideLoading() { - shareLoading.gone() - } - - private fun showData(users: List?) { - if (users == null) { - shareHelperImage.visible() - shareHelper.visible() - adapter.submitList(null) - return - } - - shareHelperImage.gone() - shareHelper.gone() - adapter.submitList(users) - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/share/ShareUserAdapter.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/share/ShareUserAdapter.kt deleted file mode 100644 index cb10dec..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/share/ShareUserAdapter.kt +++ /dev/null @@ -1,62 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.share - -import android.content.Context -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.synthetic.main.view_item_user_share.view.* -import pl.tkadziolka.snipmeandroid.R -import pl.tkadziolka.snipmeandroid.domain.share.ShareUser -import pl.tkadziolka.snipmeandroid.util.extension.inflate -import pl.tkadziolka.snipmeandroid.util.extension.loadWithFallback - -typealias ShareActionListener = (ShareUser) -> Unit - -class ShareUserAdapter( - private val shareListener: ShareActionListener -) : ListAdapter(SHARE_DIFF) { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ShareUserHolder = - ShareUserHolder(parent.inflate(R.layout.view_item_user_share), shareListener) - - override fun onBindViewHolder(holder: ShareUserHolder, position: Int) { - holder.bind(getItem(position)) - } - - class ShareUserHolder( - view: View, - private val shareListener: ShareActionListener - ) : RecyclerView.ViewHolder(view) { - fun bind(shareUser: ShareUser) = with(itemView) { - shareUserName.text = shareUser.user.login - setupShareAction(shareUser) - shareUserImage.loadWithFallback(shareUser.user.photo) - } - - private fun View.setupShareAction(user: ShareUser) { - with(shareUserAction) { - val shared = user.isShared - text = getActionText(shared, context) - isEnabled = shared.not() - setOnClickListener { shareListener(user) } - } - } - - private fun getActionText(shared: Boolean, context: Context) = - if (shared) - context.getString(R.string.shared) - else - context.getString(R.string.share) - } -} - -private val SHARE_DIFF = object : DiffUtil.ItemCallback() { - - override fun areItemsTheSame(oldItem: ShareUser, newItem: ShareUser): Boolean = - oldItem.user.login == newItem.user.login - - override fun areContentsTheSame(oldItem: ShareUser, newItem: ShareUser): Boolean = - oldItem == newItem -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/share/ShareViewModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/share/ShareViewModel.kt deleted file mode 100644 index 1e65317..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/share/ShareViewModel.kt +++ /dev/null @@ -1,102 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.share - -import androidx.navigation.NavController -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable -import io.reactivex.rxkotlin.plusAssign -import io.reactivex.rxkotlin.subscribeBy -import io.reactivex.schedulers.Schedulers -import pl.tkadziolka.snipmeandroid.domain.error.exception.* -import pl.tkadziolka.snipmeandroid.domain.message.ErrorMessages -import pl.tkadziolka.snipmeandroid.domain.share.ShareInteractor -import pl.tkadziolka.snipmeandroid.domain.share.ShareUser -import pl.tkadziolka.snipmeandroid.ui.error.ErrorParsable -import pl.tkadziolka.snipmeandroid.ui.session.SessionViewModel -import pl.tkadziolka.snipmeandroid.ui.viewmodel.SingleLiveEvent -import pl.tkadziolka.snipmeandroid.ui.viewmodel.StateViewModel -import pl.tkadziolka.snipmeandroid.util.extension.inProgress -import pl.tkadziolka.snipmeandroid.util.extension.navigateSafe - -class ShareViewModel( - private val errorMessages: ErrorMessages, - private val interactor: ShareInteractor, - private val session: SessionViewModel -) : StateViewModel(), ErrorParsable { - private var usersDisposable: Disposable? = null - private var shareDisposable: Disposable? = null - - private val mutableEvent = SingleLiveEvent() - val event = mutableEvent - - override fun parseError(throwable: Throwable) { - when (throwable) { - is ConnectionException -> setState(Error(errorMessages.parse(throwable))) - is ContentNotFoundException -> setState(Error(errorMessages.parse(throwable))) - is ForbiddenActionException -> setState(Error(errorMessages.parse(throwable))) - is NetworkNotAvailableException -> setState(Error(errorMessages.parse(throwable))) - is NotAuthorizedException -> session.logOut { mutableEvent.value = Logout } - is RemoteException -> setState(Error(errorMessages.parse(throwable))) - is SessionExpiredException -> session.logOut { mutableEvent.value = Logout } - else -> setState(Error(errorMessages.parse(throwable))) - } - } - - init { - interactor.clearCachedUsers() - } - - fun goToLogin(nav: NavController) { - nav.navigateSafe(ShareFragmentDirections.goToLogin()) - } - - fun search(loginPhrase: String, snippetUuid: String) { - usersDisposable?.dispose() - - setState(Loading) - - if (loginPhrase.isBlank()) { - setState(Loaded(null)) - return - } - - usersDisposable = interactor.users(loginPhrase, snippetUuid) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy( - onSuccess = { setState(Loaded(it)) }, - onError = { parseError(it) } - ).also { disposables += it } - } - - fun share(snippetUuid: String, userId: Int) { - if (state.value is Loading || shareDisposable.inProgress()) return - - getUser(userId)?.let { user -> - setState(Loading) - shareDisposable = interactor.share(snippetUuid, userId) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy( - onComplete = { setState(Shared(user)) }, - onError = { mutableEvent.value = Alert(errorMessages.parse(it)) } - ).also { disposables += it } - } - } - - private fun getUser(id: Int): ShareUser? = - if (state.value is Loaded) { - (state.value as Loaded).users?.find { it.user.id == id } - } else { - null - } -} - -sealed class ShareViewState -object Loading : ShareViewState() -data class Loaded(val users: List?) : ShareViewState() -data class Shared(val shareUser: ShareUser) : ShareViewState() -data class Error(val message: String?) : ShareViewState() - -sealed class ShareEvent -data class Alert(val message: String) : ShareEvent() -object Logout : ShareEvent() \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/util/extension/ViewExtensions.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/util/extension/ViewExtensions.kt index 9dee79a..3a3fb6e 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/util/extension/ViewExtensions.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/util/extension/ViewExtensions.kt @@ -21,7 +21,6 @@ import com.bumptech.glide.Glide import io.github.kbiakov.codeview.CodeView import kotlinx.android.synthetic.main.fragment_preview.* import pl.tkadziolka.snipmeandroid.R -import pl.tkadziolka.snipmeandroid.ui.preview.toThemeData import pl.tkadziolka.snipmeandroid.util.SyntaxWindowTheme import pl.tkadziolka.snipmeandroid.util.view.DebouncingOnClickListener @@ -87,8 +86,6 @@ fun CodeView.setCodeWithTheme(code: String, language: String?, theme: SyntaxWind code = code, language = language, animateOnHighlight = false, - theme = pl.tkadziolka.snipmeandroid.util.SyntaxTheme() - .toThemeData(theme ?: context.syntaxWindowTheme) ) ) } From ef39437d9e734bf5b0b33877bbc61ec6149fa800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Fri, 20 Jan 2023 14:43:44 +0100 Subject: [PATCH 40/66] Removed edit code --- .../snipmeandroid/di/NavigatorModule.kt | 2 - .../snipmeandroid/di/ViewModelModule.kt | 4 - .../snipmeandroid/ui/edit/EditFragment.kt | 214 ---------------- .../snipmeandroid/ui/edit/EditNavigator.kt | 19 -- .../snipmeandroid/ui/edit/EditViewModel.kt | 241 ------------------ .../util/extension/ViewExtensions.kt | 29 --- 6 files changed, 509 deletions(-) delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/edit/EditFragment.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/edit/EditNavigator.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/edit/EditViewModel.kt diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/NavigatorModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/NavigatorModule.kt index 3ab4f54..8935800 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/NavigatorModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/NavigatorModule.kt @@ -2,7 +2,6 @@ package pl.tkadziolka.snipmeandroid.di import org.koin.dsl.module import pl.tkadziolka.snipmeandroid.ui.detail.DetailNavigator -import pl.tkadziolka.snipmeandroid.ui.edit.EditNavigator import pl.tkadziolka.snipmeandroid.ui.login.LoginNavigator import pl.tkadziolka.snipmeandroid.ui.main.MainNavigator import pl.tkadziolka.snipmeandroid.ui.splash.SplashNavigator @@ -12,5 +11,4 @@ internal val navigatorModule = module { factory { LoginNavigator() } factory { MainNavigator() } factory { DetailNavigator() } - factory { EditNavigator() } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt index fa0240a..ba6bfef 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt @@ -2,10 +2,7 @@ package pl.tkadziolka.snipmeandroid.di import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module -import pl.tkadziolka.snipmeandroid.bridge.main.MainModel -import pl.tkadziolka.snipmeandroid.bridge.session.SessionModel import pl.tkadziolka.snipmeandroid.ui.detail.DetailViewModel -import pl.tkadziolka.snipmeandroid.ui.edit.EditViewModel import pl.tkadziolka.snipmeandroid.ui.login.LoginViewModel import pl.tkadziolka.snipmeandroid.ui.main.MainViewModel import pl.tkadziolka.snipmeandroid.ui.session.SessionViewModel @@ -17,5 +14,4 @@ internal val viewModelModule = module { viewModel { SessionViewModel(get()) } viewModel { MainViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { DetailViewModel(get(), get(), get(), get(), get(), get(), get()) } - viewModel { EditViewModel(get(), get(), get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/edit/EditFragment.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/edit/EditFragment.kt deleted file mode 100644 index d9f2829..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/edit/EditFragment.kt +++ /dev/null @@ -1,214 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.edit - -import android.os.Bundle -import android.view.View -import android.widget.ArrayAdapter -import androidx.appcompat.app.AlertDialog -import androidx.core.view.isVisible -import androidx.core.widget.addTextChangedListener -import androidx.navigation.NavController -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import androidx.navigation.ui.setupWithNavController -import kotlinx.android.synthetic.main.fragment_edit.* -import pl.tkadziolka.snipmeandroid.R -import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet -import pl.tkadziolka.snipmeandroid.ui.Dialogs -import pl.tkadziolka.snipmeandroid.ui.viewmodel.ViewModelFragment -import pl.tkadziolka.snipmeandroid.ui.viewmodel.observeNotNull -import pl.tkadziolka.snipmeandroid.util.extension.* - -class EditFragment : ViewModelFragment(EditViewModel::class) { - // Update toolbar title when nav controller set up toolbar's destination - private val destinationChange = NavController.OnDestinationChangedListener { _, _, _ -> - editToolbar.title = if (isEdit()) getString(R.string.edit) else getString(R.string.create) - } - - private val args by navArgs() - - override val layout: Int = R.layout.fragment_edit - - private lateinit var dialogs: Dialogs - private var loadingDialog: AlertDialog? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setupExitDialogOnBack() - } - - override fun onViewCreated() { - dialogs = Dialogs(requireContext()) - setupToolbar() - setupActions() - viewModel.load(args.id) - } - - override fun observeViewModel() { - viewModel.state.observeNotNull(this) { state -> - hideLoading() - when (state) { - Loading -> showLoading() - is Loaded -> { - showData(state.languages, state.snippet) - showValidationError(state.error) - } - is Completed -> { - viewModel.goToDetail(findNavController(), state.snippet.uuid) - } - is Error -> { - viewModel.goToError(findNavController(), state.error) - } - } - } - - viewModel.event.observeNotNull(this) { event -> - when (event) { - is Logout -> viewModel.goToLogin(findNavController()) - is Alert -> showToast(event.message) - is PastedFromClipboard -> { - editCode.setText(event.code) - updatePasteButtonVisibility() - } - } - } - } - - private fun showData(languages: List, snippet: Snippet?) { - // Check if data is different - val adapter = editLanguage.adapter - if (adapter == null || adapter.count != languages.size) { - editLanguage.setAdapter(getLanguageAdapter(languages)) - } - - snippet?.let { updatedSnippet -> - getDifferingFields(updatedSnippet).forEach { - if (it.id == R.id.editName) editName.setText(updatedSnippet.title) - if (it.id == R.id.editCode) editCode.setText(updatedSnippet.code.raw) - if (it.id == R.id.editLanguage) editLanguage.setText(updatedSnippet.language.raw) - } - } - - updatePasteButtonVisibility() - } - - private fun showLoading() { - loadingDialog = dialogs.loading.show() - } - - private fun hideLoading() { - loadingDialog?.dismiss() - } - - private fun showValidationError(error: String?) { - with(editError) { - if (error == null) { - gone() - return - } - visible() - text = error - } - } - - private fun applyChange() { - getTextsAndUpdateState() - if (isEdit()) { - viewModel.edit(args.id ?: "") - } else { - viewModel.create() - } - } - - private fun setupActions() { - editPasteFromClipboard.setOnClick { - viewModel.pasteFromClipboard() - } - } - - private fun getTextsAndUpdateState() { - val title = editName.getTyped() - val code = editCode.text.toString() - val language = editLanguage.getTyped() - viewModel.update(title, code, language) - updatePasteButtonVisibility() - } - - private fun getDifferingFields(snip: Snippet): List { - val differingFields = mutableListOf() - - if (editName.getTyped() != snip.title) { - differingFields.add(editName) - } - - if (editCode.text.toString() != snip.code.raw) { - differingFields.add(editCode) - } - - if (editLanguage.getTyped() != snip.language.raw) { - differingFields.add(editLanguage) - } - - return differingFields - } - - - private fun setupToolbar() { - with(editToolbar) { - // setupWithNavController sets action bar with default implementation - setupWithNavController(findNavController()) - // So, each customization must be done after - setNavigationOnClickListener { requireActivity().onBackPressed() } - findNavController().addOnDestinationChangedListener(destinationChange) - inflateMenu(R.menu.edit_menu) - setOnMenuItemClickListener { menu -> - when (menu.itemId) { - R.id.editMenuSave -> { applyChange(); true } - else -> false - } - } - } - } - - private fun isEdit(): Boolean = args.id != null - - private fun getLanguageAdapter(languages: List) = ArrayAdapter( - requireContext(), - android.R.layout.simple_dropdown_item_1line, - languages - ) - - private fun setupExitDialogOnBack() { - onBackPressed { - val snippet = (viewModel.state.value as? Loaded)?.snippet - - when { - snippet == null -> { back(); return@onBackPressed } - isEdit().not() && fieldsAreEmpty() -> { back(); return@onBackPressed } - getDifferingFields(snippet).isEmpty() -> { back(); return@onBackPressed } - } - - dialogs.quit - .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } - .setPositiveButton(R.string.okay) { dialog, _ -> - dialog.dismiss() - back() - }.show() - } - } - - private fun updatePasteButtonVisibility() { - if (editCode.text.toString().isNotBlank()) { - editPasteFromClipboard.gone() - } - } - - private fun fieldsAreEmpty() = - editName.getTyped().isEmpty() - && editCode.getTyped().isEmpty() - && editLanguage.getTyped().isEmpty() - - override fun onStop() { - findNavController().removeOnDestinationChangedListener(destinationChange) - super.onStop() - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/edit/EditNavigator.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/edit/EditNavigator.kt deleted file mode 100644 index 712d53e..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/edit/EditNavigator.kt +++ /dev/null @@ -1,19 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.edit - -import androidx.navigation.NavController -import pl.tkadziolka.snipmeandroid.util.extension.navigateSafe - -class EditNavigator { - - fun goToDetail(nav: NavController, snippetId: String) { - nav.navigateSafe(EditFragmentDirections.goToDetail(snippetId)) - } - - fun goToLogin(nav: NavController) { - nav.navigateSafe(EditFragmentDirections.goToLogin()) - } - - fun goToError(nav: NavController, message: String?) { - nav.navigateSafe(EditFragmentDirections.goToError(message)) - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/edit/EditViewModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/edit/EditViewModel.kt deleted file mode 100644 index c7ae4ab..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/edit/EditViewModel.kt +++ /dev/null @@ -1,241 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.edit - -import android.text.SpannableString -import androidx.navigation.NavController -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable -import io.reactivex.rxkotlin.plusAssign -import io.reactivex.rxkotlin.subscribeBy -import io.reactivex.schedulers.Schedulers -import pl.tkadziolka.snipmeandroid.domain.error.exception.* -import pl.tkadziolka.snipmeandroid.domain.message.ErrorMessages -import pl.tkadziolka.snipmeandroid.domain.message.ValidationMessages -import pl.tkadziolka.snipmeandroid.domain.reaction.UserReaction -import pl.tkadziolka.snipmeandroid.domain.snippet.EditInteractor -import pl.tkadziolka.snipmeandroid.domain.snippets.* -import pl.tkadziolka.snipmeandroid.domain.validation.FieldValidator -import pl.tkadziolka.snipmeandroid.domain.validation.messageOrNull -import pl.tkadziolka.snipmeandroid.ui.error.ErrorParsable -import pl.tkadziolka.snipmeandroid.ui.session.SessionViewModel -import pl.tkadziolka.snipmeandroid.ui.viewmodel.SingleLiveEvent -import pl.tkadziolka.snipmeandroid.ui.viewmodel.StateViewModel -import pl.tkadziolka.snipmeandroid.util.extension.inProgress -import timber.log.Timber -import java.util.* - -private val NO_ERROR: String? = null - -class EditViewModel( - private val messages: ErrorMessages, - private val errorMessages: ErrorMessages, - private val validationMessages: ValidationMessages, - private val navigator: EditNavigator, - private val interactor: EditInteractor, - private val session: SessionViewModel -) : StateViewModel(), ErrorParsable { - private val mutableEvent = SingleLiveEvent() - val event = mutableEvent - - private var createDisposable: Disposable? = null - - override fun parseError(throwable: Throwable) { - when (throwable) { - is ConnectionException -> setState(Error(errorMessages.parse(throwable))) - is ContentNotFoundException -> setState(Error(errorMessages.parse(throwable))) - is ForbiddenActionException -> setState(Error(errorMessages.parse(throwable))) - is NetworkNotAvailableException -> setState(Error(errorMessages.parse(throwable))) - is NotAuthorizedException -> session.logOut { mutableEvent.value = Logout } - is RemoteException -> setState(Error(errorMessages.parse(throwable))) - is SessionExpiredException -> session.logOut { mutableEvent.value = Logout } - else -> setState(Error(errorMessages.parse(throwable))) - } - } - - fun load(uuid: String?) { - // Don't load the same state again when fragment is recreated - if (state.value != null) return - - setState(Loading) - - interactor.languages() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy( - onSuccess = { languages -> fetchSnippetOrComplete(languages, uuid) }, - onError = { error -> - Timber.e("Couldn't load languages, ERROR = $error") - parseError(error) - } - ).also { disposables += it } - } - - fun goToDetail(nav: NavController, snippetId: String) { - navigator.goToDetail(nav, snippetId) - } - - fun goToLogin(nav: NavController) { - navigator.goToLogin(nav) - } - - fun goToError(nav: NavController, message: String?) { - navigator.goToError(nav, message) - } - - fun update(title: String, code: String, language: String) { - getLoaded()?.let { - val snip = it.snippet ?: return - if (title != snip.title || code != snip.code.raw || language != snip.language.raw) - setState(it.copy(snippet = getTempSnippet(title, code, language))) - } - } - - fun create() { - if (state.value is Loading || createDisposable.inProgress()) return - - getSnippetOnValid()?.let { snip -> - setState(Loading) - createDisposable = interactor.create(snip.title, snip.code.raw, snip.language.raw) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy( - onSuccess = { snippet -> setState(Completed(snippet)) }, - onError = { error -> - Timber.e("Couldn't create a new snippet, ERROR = $error") - mutableEvent.value = Alert(errorMessages.parse(error)) - } - ).also { disposables += it } - } - } - - fun edit(uuid: String) { - if (state.value is Loading || createDisposable.inProgress()) return - - getSnippetOnValid()?.let { snip -> - setState(Loading) - createDisposable = interactor.update( - uuid, - snip.title, - snip.code.raw, - snip.language.raw, - snip.visibility - ) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy( - onSuccess = { snippet -> setState(Completed(snippet)) }, - onError = { error -> - Timber.e("Couldn't update the snippet, ERROR = $error") - mutableEvent.value = Alert(errorMessages.parse(error)) - } - ).also { disposables += it } - } - } - - fun pasteFromClipboard() { - getLoaded()?.let { - interactor.getFromClipboard()?.let { codeFromClipboard -> - mutableEvent.value = PastedFromClipboard(codeFromClipboard) - } - } - } - - private fun fetchSnippetOrComplete(languages: List, uuid: String?) { - if (uuid == null) { - setState(Loaded(languages, getTempSnippet("", "", ""), NO_ERROR)) - return - } - - interactor.snippet(uuid) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy( - onSuccess = { snippet -> setState(Loaded(languages, snippet, NO_ERROR)) }, - onError = { error -> - Timber.e("Couldn't update the snippet, ERROR = $error") - parseError(error) - } - ).also { disposables += it } - } - - private fun getSnippetOnValid(): Snippet? { - getLoaded()?.let { - var escape = false - val snip = it.snippet ?: return null - - validateFields(snip.title, snip.code.raw, snip.language.raw) { errorMessage -> - setState(it.copy(error = errorMessage)) - escape = true - } - - if (escape) return null - - if (it.languages.contains(snip.language.raw).not()) { - setState(it.copy(error = messages.unknownLanguage)) - return null - } - - return snip - } - - return null - } - - private fun validateFields( - title: String, - code: String, - language: String, - onError: (String) -> Unit - ) { - FieldValidator(validationMessages).validate(title).messageOrNull()?.let { - onError(validationMessages.phrasesBlank) - return - } - - FieldValidator(validationMessages).validate(code).messageOrNull()?.let { - onError(validationMessages.phrasesBlank) - return - } - - FieldValidator(validationMessages).validate(language).messageOrNull()?.let { - onError(validationMessages.phrasesBlank) - return - } - } - - private fun getLoaded(): Loaded? = - if (state.value is Loaded) { - state.value as Loaded - } else { - null - } - - private fun getTempSnippet(title: String, code: String, language: String) = Snippet( - uuid = "", - title = title, - code = SnippetCode(raw = code, highlighted = SpannableString("")), - language = SnippetLanguage(raw = language, type = SnippetLanguageType.UNKNOWN), - visibility = SnippetVisibility.PUBLIC, - isOwner = false, - owner = Owner(0, ""), - modifiedAt = Date(), - numberOfLikes = 0, - numberOfDislikes = 0, - userReaction = UserReaction.NONE - ) -} - -sealed class EditViewState -object Loading : EditViewState() -data class Loaded( - val languages: List, - val snippet: Snippet?, - val error: String? -) : EditViewState() - -data class Completed(val snippet: Snippet) : EditViewState() -data class Error(val error: String?) : EditViewState() - -sealed class EditEvent -object Logout : EditEvent() -data class PastedFromClipboard(val code: String) : EditEvent() -data class Alert(val message: String) : EditEvent() \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/util/extension/ViewExtensions.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/util/extension/ViewExtensions.kt index 3a3fb6e..8d34ba4 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/util/extension/ViewExtensions.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/util/extension/ViewExtensions.kt @@ -11,15 +11,12 @@ import android.view.ViewGroup.MarginLayoutParams import android.widget.EditText import android.widget.ImageView import androidx.annotation.ColorRes -import androidx.annotation.DimenRes import androidx.annotation.LayoutRes import androidx.core.content.ContextCompat import androidx.core.view.children -import androidx.core.view.isInvisible import androidx.core.view.isVisible import com.bumptech.glide.Glide import io.github.kbiakov.codeview.CodeView -import kotlinx.android.synthetic.main.fragment_preview.* import pl.tkadziolka.snipmeandroid.R import pl.tkadziolka.snipmeandroid.util.SyntaxWindowTheme import pl.tkadziolka.snipmeandroid.util.view.DebouncingOnClickListener @@ -60,10 +57,6 @@ fun View.visible() { isVisible = true } -fun View.invisible() { - isInvisible = true -} - fun View.gone() { isVisible = false } @@ -105,26 +98,4 @@ fun ImageView.tint(@ColorRes res: Int) { ContextCompat.getColor(context, res), android.graphics.PorterDuff.Mode.SRC_IN ) -} - -fun View.setOnOutsideClick(@DimenRes marginRes: Int, action: () -> Unit) { - val marginPx = resources.getDimensionPixelSize(marginRes) - setOnTouchListener { view, event -> - view.performClick() - return@setOnTouchListener when { - event.x <= marginPx -> { - action(); true - } - event.y <= marginPx -> { - action(); true - } - view.width - event.x <= marginPx -> { - action(); true - } - view.height - event.y <= marginPx -> { - action(); true - } - else -> false - } - } } \ No newline at end of file From 9ecf7a925ee5de4395dc616bf4e69ab14d4074ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Fri, 20 Jan 2023 14:57:34 +0100 Subject: [PATCH 41/66] Removed most of presentation layer --- .../snipmeandroid/di/NavigatorModule.kt | 6 - .../tkadziolka/snipmeandroid/di/UtilModule.kt | 3 - .../snipmeandroid/di/ViewModelModule.kt | 6 +- .../pl/tkadziolka/snipmeandroid/ui/Dialogs.kt | 34 ---- .../snipmeandroid/ui/detail/DetailFragment.kt | 129 -------------- .../ui/detail/DetailNavigator.kt | 22 --- .../ui/detail/DetailViewModel.kt | 32 ---- .../snipmeandroid/ui/login/LoginFragment.kt | 158 ------------------ .../snipmeandroid/ui/login/LoginNavigator.kt | 15 -- .../snipmeandroid/ui/login/LoginViewModel.kt | 89 ---------- .../snipmeandroid/ui/main/MainFragment.kt | 110 ------------ .../snipmeandroid/ui/main/MainNavigator.kt | 35 ---- .../snipmeandroid/ui/main/MainViewModel.kt | 65 +------ .../util/extension/ViewExtensions.kt | 72 -------- .../util/view/LoadMoreListener.kt | 26 --- .../util/view/OutlinedTextInput.kt | 106 ------------ .../util/view/SnippetsFilterView.kt | 102 ----------- 17 files changed, 9 insertions(+), 1001 deletions(-) delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/Dialogs.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailFragment.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailNavigator.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginFragment.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginNavigator.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainFragment.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainNavigator.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/util/view/LoadMoreListener.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/util/view/OutlinedTextInput.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/util/view/SnippetsFilterView.kt diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/NavigatorModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/NavigatorModule.kt index 8935800..0f23c83 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/NavigatorModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/NavigatorModule.kt @@ -1,14 +1,8 @@ package pl.tkadziolka.snipmeandroid.di import org.koin.dsl.module -import pl.tkadziolka.snipmeandroid.ui.detail.DetailNavigator -import pl.tkadziolka.snipmeandroid.ui.login.LoginNavigator -import pl.tkadziolka.snipmeandroid.ui.main.MainNavigator import pl.tkadziolka.snipmeandroid.ui.splash.SplashNavigator internal val navigatorModule = module { factory { SplashNavigator() } - factory { LoginNavigator() } - factory { MainNavigator() } - factory { DetailNavigator() } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UtilModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UtilModule.kt index 979d45e..11a73c1 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UtilModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/UtilModule.kt @@ -3,7 +3,6 @@ package pl.tkadziolka.snipmeandroid.di import android.content.ClipboardManager import android.content.Context.CLIPBOARD_SERVICE import org.koin.android.ext.koin.androidApplication -import org.koin.android.ext.koin.androidContext import org.koin.dsl.module import pl.tkadziolka.snipmeandroid.BuildConfig import pl.tkadziolka.snipmeandroid.domain.error.DebugErrorHandler @@ -11,12 +10,10 @@ import pl.tkadziolka.snipmeandroid.domain.error.SafeErrorHandler import pl.tkadziolka.snipmeandroid.domain.message.ErrorMessages import pl.tkadziolka.snipmeandroid.domain.message.ValidationMessages import pl.tkadziolka.snipmeandroid.domain.message.RealValidationMessages -import pl.tkadziolka.snipmeandroid.ui.Dialogs internal val utilModule = module { factory { if (BuildConfig.DEBUG) DebugErrorHandler() else SafeErrorHandler() } factory { ErrorMessages(get()) } factory { RealValidationMessages(get()) } - factory { Dialogs(get()) } single { androidApplication().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt index ba6bfef..72ec73b 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt @@ -10,8 +10,8 @@ import pl.tkadziolka.snipmeandroid.ui.splash.SplashViewModel internal val viewModelModule = module { viewModel { SplashViewModel(get(), get()) } - viewModel { LoginViewModel(get(), get(), get(), get()) } + viewModel { LoginViewModel(get(), get(), get()) } viewModel { SessionViewModel(get()) } - viewModel { MainViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } - viewModel { DetailViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { MainViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { DetailViewModel(get(), get(), get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/Dialogs.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/Dialogs.kt deleted file mode 100644 index 7fe458f..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/Dialogs.kt +++ /dev/null @@ -1,34 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui - -import android.content.Context -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import pl.tkadziolka.snipmeandroid.R - -class Dialogs(private val context: Context) { - val loading by lazy { - MaterialAlertDialogBuilder(context) - .setTitle(R.string.loading) - .setView(R.layout.view_loading) - .setCancelable(false) - } - - val ok by lazy { - MaterialAlertDialogBuilder(context) - .setPositiveButton(R.string.okay) { dialog, _ -> dialog.cancel() } - .setCancelable(true) - } - - val alreadyRegistered by lazy { - MaterialAlertDialogBuilder(context) - .setTitle(R.string.no_account_title) - .setMessage(R.string.no_account_message) - .setCancelable(false) - } - - val quit by lazy { - MaterialAlertDialogBuilder(context) - .setTitle(R.string.quit_title) - .setMessage(R.string.quit_message) - .setCancelable(true) - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailFragment.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailFragment.kt deleted file mode 100644 index 26165c5..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailFragment.kt +++ /dev/null @@ -1,129 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.detail - -import android.os.Build -import androidx.core.view.isVisible -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import androidx.navigation.ui.setupWithNavController -import kotlinx.android.synthetic.main.fragment_detail.* -import pl.tkadziolka.snipmeandroid.R -import pl.tkadziolka.snipmeandroid.domain.reaction.UserReaction -import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet -import pl.tkadziolka.snipmeandroid.ui.viewmodel.ViewModelFragment -import pl.tkadziolka.snipmeandroid.ui.viewmodel.observeNotNull -import pl.tkadziolka.snipmeandroid.util.extension.* - -class DetailFragment : ViewModelFragment(DetailViewModel::class) { - private val args: DetailFragmentArgs by navArgs() - - override val layout: Int = R.layout.fragment_detail - - override fun onViewCreated() { - setupToolbar() - setupActions() - viewModel.load(args.uuid) - } - - override fun observeViewModel() { - viewModel.state.observeNotNull(this) { state -> - when (state) { - is Loading -> showLoading() - is Loaded -> showData(state.snippet) - is Error -> viewModel.goToError(findNavController(), state.error) - } - } - - viewModel.event.observeNotNull(this) { event -> - when (event) { - is Alert -> showToast(event.message) - is Logout -> { - viewModel.goToLogin(findNavController()) - } - } - } - } - - private fun showLoading() { - detailLoading.visible() - } - - private fun showData(snippet: Snippet) { - detailLoading.gone() - with(snippet) { - showActionsForOwner(isOwner) - detailName.text = title - detailLanguage.text = language.raw - detailLikeCounter.text = numberOfLikes.toString() - detailDislikeCounter.text = numberOfDislikes.toString() - setReactionState(snippet.userReaction) - detailCode.setCodeWithTheme(code.raw, language.type.fileExtension) - } - } - - private fun showActionsForOwner(isOwner: Boolean) { - detailToolbar.menu.findItem(R.id.detailMenuShare).isVisible = isOwner - detailEdit.isVisible = isOwner - } - - private fun setupActions() { - detailEdit.setOnClick { - viewModel.goToEdit(findNavController(), args.uuid) - } - - detailLikeAction.setOnClick { - setReactionAvailability(isAvailable = false) - viewModel.like() - } - - detailDislikeAction.setOnClick { - setReactionAvailability(isAvailable = false) - viewModel.dislike() - } - } - - private fun setupToolbar() { - with(detailToolbar) { - setupWithNavController(findNavController()) - inflateMenu(R.menu.detail_menu) - setOnMenuItemClickListener { menu -> - when (menu.itemId) { - R.id.detailMenuCopy -> { - viewModel.copyToClipboard() - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { - // Android 12+ shows message itself - showToast(getString(R.string.message_copied, detailName.text)) - } - true - } - R.id.detailMenuShare -> { - viewModel.goToShare(findNavController(), args.uuid); true - } - else -> false - } - } - } - } - - private fun setReactionState(userReaction: UserReaction) { - setReactionAvailability(isAvailable = true) - when (userReaction) { - UserReaction.LIKE -> { - detailLikeAction.tint(R.color.highlight) - detailDislikeAction.tint(R.color.white) - } - UserReaction.DISLIKE -> { - detailLikeAction.tint(R.color.white) - detailDislikeAction.tint(R.color.highlight) - } - UserReaction.NONE -> { - detailLikeAction.tint(R.color.white) - detailDislikeAction.tint(R.color.white) - } - } - } - - private fun setReactionAvailability(isAvailable: Boolean) { - detailLikeAction.isEnabled = isAvailable - detailLikeAction.isEnabled = isAvailable - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailNavigator.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailNavigator.kt deleted file mode 100644 index 4fdefaa..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailNavigator.kt +++ /dev/null @@ -1,22 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.detail - -import androidx.navigation.NavController -import pl.tkadziolka.snipmeandroid.util.extension.navigateSafe - -class DetailNavigator { - fun goToEdit(nav: NavController, snippetUUID: String) { - nav.navigateSafe(DetailFragmentDirections.goToEdit(snippetUUID)) - } - - fun goToShare(nav: NavController, snippetUUID: String) { - nav.navigateSafe(DetailFragmentDirections.goToShare(snippetUUID)) - } - - fun goToLogin(nav: NavController) { - nav.navigateSafe(DetailFragmentDirections.goToLogin()) - } - - fun goToError(nav: NavController, message: String?) { - nav.navigateSafe(DetailFragmentDirections.goToError(message)) - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailViewModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailViewModel.kt index 9b3b611..887a9c7 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailViewModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/detail/DetailViewModel.kt @@ -1,6 +1,5 @@ package pl.tkadziolka.snipmeandroid.ui.detail -import androidx.navigation.NavController import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.rxkotlin.plusAssign import io.reactivex.rxkotlin.subscribeBy @@ -21,7 +20,6 @@ import timber.log.Timber class DetailViewModel( private val errorMessages: ErrorMessages, - private val navigator: DetailNavigator, private val getSnippet: GetSingleSnippetUseCase, private val clipboard: AddToClipboardUseCase, private val getTargetReaction: GetTargetUserReactionUseCase, @@ -45,28 +43,6 @@ class DetailViewModel( } } - fun goToLogin(nav: NavController) { - navigator.goToLogin(nav) - } - - fun goToError(nav: NavController, message: String?) { - navigator.goToError(nav, message) - } - - fun load(uuid: String) { - setState(Loading) - getSnippet(uuid) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy( - onSuccess = { setState(Loaded(it)) }, - onError = { - Timber.e("Couldn't load snippets, error = $it") - parseError(it) - } - ).also { disposables += it } - } - fun like() { changeReaction(UserReaction.LIKE) } @@ -81,14 +57,6 @@ class DetailViewModel( } } - fun goToEdit(nav: NavController, snippetUUID: String) { - navigator.goToEdit(nav, snippetUUID) - } - - fun goToShare(nav: NavController, snippetUUID: String) { - navigator.goToShare(nav, snippetUUID) - } - private fun changeReaction(newReaction: UserReaction) { // Immediately show change to user val previousState = getLoaded() ?: return diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginFragment.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginFragment.kt deleted file mode 100644 index d5f913d..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginFragment.kt +++ /dev/null @@ -1,158 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.login - -import android.view.inputmethod.EditorInfo.IME_ACTION_DONE -import android.view.inputmethod.EditorInfo.IME_ACTION_NEXT -import androidx.appcompat.app.AlertDialog -import androidx.core.view.isVisible -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import kotlinx.android.synthetic.main.fragment_login.* -import pl.tkadziolka.snipmeandroid.BuildConfig -import pl.tkadziolka.snipmeandroid.R -import pl.tkadziolka.snipmeandroid.ui.Dialogs -import pl.tkadziolka.snipmeandroid.ui.viewmodel.ViewModelFragment -import pl.tkadziolka.snipmeandroid.ui.viewmodel.observeNotNull -import pl.tkadziolka.snipmeandroid.util.extension.* - -class LoginFragment : ViewModelFragment(LoginViewModel::class) { - - private val args: LoginFragmentArgs by navArgs() - - override val layout: Int = R.layout.fragment_login - - private lateinit var dialogs: Dialogs - private var loadingDialog: AlertDialog? = null - - override fun onViewCreated() { - dialogs = Dialogs(requireContext()) - - if (BuildConfig.DEBUG) - loginVersion.text = "v${BuildConfig.VERSION_NAME}" - - setupActions() - viewModel.startup(args.login) - } - - override fun observeViewModel() { - viewModel.state.observeNotNull(this) { state -> - hideLoading() - when (state) { - Loading -> showLoading() - is Startup -> { - showOnlyLogin(state.login) - showValidationError(state.error) - } - is Login -> { - setRegisterViewState(isRegister = false) - showValidationError(state.error) - } - is Register -> { - setRegisterViewState(isRegister = true) - showValidationError(state.error) - } - is UserFound -> showUserFound() - is Completed -> viewModel.goToMain(findNavController()) - } - - viewModel.event.observeNotNull(this) { event -> - hideLoading() - when (event) { - is Error -> viewModel.goToError(findNavController(), event.message) - } - } - } - } - - private fun setupActions() { - loginAction.setOnClick { - when (viewModel.state.value) { - is Startup -> viewModel.validateLogin(loginName.text.trim()) - is Login -> viewModel.login( - loginName.text.trim(), - loginPassword.text.trim() - ) - is Register -> viewModel.register( - loginName.text.trim(), - loginPassword.text.trim(), - loginPasswordRepeat.text.trim(), - loginEmail.text.trim() - ) - else -> Unit - } - } - - loginRegisterAction.setOnClick { - when (viewModel.state.value) { - is Startup -> viewModel.register("", "", "", "") - else -> Unit - } - } - } - - private fun showLoading() { - loadingDialog = dialogs.loading.show() - } - - private fun hideLoading() { - loadingDialog?.dismiss() - } - - private fun showOnlyLogin(login: String?) { - val phrase = getString(R.string.call_login) - loginHelper.text = phrase - login?.let { loginName.text = login } - loginName.imeOptions = IME_ACTION_DONE - } - - private fun setRegisterViewState(isRegister: Boolean) { - loginImage.isVisible = isRegister.not() - loginTitle.isVisible = isRegister.not() - loginPassword.isVisible = true - loginPasswordRepeat.isVisible = isRegister - loginEmail.isVisible = isRegister - loginRegisterAction.isVisible = false - - val imeOption = if (isRegister) { - IME_ACTION_NEXT - } else { - IME_ACTION_DONE - } - - val helperText = if (isRegister) { - getString(R.string.call_register) - } else { - getString(R.string.call_login) - } - - loginName.imeOptions = IME_ACTION_NEXT - loginPassword.imeOptions = imeOption - loginHelper.text = helperText - } - - private fun showValidationError(error: String?) { - with(loginError) { - if (error == null) { - gone() - return - } - visible() - text = error - } - } - - private fun showUserFound() { - dialogs.alreadyRegistered - .setPositiveButton(R.string.okay) { dialog, _ -> - viewModel.goToLogin(viewModel.getLogin().orEmpty()) - dialog.dismiss() - } - .setNegativeButton(R.string.cancel) { dialog, _ -> - viewModel.revokeLogin(viewModel.getLogin().orEmpty()) - dialog.dismiss() - }.show() - } - - private fun showDialog(message: String) { - dialogs.ok.setMessage(message).show() - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginNavigator.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginNavigator.kt deleted file mode 100644 index 4c5e20b..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginNavigator.kt +++ /dev/null @@ -1,15 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.login - -import androidx.navigation.NavController -import pl.tkadziolka.snipmeandroid.util.extension.navigateSafe - -class LoginNavigator { - - fun goToMain(navController: NavController) { - navController.navigateSafe(LoginFragmentDirections.goToMain()) - } - - fun goToError(nav: NavController, message: String?) { - nav.navigateSafe(LoginFragmentDirections.goToError(message)) - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginViewModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginViewModel.kt index cf1397e..0d5bd8c 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginViewModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/login/LoginViewModel.kt @@ -29,9 +29,7 @@ class LoginViewModel( private val errorMessages: ErrorMessages, private val messages: ValidationMessages, private val interactor: LoginInteractor, - private val navigator: LoginNavigator ) : StateViewModel(), ErrorParsable { - private var identifyDisposable: Disposable? = null private var loginDisposable: Disposable? = null private var registerDisposable: Disposable? = null @@ -51,52 +49,6 @@ class LoginViewModel( } } - fun startup(login: String) { - // Don't reset state on configuration changed - if (state.value != null) return - - setState(Startup(login, null)) - } - - fun goToLogin(login: String) { - setState(Login(login, "", null)) - } - - fun goToMain(nav: NavController) { - navigator.goToMain(nav) - } - - fun goToError(nav: NavController, message: String?) { - navigator.goToError(nav, message) - } - - fun revokeLogin(login: String) { - setState(Startup(login, null)) - } - - fun validateLogin(login: String) { - if (identifyDisposable.inProgress()) return - setState(Loading) - - FieldValidator(messages).validate(login).messageOrNull()?.let { message -> - setState(Startup(login, message)) - return - } - - identifyDisposable = interactor.identify(login) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy( - onSuccess = { identified -> publishIdentified(login, identified) }, - onError = { - val errorMessage = getErrorMessage(it) - Timber.d("Couldn't identify user = $login, error = $it") - setState(Startup(login, errorMessage)) - parseError(it) - } - ).also { disposables += it } - } - fun login(login: String, password: String) { if (loginDisposable.inProgress()) return setState(Loading) @@ -120,47 +72,6 @@ class LoginViewModel( ).also { disposables += it } } - fun register(login: String, password: String, repeatedPassword: String, email: String) { - if (registerDisposable.inProgress()) return - setState(Loading) - - SignUpValidator(messages).validate(login, password, repeatedPassword, email).messageOrNull() - ?.let { message -> - setState(Register(login, password, repeatedPassword, email, message)) - return - } - - registerDisposable = interactor.register(login, password, email) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy( - onComplete = { setState(Completed(login)) }, - onError = { - val errorMessage = getErrorMessage(it) - Timber.d("Couldn't register user = $login, error = $it") - setState(Register(login, password, repeatedPassword, email, errorMessage)) - parseError(it) - } - ).also { disposables += it } - } - - fun getLogin(): String? = when (state.value) { - is Startup -> (state.value as Startup).login - is Login -> (state.value as Login).login - is Register -> (state.value as Register).login - is UserFound -> (state.value as UserFound).login - is Completed -> (state.value as Completed).login - else -> null - } - - private fun publishIdentified(login: String, identified: Boolean) { - if (identified) { - setState(UserFound(login)) - } else { - setState(Register(login, "", "", "", null)) - } - } - private fun setEvent(event: LoginEvent) { mutableEvent.value = event } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainFragment.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainFragment.kt deleted file mode 100644 index b729cb5..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainFragment.kt +++ /dev/null @@ -1,110 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.main - -import androidx.annotation.LayoutRes -import androidx.navigation.fragment.findNavController -import kotlinx.android.synthetic.main.fragment_main.* -import pl.tkadziolka.snipmeandroid.BuildConfig -import pl.tkadziolka.snipmeandroid.R -import pl.tkadziolka.snipmeandroid.domain.repository.snippet.SNIPPET_PAGE_SIZE -import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet -import pl.tkadziolka.snipmeandroid.domain.user.User -import pl.tkadziolka.snipmeandroid.ui.viewmodel.ViewModelFragment -import pl.tkadziolka.snipmeandroid.ui.viewmodel.observeNotNull -import pl.tkadziolka.snipmeandroid.util.extension.* -import pl.tkadziolka.snipmeandroid.util.view.LoadMoreListener - -private const val TOP_OF_LIST = 0 - -class MainFragment : ViewModelFragment(MainViewModel::class) { - - private val loadMore = LoadMoreListener(SNIPPET_PAGE_SIZE) { - viewModel.loadNextPage() - } - - private val mainAdapter by lazy { - SnippetAdapter( - clickListener = { snippet -> - viewModel.goToDetail(findNavController(), snippet.uuid) - }, - pressListener = { snippet -> - with(snippet) { - viewModel.goToPreview(findNavController(), title, uuid, code.raw, language.raw) - } - } - ) - } - - @LayoutRes - override val layout: Int = R.layout.fragment_main - - override fun onViewCreated() { - if (BuildConfig.DEBUG) - mainVersion.text = "v${BuildConfig.VERSION_NAME}" - - setupMenu() - setupActions() - showLogin("") - mainList.adapter = mainAdapter - viewModel.init() - viewModel.refreshSnippetUpdates() - } - - override fun observeViewModel() { - viewModel.state.observeNotNull(this) { state -> - when (state) { - Loading -> showLoading() - is Loaded -> showData(state.user, state.snippets) - is Error -> viewModel.goToError(findNavController(), state.message) - } - } - - viewModel.event.observeNotNull(this) { event -> - when (event) { - is ListRefreshed -> { - mainList.smoothScrollToPosition(TOP_OF_LIST) - mainAppBar.setExpanded(true) - } - is Alert -> showToast(event.message) - is Logout -> viewModel.goToLogin(findNavController()) - } - } - } - - private fun showLoading() { - mainLoading.visible() - } - - private fun showData(user: User, snippets: List) { - mainLoading.gone() - showLogin(user.login) - mainImage.loadWithFallback(user.photo) - with(mainList) { - visible() - mainAdapter.submitList(snippets) - } - } - - private fun showLogin(login: String) { - mainLogin.text = login - } - - private fun setupActions() { - mainAdd.setOnClick { viewModel.goToEdit(findNavController()) } - mainFilters.filterListener = { viewModel.filter(it) } - mainList.addOnScrollListener(loadMore) - } - - private fun setupMenu() { - with(mainToolbar) { - inflateMenu(R.menu.main_menu) - setOnMenuItemClickListener { item -> - when (item.itemId) { - R.id.mainMenuDonate -> { viewModel.goToDonate(findNavController()); true } - R.id.mainMenuContact -> { viewModel.goToContact(findNavController()); true } - R.id.mainMenuLogout -> { viewModel.logOut(); true } - else -> false - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainNavigator.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainNavigator.kt deleted file mode 100644 index 03b6829..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainNavigator.kt +++ /dev/null @@ -1,35 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.main - -import androidx.navigation.NavController -import pl.tkadziolka.snipmeandroid.util.extension.navigateSafe - -class MainNavigator { - - fun goToDetail(nav: NavController, snippetId: String) { - nav.navigateSafe(MainFragmentDirections.goToDetail(snippetId)) - } - - fun goToPreview(nav: NavController, title: String, uuid: String, code: String, language: String) { - nav.navigateSafe(MainFragmentDirections.goToPreview(title, uuid, code, language)) - } - - fun goToEdit(nav: NavController) { - nav.navigateSafe(MainFragmentDirections.goToEdit()) - } - - fun goToLogin(nav: NavController) { - nav.navigateSafe(MainFragmentDirections.goToLogin()) - } - - fun goToError(nav: NavController, message: String?) { - nav.navigateSafe(MainFragmentDirections.goToError(message)) - } - - fun goToContact(nav: NavController) { - nav.navigateSafe(MainFragmentDirections.goToContact()) - } - - fun goToDonate(nav: NavController) { - nav.navigateSafe(MainFragmentDirections.goToDonate()) - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainViewModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainViewModel.kt index 4eb5a11..6918b22 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainViewModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainViewModel.kt @@ -1,10 +1,10 @@ package pl.tkadziolka.snipmeandroid.ui.main -import androidx.navigation.NavController import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.rxkotlin.plusAssign import io.reactivex.rxkotlin.subscribeBy import io.reactivex.schedulers.Schedulers +import pl.tkadziolka.snipmeandroid.bridge.Bridge import pl.tkadziolka.snipmeandroid.domain.error.exception.* import pl.tkadziolka.snipmeandroid.domain.message.ErrorMessages import pl.tkadziolka.snipmeandroid.domain.snippet.ObserveUpdatedSnippetPageUseCase @@ -16,13 +16,11 @@ import pl.tkadziolka.snipmeandroid.ui.error.ErrorParsable import pl.tkadziolka.snipmeandroid.ui.session.SessionViewModel import pl.tkadziolka.snipmeandroid.ui.viewmodel.PersistedStateViewModel import pl.tkadziolka.snipmeandroid.ui.viewmodel.SingleLiveEvent -import pl.tkadziolka.snipmeandroid.util.view.SnippetFilter import timber.log.Timber private const val ONE_PAGE = 1 class MainViewModel( - private val navigator: MainNavigator, private val errorMessages: ErrorMessages, private val getUser: GetSingleUserUseCase, private val getSnippets: GetSnippetsUseCase, @@ -64,58 +62,7 @@ class MainViewModel( ).also { disposables += it } } - fun goToDetail(navController: NavController, snippetId: String) { - navigator.goToDetail(navController, snippetId) - } - - fun goToPreview( - nav: NavController, - title: String, - uuid: String, - code: String, - language: String - ) { - navigator.goToPreview(nav, title, uuid, code, language) - } - - fun goToEdit(nav: NavController) { - navigator.goToEdit(nav) - } - - fun goToLogin(nav: NavController) { - navigator.goToLogin(nav) - } - - fun goToError(nav: NavController, message: String?) { - navigator.goToError(nav, message) - } - - fun goToContact(nav: NavController) { - navigator.goToContact(nav) - } - - fun goToDonate(nav: NavController) { - navigator.goToDonate(nav) - } - - fun loadNextPage() { -// getLoadedState()?.let { state -> -// hasMore(state.filters, state.pages) -// .subscribeOn(Schedulers.io()) -// .observeOn(AndroidSchedulers.mainThread()) -// .subscribeBy( -// onSuccess = { hasMore -> -// if (hasMore) loadSnippets(state.user, pages = state.pages + ONE_PAGE) -// }, -// onError = { -// Timber.e("Couldn't check next page, error = $it") -// mutableEvent.value = Alert(errorMessages.parse(it)) -// }) -// .also { disposables += it } -// } - } - - fun filter(filter: SnippetFilter) { + fun filter(filter: Bridge.SnippetFilter) { val scope = filterToScope(filter) getLoadedState()?.let { state -> loadSnippets(state.user, pages = ONE_PAGE, scope = scope) @@ -169,16 +116,16 @@ class MainViewModel( private fun getLoadedState(): Loaded? = state.value as? Loaded - private fun filterToScope(filter: SnippetFilter) = + private fun filterToScope(filter: Bridge.SnippetFilter) = when (filter) { - SnippetFilter.ALL -> SnippetScope.ALL - SnippetFilter.MINE -> SnippetScope.OWNED +// Bridge.SnippetFilter.ALL -> SnippetScope.ALL +// Bridge.SnippetFilter.MINE -> SnippetScope.OWNED else -> SnippetScope.SHARED_FOR } private fun getScope(): SnippetScope { getLoadedState()?.let { -// return it.scope + } return SnippetScope.ALL } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/util/extension/ViewExtensions.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/util/extension/ViewExtensions.kt index 8d34ba4..1ecff5e 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/util/extension/ViewExtensions.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/util/extension/ViewExtensions.kt @@ -1,68 +1,18 @@ package pl.tkadziolka.snipmeandroid.util.extension -import android.content.res.TypedArray -import android.os.Parcelable -import android.util.AttributeSet -import android.util.SparseArray import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.ViewGroup.MarginLayoutParams -import android.widget.EditText import android.widget.ImageView import androidx.annotation.ColorRes import androidx.annotation.LayoutRes import androidx.core.content.ContextCompat -import androidx.core.view.children -import androidx.core.view.isVisible -import com.bumptech.glide.Glide -import io.github.kbiakov.codeview.CodeView -import pl.tkadziolka.snipmeandroid.R -import pl.tkadziolka.snipmeandroid.util.SyntaxWindowTheme import pl.tkadziolka.snipmeandroid.util.view.DebouncingOnClickListener -fun ViewGroup.first(): View = getChildAt(0) - -fun View.setMargins( - left: Int? = null, - top: Int? = null, - right: Int? = null, - bottom: Int? = null -) { - if (layoutParams is MarginLayoutParams) { - val layoutParams = layoutParams as MarginLayoutParams - layoutParams.setMargins(left ?: 0, top ?: 0, right ?: 0, bottom ?: 0) - requestLayout() - } -} - -fun ViewGroup.saveChildViewStates(): SparseArray { - val childViewStates = SparseArray() - children.forEach { child -> child.saveHierarchyState(childViewStates) } - return childViewStates -} - -fun ViewGroup.restoreChildViewStates(childViewStates: SparseArray) { - children.forEach { child -> child.restoreHierarchyState(childViewStates) } -} - -fun View.getStyledAttributes(attrs: AttributeSet?, res: IntArray): TypedArray = - context.theme.obtainStyledAttributes(attrs, res, 0, 0) - fun ViewGroup.inflate(@LayoutRes resource: Int, attachToRoot: Boolean = false): View { return LayoutInflater.from(this.context).inflate(resource, this, attachToRoot)!! } -fun View.visible() { - isVisible = true -} - -fun View.gone() { - isVisible = false -} - -fun EditText.getTyped() = text.toString() - fun View.setOnClick(intervalMillis: Long = 300, doClick: (View) -> Unit) = setOnClickListener( DebouncingOnClickListener( @@ -71,28 +21,6 @@ fun View.setOnClick(intervalMillis: Long = 300, doClick: (View) -> Unit) = ) ) -fun CodeView.setCodeWithTheme(code: String, language: String?, theme: SyntaxWindowTheme? = null) { - with(this) { - if (language != null) setCode(code, language) else setCode(code) - setOptions( - getOptions()!!.copy( - code = code, - language = language, - animateOnHighlight = false, - ) - ) - } -} - -fun ImageView.loadWithFallback(image: String) { - Glide.with(this) - .load(image) - .fallback(R.drawable.placeholder) - .placeholder(R.drawable.placeholder) - .error(R.drawable.placeholder) - .into(this) -} - fun ImageView.tint(@ColorRes res: Int) { setColorFilter( ContextCompat.getColor(context, res), diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/util/view/LoadMoreListener.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/util/view/LoadMoreListener.kt deleted file mode 100644 index e3a2d11..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/util/view/LoadMoreListener.kt +++ /dev/null @@ -1,26 +0,0 @@ -package pl.tkadziolka.snipmeandroid.util.view - -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView - -private const val NO_ITEM = 0 -private const val ZERO_INDEX = 1 - -class LoadMoreListener( - private val itemsPerPage: Int = 1, - private val action: (Int) -> Unit -): RecyclerView.OnScrollListener() { - - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) - if (dy > 0) { - // Scroll down - val linearLayoutManager = recyclerView.layoutManager as LinearLayoutManager - val lastItem = linearLayoutManager.findLastCompletelyVisibleItemPosition() - val itemsLeft = (lastItem + ZERO_INDEX) % itemsPerPage - if (lastItem > RecyclerView.NO_POSITION && itemsLeft == NO_ITEM) { - action(lastItem) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/util/view/OutlinedTextInput.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/util/view/OutlinedTextInput.kt deleted file mode 100644 index cee04d6..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/util/view/OutlinedTextInput.kt +++ /dev/null @@ -1,106 +0,0 @@ -package pl.tkadziolka.snipmeandroid.util.view - -import android.content.Context -import android.os.Bundle -import android.os.Parcelable -import android.text.InputType.TYPE_NULL -import android.util.AttributeSet -import android.util.SparseArray -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.EditorInfo.IME_NULL -import android.widget.EditText -import android.widget.FrameLayout -import com.google.android.material.textfield.TextInputLayout.END_ICON_PASSWORD_TOGGLE -import kotlinx.android.synthetic.main.view_outlined_text_input.view.* -import pl.tkadziolka.snipmeandroid.R -import pl.tkadziolka.snipmeandroid.util.extension.getStyledAttributes -import pl.tkadziolka.snipmeandroid.util.extension.restoreChildViewStates -import pl.tkadziolka.snipmeandroid.util.extension.saveChildViewStates -import timber.log.Timber -import java.util.* -import kotlin.random.Random - -private const val SKIP_ANIMATION = true - -class OutlinedTextInput : FrameLayout { - private val editText: EditText? get() = outlinedTextField.editText - - private val randomId get() = Random(Date().time).nextInt(0, 10) - - constructor(context: Context) : this(context, null) - - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - - constructor(context: Context, attrs: AttributeSet?, style: Int) : super(context, attrs, style) { - inflate(context, R.layout.view_outlined_text_input, this) - editText?.id = randomId - getStyledAttributes(attrs, R.styleable.OutlinedTextInput).apply { - try { - with(outlinedTextField) { - hint = getString(R.styleable.OutlinedTextInput_android_hint) - editText?.setText(getString(R.styleable.OutlinedTextInput_android_text)) - editText?.inputType = - getInteger(R.styleable.OutlinedTextInput_android_inputType, TYPE_NULL) - editText?.imeOptions = - getInteger(R.styleable.OutlinedTextInput_android_imeOptions, IME_NULL) - editText?.isSingleLine = - getBoolean(R.styleable.OutlinedTextInput_android_singleLine, true) - - val isPassword = - getBoolean(R.styleable.OutlinedTextInput_android_password, false) - if (isPassword) { - endIconMode = END_ICON_PASSWORD_TOGGLE - passwordVisibilityToggleRequested(SKIP_ANIMATION) - editText?.inputType = EditorInfo.TYPE_TEXT_VARIATION_PASSWORD - } - } - } catch (e: Exception) { - Timber.e("Error during resolving style attributes, error = $e") - } finally { - recycle() - } - } - } - - var text: String - get() = editText?.text?.toString().orEmpty() - set(value) { - editText?.setText(value) - } - - var imeOptions: Int - get() = editText?.imeOptions ?: IME_NULL - set(options) { - editText?.imeOptions = options - } - - override fun dispatchSaveInstanceState(container: SparseArray) { - dispatchFreezeSelfOnly(container) - } - - override fun dispatchRestoreInstanceState(container: SparseArray) { - dispatchThawSelfOnly(container) - } - - override fun onSaveInstanceState(): Parcelable { - return Bundle().apply { - putParcelable(SUPER_STATE_KEY, super.onSaveInstanceState()) - putSparseParcelableArray(SPARSE_STATE_KEY, saveChildViewStates()) - } - } - - override fun onRestoreInstanceState(state: Parcelable?) { - var newState = state - if (newState is Bundle) { - val childrenState = newState.getSparseParcelableArray(SPARSE_STATE_KEY) - childrenState?.let { restoreChildViewStates(it) } - newState = newState.getParcelable(SUPER_STATE_KEY) - } - super.onRestoreInstanceState(newState) - } - - companion object { - private const val SPARSE_STATE_KEY = "SPARSE_STATE_KEY" - private const val SUPER_STATE_KEY = "SUPER_STATE_KEY" - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/util/view/SnippetsFilterView.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/util/view/SnippetsFilterView.kt deleted file mode 100644 index 2915613..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/util/view/SnippetsFilterView.kt +++ /dev/null @@ -1,102 +0,0 @@ -package pl.tkadziolka.snipmeandroid.util.view - -import android.content.Context -import android.os.Bundle -import android.os.Parcelable -import android.util.AttributeSet -import android.view.ViewGroup -import android.widget.TextView -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.view.forEach -import kotlinx.android.synthetic.main.view_snippets_filter.view.* -import pl.tkadziolka.snipmeandroid.R -import pl.tkadziolka.snipmeandroid.util.extension.drawable -import pl.tkadziolka.snipmeandroid.util.extension.first -import pl.tkadziolka.snipmeandroid.util.extension.setMargins -import pl.tkadziolka.snipmeandroid.util.extension.setOnClick -import pl.tkadziolka.snipmeandroid.util.view.SnippetFilter.* - -typealias FilterListener = (SnippetFilter) -> Unit - -enum class SnippetFilter { ALL, MINE, SHARED } - -class SnippetsFilterView : ConstraintLayout, SavedStateView { - private var shouldNotify = true - - var selectedFilter = ALL - - var filterListener: FilterListener? = null - - override val onSaveState: (Bundle) -> Bundle = { - it.apply { putString(SNIPPET_FILTER_KEY, selectedFilter.name) } - } - - override val onRestoreState: (Bundle) -> Unit = { - markFilter(valueOf(it.getString(SNIPPET_FILTER_KEY, ""))) - } - - constructor(context: Context) : this(context, null) - - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - - constructor(context: Context, attrs: AttributeSet?, style: Int) : super(context, attrs, style) { - inflate(context, R.layout.view_snippets_filter, this) - setupActions() - markFilter(selectedFilter) - } - - override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { - super.onLayout(changed, left, top, right, bottom) - val child = filterViewScrollView.first() as ViewGroup - - if (child.width >= filterViewScrollView.width) { - val startMargin = resources.getDimensionPixelSize(R.dimen.spacing_big) - child.first().setMargins(left = startMargin) - } - } - - fun setFilter(filter: SnippetFilter) { - resetSelection() - selectedFilter = filter - val view = when (filter) { - ALL -> viewAllAction - MINE -> viewMineAction - SHARED -> viewSharedAction - } - view.setTextAppearance(R.style.TextAppearance_Filter_Selected) - view.background = context.drawable(R.drawable.filter_background_selected) - - if (shouldNotify) - filterListener?.invoke(filter) - } - - override fun onSaveInstanceState(): Parcelable? = onSaveInstance(super.onSaveInstanceState()) - - override fun onRestoreInstanceState(state: Parcelable?) = - super.onRestoreInstanceState(onRestoreInstance(state)) - - private fun markFilter(filter: SnippetFilter) { - shouldNotify = false - setFilter(filter) - shouldNotify = true - } - - private fun setupActions() { - viewAllAction.setOnClick { setFilter(ALL) } - viewMineAction.setOnClick { setFilter(MINE) } - viewSharedAction.setOnClick { setFilter(SHARED) } - } - - private fun resetSelection() { - filterViewContainer.forEach { view -> - (view as? TextView)?.let { - it.setTextAppearance(R.style.TextAppearance_Filter) - it.background = context.drawable(R.drawable.filter_background) - } - } - } - - private companion object { - private const val SNIPPET_FILTER_KEY = "snippet_filter_key" - } -} \ No newline at end of file From 7d758100623d2ed4b37c6dbed4d599daa3f5c130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20K=C4=85dzio=C5=82ka?= Date: Sat, 21 Jan 2023 07:57:36 +0100 Subject: [PATCH 42/66] Removed most of ui layer --- app/src/main/AndroidManifest.xml | 8 +- .../snipmeandroid/bridge/login/LoginModel.kt | 1 - .../tkadziolka/snipmeandroid/di/KoinConfig.kt | 1 - .../snipmeandroid/di/NavigatorModule.kt | 8 - .../snipmeandroid/di/ViewModelModule.kt | 2 - .../snipmeandroid/ui/error/ErrorFragment.kt | 48 ----- .../snipmeandroid/ui/error/ErrorParsable.kt | 1 - .../snipmeandroid/ui/main/MainActivity.kt | 1 - .../snipmeandroid/ui/main/SnippetAdapter.kt | 78 ------- .../snipmeandroid/ui/splash/SplashFragment.kt | 24 --- .../ui/splash/SplashNavigator.kt | 15 -- .../ui/splash/SplashViewModel.kt | 45 ---- app/src/main/res/layout/activity_main.xml | 20 -- app/src/main/res/layout/fragment_contact.xml | 106 ---------- app/src/main/res/layout/fragment_detail.xml | 135 ------------ app/src/main/res/layout/fragment_donate.xml | 167 --------------- app/src/main/res/layout/fragment_edit.xml | 104 ---------- app/src/main/res/layout/fragment_error.xml | 101 --------- app/src/main/res/layout/fragment_login.xml | 190 ----------------- app/src/main/res/layout/fragment_main.xml | 141 ------------- app/src/main/res/layout/fragment_preview.xml | 71 ------- app/src/main/res/layout/fragment_share.xml | 93 --------- app/src/main/res/layout/fragment_splash.xml | 20 -- app/src/main/res/layout/view_item_snippet.xml | 95 --------- .../main/res/layout/view_item_user_share.xml | 38 ---- app/src/main/res/layout/view_loading.xml | 14 -- .../res/layout/view_outlined_text_input.xml | 20 -- .../main/res/layout/view_snippets_filter.xml | 52 ----- app/src/main/res/navigation/nav_graph.xml | 194 ------------------ 29 files changed, 3 insertions(+), 1790 deletions(-) delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/di/NavigatorModule.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/error/ErrorFragment.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/SnippetAdapter.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/splash/SplashFragment.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/splash/SplashNavigator.kt delete mode 100644 app/src/main/java/pl/tkadziolka/snipmeandroid/ui/splash/SplashViewModel.kt delete mode 100644 app/src/main/res/layout/activity_main.xml delete mode 100644 app/src/main/res/layout/fragment_contact.xml delete mode 100644 app/src/main/res/layout/fragment_detail.xml delete mode 100644 app/src/main/res/layout/fragment_donate.xml delete mode 100644 app/src/main/res/layout/fragment_edit.xml delete mode 100644 app/src/main/res/layout/fragment_error.xml delete mode 100644 app/src/main/res/layout/fragment_login.xml delete mode 100644 app/src/main/res/layout/fragment_main.xml delete mode 100644 app/src/main/res/layout/fragment_preview.xml delete mode 100644 app/src/main/res/layout/fragment_share.xml delete mode 100644 app/src/main/res/layout/fragment_splash.xml delete mode 100644 app/src/main/res/layout/view_item_snippet.xml delete mode 100644 app/src/main/res/layout/view_item_user_share.xml delete mode 100644 app/src/main/res/layout/view_loading.xml delete mode 100644 app/src/main/res/layout/view_outlined_text_input.xml delete mode 100644 app/src/main/res/layout/view_snippets_filter.xml delete mode 100644 app/src/main/res/navigation/nav_graph.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dd14f53..15656b7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - @@ -27,11 +26,10 @@ + android:theme="@style/Theme.SnipMe" + android:windowSoftInputMode="adjustResize" /> diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModel.kt index 01e2b6a..332a1f9 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModel.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/bridge/login/LoginModel.kt @@ -12,7 +12,6 @@ import pl.tkadziolka.snipmeandroid.domain.error.exception.* import pl.tkadziolka.snipmeandroid.domain.message.ErrorMessages import pl.tkadziolka.snipmeandroid.ui.error.ErrorParsable import pl.tkadziolka.snipmeandroid.ui.login.* -import pl.tkadziolka.snipmeandroid.ui.splash.NotAuthorized import pl.tkadziolka.snipmeandroid.util.extension.inProgress import timber.log.Timber import java.util.concurrent.TimeUnit diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/KoinConfig.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/KoinConfig.kt index 99e88f6..c025e8b 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/KoinConfig.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/KoinConfig.kt @@ -9,7 +9,6 @@ val koinModules = listOf( utilModule, useCaseModule, interactorModule, - navigatorModule, viewModelModule, modelModule ) \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/NavigatorModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/NavigatorModule.kt deleted file mode 100644 index 0f23c83..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/NavigatorModule.kt +++ /dev/null @@ -1,8 +0,0 @@ -package pl.tkadziolka.snipmeandroid.di - -import org.koin.dsl.module -import pl.tkadziolka.snipmeandroid.ui.splash.SplashNavigator - -internal val navigatorModule = module { - factory { SplashNavigator() } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt index 72ec73b..f21f99f 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/di/ViewModelModule.kt @@ -6,10 +6,8 @@ import pl.tkadziolka.snipmeandroid.ui.detail.DetailViewModel import pl.tkadziolka.snipmeandroid.ui.login.LoginViewModel import pl.tkadziolka.snipmeandroid.ui.main.MainViewModel import pl.tkadziolka.snipmeandroid.ui.session.SessionViewModel -import pl.tkadziolka.snipmeandroid.ui.splash.SplashViewModel internal val viewModelModule = module { - viewModel { SplashViewModel(get(), get()) } viewModel { LoginViewModel(get(), get(), get()) } viewModel { SessionViewModel(get()) } viewModel { MainViewModel(get(), get(), get(), get(), get(), get(), get()) } diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/error/ErrorFragment.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/error/ErrorFragment.kt deleted file mode 100644 index bf25448..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/error/ErrorFragment.kt +++ /dev/null @@ -1,48 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.error - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import kotlinx.android.synthetic.main.fragment_error.* -import pl.tkadziolka.snipmeandroid.BuildConfig -import pl.tkadziolka.snipmeandroid.R -import pl.tkadziolka.snipmeandroid.util.extension.navigateUpTo -import pl.tkadziolka.snipmeandroid.util.extension.onBackPressed -import pl.tkadziolka.snipmeandroid.util.extension.safeOpenWebsite -import pl.tkadziolka.snipmeandroid.util.extension.setOnClick - -class ErrorFragment: Fragment() { - - private val args: ErrorFragmentArgs by navArgs() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = inflater.inflate(R.layout.fragment_error, container, false) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - errorMessage.text = args.message ?: getString(R.string.error_message_generic) - - onBackPressed { navigateToPreviousSuccessScreen() } - errorClose.setOnClick { navigateToPreviousSuccessScreen() } - } - - private fun navigateToPreviousSuccessScreen() { - val nav = findNavController() - val previousNavigation = nav.previousBackStackEntry - val errorScreen = previousNavigation?.destination ?: return - when(errorScreen.id) { - // Error on main list blocks all app, so close it - R.id.main -> requireActivity().finishAndRemoveTask() - R.id.share -> nav.navigateUpTo(R.id.detail) - R.id.login -> nav.navigateUp() - else -> nav.navigateUpTo(R.id.main) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/error/ErrorParsable.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/error/ErrorParsable.kt index 66ec2d2..dfae779 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/error/ErrorParsable.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/error/ErrorParsable.kt @@ -1,6 +1,5 @@ package pl.tkadziolka.snipmeandroid.ui.error interface ErrorParsable { - fun parseError(throwable: Throwable) } \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainActivity.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainActivity.kt index 63b5753..c95ccb3 100644 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainActivity.kt +++ b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/MainActivity.kt @@ -18,7 +18,6 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) // Instantiate a FlutterEngine. flutterEngine = FlutterEngine(this) diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/SnippetAdapter.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/SnippetAdapter.kt deleted file mode 100644 index 61e2371..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/main/SnippetAdapter.kt +++ /dev/null @@ -1,78 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.main - -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.synthetic.main.view_item_snippet.view.* -import pl.tkadziolka.snipmeandroid.R -import pl.tkadziolka.snipmeandroid.domain.reaction.UserReaction -import pl.tkadziolka.snipmeandroid.domain.snippets.Snippet -import pl.tkadziolka.snipmeandroid.util.extension.inflate -import pl.tkadziolka.snipmeandroid.util.extension.setOnClick -import pl.tkadziolka.snipmeandroid.util.extension.tint - -typealias SnippetListener = (Snippet) -> Unit - -class SnippetAdapter( - private val clickListener: SnippetListener, - private val pressListener: SnippetListener -) : ListAdapter(SNIPPET_DIFF) { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SnippetHolder = - SnippetHolder(parent.inflate(R.layout.view_item_snippet), clickListener, pressListener) - - override fun onBindViewHolder(holder: SnippetHolder, position: Int) { - holder.bind(getItem(position)) - } - - class SnippetHolder( - view: View, - private val clickListener: SnippetListener, - private val pressListener: SnippetListener - ) : RecyclerView.ViewHolder(view) { - - private fun ImageView.accented() = tint(R.color.highlight) - - private fun ImageView.regular() = tint(R.color.white) - - fun bind(snippet: Snippet) = with(itemView) { - setOnClick { clickListener(snippet) } - setOnLongClickListener { pressListener(snippet); true } - snippetTitle.text = snippet.title - snippetLanguage.text = snippet.language.raw - with(snippetCode) { text = snippet.code.highlighted } - bindReactions(snippet) - } - - private fun View.bindReactions(snippet: Snippet) = with(snippet) { - val likeDiff = numberOfLikes - numberOfDislikes - snippetLikeCounter.text = likeDiff.toString() - when (snippet.userReaction) { - UserReaction.LIKE -> { - snippetLikeIndicator.accented() - snippetDislikeIndicator.regular() - } - UserReaction.DISLIKE -> { - snippetLikeIndicator.regular() - snippetDislikeIndicator.accented() - } - UserReaction.NONE -> { - snippetLikeIndicator.regular() - snippetDislikeIndicator.regular() - } - } - } - } -} - -private val SNIPPET_DIFF = object : DiffUtil.ItemCallback() { - - override fun areItemsTheSame(oldItem: Snippet, newItem: Snippet): Boolean = - oldItem.uuid == newItem.uuid - - override fun areContentsTheSame(oldItem: Snippet, newItem: Snippet): Boolean = - oldItem == newItem -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/splash/SplashFragment.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/splash/SplashFragment.kt deleted file mode 100644 index a9c1911..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/splash/SplashFragment.kt +++ /dev/null @@ -1,24 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.splash - -import androidx.navigation.fragment.findNavController -import pl.tkadziolka.snipmeandroid.ui.viewmodel.ViewModelFragment -import pl.tkadziolka.snipmeandroid.ui.viewmodel.observeNotNull - -class SplashFragment : ViewModelFragment(SplashViewModel::class) { - - override val layout: Int = pl.tkadziolka.snipmeandroid.R.layout.fragment_splash - - override fun onViewCreated() { - viewModel.init() - } - - override fun observeViewModel() { - viewModel.state.observeNotNull(this) { state -> - when(state) { - is Logged -> viewModel.goToMain(findNavController()) - else -> viewModel.goToLogin(findNavController()) - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/splash/SplashNavigator.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/splash/SplashNavigator.kt deleted file mode 100644 index 2021a87..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/splash/SplashNavigator.kt +++ /dev/null @@ -1,15 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.splash - -import androidx.navigation.NavController -import pl.tkadziolka.snipmeandroid.util.extension.navigateSafe - -class SplashNavigator { - - fun goToLogin(nav: NavController) { - nav.navigateSafe(SplashFragmentDirections.goToLogin()) - } - - fun goToMain(nav: NavController) { - nav.navigateSafe(SplashFragmentDirections.goToMain()) - } -} \ No newline at end of file diff --git a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/splash/SplashViewModel.kt b/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/splash/SplashViewModel.kt deleted file mode 100644 index 1555c2f..0000000 --- a/app/src/main/java/pl/tkadziolka/snipmeandroid/ui/splash/SplashViewModel.kt +++ /dev/null @@ -1,45 +0,0 @@ -package pl.tkadziolka.snipmeandroid.ui.splash - -import androidx.navigation.NavController -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.rxkotlin.plusAssign -import io.reactivex.rxkotlin.subscribeBy -import io.reactivex.schedulers.Schedulers -import pl.tkadziolka.snipmeandroid.domain.auth.InitialLoginUseCase -import pl.tkadziolka.snipmeandroid.domain.error.exception.NotAuthorizedException -import pl.tkadziolka.snipmeandroid.ui.viewmodel.StateViewModel -import timber.log.Timber -import java.util.concurrent.TimeUnit - -class SplashViewModel( - private val initialLogin: InitialLoginUseCase, - private val navigator: SplashNavigator -): StateViewModel() { - - override fun init() { - initialLogin() - .delay(1, TimeUnit.SECONDS) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy( - onComplete = { setState(Logged) }, - onError = { - if (it !is NotAuthorizedException) - Timber.e("Couldn't get token or user, error = $it") - setState(NotAuthorized) - } - ).also { disposables += it } - } - - fun goToLogin(nav: NavController) { - navigator.goToLogin(nav) - } - - fun goToMain(nav: NavController) { - navigator.goToMain(nav) - } -} - -sealed class SplashViewState -object NotAuthorized: SplashViewState() -object Logged: SplashViewState() \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index afc75e1..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_contact.xml b/app/src/main/res/layout/fragment_contact.xml deleted file mode 100644 index 5b0e881..0000000 --- a/app/src/main/res/layout/fragment_contact.xml +++ /dev/null @@ -1,106 +0,0 @@ - - - - - - - - - -